diff --git a/.eslintrc.js b/.eslintrc.js index 2ce6d279d93a9..dc2eaa993ce8b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -109,7 +109,7 @@ module.exports = { }, }, { - files: ['x-pack/legacy/plugins/lens/**/*.{js,ts,tsx}'], + files: ['x-pack/plugins/lens/**/*.{js,ts,tsx}'], rules: { 'react-hooks/exhaustive-deps': 'off', 'react-hooks/rules-of-hooks': 'off', @@ -536,9 +536,15 @@ module.exports = { * ML overrides */ { - files: ['x-pack/legacy/plugins/ml/**/*.js'], + files: ['x-pack/plugins/ml/**/*.js'], rules: { 'no-shadow': 'error', + 'import/no-extraneous-dependencies': [ + 'error', + { + packageDir: './x-pack', + }, + ], }, }, @@ -728,7 +734,7 @@ module.exports = { * Lens overrides */ { - files: ['x-pack/legacy/plugins/lens/**/*.{ts,tsx}', 'x-pack/plugins/lens/**/*.{ts,tsx}'], + files: ['x-pack/plugins/lens/**/*.{ts,tsx}'], rules: { '@typescript-eslint/no-explicit-any': 'error', }, diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e707250ff3261..985eb5c1342a0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,22 +3,24 @@ # For more info, see https://help.github.com/articles/about-codeowners/ # App -/x-pack/legacy/plugins/lens/ @elastic/kibana-app -/x-pack/legacy/plugins/graph/ @elastic/kibana-app +/x-pack/plugins/lens/ @elastic/kibana-app +/x-pack/plugins/graph/ @elastic/kibana-app /src/legacy/server/url_shortening/ @elastic/kibana-app /src/legacy/server/sample_data/ @elastic/kibana-app /src/legacy/core_plugins/kibana/public/dashboard/ @elastic/kibana-app /src/legacy/core_plugins/kibana/public/discover/ @elastic/kibana-app -/src/legacy/core_plugins/kibana/public/visualize/ @elastic/kibana-app /src/legacy/core_plugins/kibana/public/local_application_service/ @elastic/kibana-app /src/legacy/core_plugins/kibana/public/dev_tools/ @elastic/kibana-app -/src/legacy/core_plugins/metrics/ @elastic/kibana-app /src/legacy/core_plugins/vis_type_vislib/ @elastic/kibana-app -/src/legacy/core_plugins/vis_type_xy/ @elastic/kibana-app +/src/plugins/vis_type_xy/ @elastic/kibana-app /src/plugins/kibana_legacy/ @elastic/kibana-app /src/plugins/timelion/ @elastic/kibana-app /src/plugins/dashboard/ @elastic/kibana-app /src/plugins/discover/ @elastic/kibana-app +/src/plugins/visualize/ @elastic/kibana-app +/src/plugins/vis_type_timeseries/ @elastic/kibana-app +/src/plugins/vis_type_metric/ @elastic/kibana-app +/src/plugins/vis_type_markdown/ @elastic/kibana-app # Core UI # Exclude tutorials folder for now because they are not owned by Kibana app and most will move out soon @@ -80,6 +82,8 @@ /x-pack/legacy/plugins/ingest_manager/ @elastic/ingest-management /x-pack/plugins/observability/ @elastic/logs-metrics-ui @elastic/apm-ui @elastic/uptime @elastic/ingest-management /x-pack/legacy/plugins/monitoring/ @elastic/stack-monitoring-ui +/x-pack/legacy/plugins/uptime @elastic/uptime +/x-pack/plugins/uptime @elastic/uptime # Machine Learning /x-pack/legacy/plugins/ml/ @elastic/ml-ui @@ -95,6 +99,7 @@ # Maps /x-pack/legacy/plugins/maps/ @elastic/kibana-gis +/x-pack/plugins/maps/ @elastic/kibana-gis /x-pack/test/api_integration/apis/maps/ @elastic/kibana-gis /x-pack/test/functional/apps/maps/ @elastic/kibana-gis /x-pack/test/functional/es_archives/maps/ @elastic/kibana-gis @@ -127,7 +132,6 @@ /packages/kbn-config-schema/ @elastic/kibana-platform /src/legacy/server/config/ @elastic/kibana-platform /src/legacy/server/http/ @elastic/kibana-platform -/src/legacy/server/i18n/ @elastic/kibana-platform /src/legacy/server/logging/ @elastic/kibana-platform /src/legacy/server/saved_objects/ @elastic/kibana-platform /src/legacy/server/status/ @elastic/kibana-platform @@ -146,7 +150,10 @@ /x-pack/test/api_integration/apis/security/ @elastic/kibana-security # Kibana Localization -/src/dev/i18n/ @elastic/kibana-localization +/src/dev/i18n/ @elastic/kibana-localization +/src/legacy/server/i18n/ @elastic/kibana-localization +/src/core/public/i18n/ @elastic/kibana-localization +/packages/kbn-i18n/ @elastic/kibana-localization # Pulse /packages/kbn-analytics/ @elastic/pulse @@ -180,7 +187,7 @@ /src/plugins/console/ @elastic/es-ui /src/plugins/es_ui_shared/ @elastic/es-ui /x-pack/legacy/plugins/cross_cluster_replication/ @elastic/es-ui -/x-pack/legacy/plugins/index_lifecycle_management/ @elastic/es-ui +/x-pack/plugins/index_lifecycle_management/ @elastic/es-ui /x-pack/legacy/plugins/index_management/ @elastic/es-ui /x-pack/legacy/plugins/license_management/ @elastic/es-ui /x-pack/legacy/plugins/rollup/ @elastic/es-ui @@ -202,9 +209,12 @@ # Endpoint /x-pack/plugins/endpoint/ @elastic/endpoint-app-team /x-pack/test/api_integration/apis/endpoint/ @elastic/endpoint-app-team +/x-pack/test/endpoint_api_integration_no_ingest/ @elastic/endpoint-app-team /x-pack/test/functional_endpoint/ @elastic/endpoint-app-team /x-pack/test/functional_endpoint_ingest_failure/ @elastic/endpoint-app-team /x-pack/test/functional/es_archives/endpoint/ @elastic/endpoint-app-team +/x-pack/test/plugin_functional/plugins/resolver_test/ @elastic/endpoint-app-team +/x-pack/test/plugin_functional/test_suites/resolver/ @elastic/endpoint-app-team # SIEM /x-pack/legacy/plugins/siem/ @elastic/siem diff --git a/.github/paths-labeller.yml b/.github/paths-labeller.yml index 3d35cd74e0718..544dd577313df 100644 --- a/.github/paths-labeller.yml +++ b/.github/paths-labeller.yml @@ -8,3 +8,6 @@ - "Feature:ExpressionLanguage": - "src/plugins/expressions/**/*.*" - "src/plugins/bfetch/**/*.*" + - "Team:uptime": + - "x-pack/plugins/uptime/**/*.*" + - "x-pack/legacy/plugins/uptime/**/*.*" diff --git a/.i18nrc.json b/.i18nrc.json index 19d361aed9344..70277112136a9 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -24,6 +24,7 @@ "src/legacy/core_plugins/management", "src/plugins/management" ], + "maps_legacy": "src/plugins/maps_legacy", "indexPatternManagement": "src/plugins/index_pattern_management", "advancedSettings": "src/plugins/advanced_settings", "kibana_legacy": "src/plugins/kibana_legacy", @@ -45,15 +46,16 @@ "timelion": ["src/legacy/core_plugins/timelion", "src/legacy/core_plugins/vis_type_timelion", "src/plugins/timelion"], "uiActions": "src/plugins/ui_actions", "visDefaultEditor": "src/plugins/vis_default_editor", - "visTypeMarkdown": "src/legacy/core_plugins/vis_type_markdown", - "visTypeMetric": "src/legacy/core_plugins/vis_type_metric", + "visTypeMarkdown": "src/plugins/vis_type_markdown", + "visTypeMetric": "src/plugins/vis_type_metric", "visTypeTable": "src/legacy/core_plugins/vis_type_table", "visTypeTagCloud": "src/legacy/core_plugins/vis_type_tagcloud", "visTypeTimeseries": ["src/legacy/core_plugins/vis_type_timeseries", "src/plugins/vis_type_timeseries"], "visTypeVega": "src/legacy/core_plugins/vis_type_vega", "visTypeVislib": "src/legacy/core_plugins/vis_type_vislib", "visTypeXy": "src/legacy/core_plugins/vis_type_xy", - "visualizations": "src/plugins/visualizations" + "visualizations": "src/plugins/visualizations", + "visualize": "src/plugins/visualize" }, "exclude": [ "src/legacy/ui/ui_render/ui_render_mixin.js" diff --git a/.sass-lint.yml b/.sass-lint.yml index dd7bc0576692b..5c2c88a1dad5d 100644 --- a/.sass-lint.yml +++ b/.sass-lint.yml @@ -8,10 +8,11 @@ files: - 'x-pack/legacy/plugins/security/**/*.s+(a|c)ss' - 'x-pack/legacy/plugins/canvas/**/*.s+(a|c)ss' - 'x-pack/plugins/triggers_actions_ui/**/*.s+(a|c)ss' + - 'x-pack/plugins/lens/**/*.s+(a|c)ss' + - 'x-pack/legacy/plugins/maps/**/*.s+(a|c)ss' + - 'x-pack/plugins/maps/**/*.s+(a|c)ss' ignore: - 'x-pack/legacy/plugins/canvas/shareable_runtime/**/*.s+(a|c)ss' - - 'x-pack/legacy/plugins/lens/**/*.s+(a|c)ss' - - 'x-pack/legacy/plugins/maps/**/*.s+(a|c)ss' rules: quotes: - 2 diff --git a/docs/api/saved-objects/bulk_create.asciidoc b/docs/api/saved-objects/bulk_create.asciidoc index 9daba224b317c..fbd4c6e77f8bf 100644 --- a/docs/api/saved-objects/bulk_create.asciidoc +++ b/docs/api/saved-objects/bulk_create.asciidoc @@ -104,7 +104,7 @@ The API returns the following: "type": "dashboard", "error": { "statusCode": 409, - "message": "version conflict, document already exists" + "message": "Saved object [dashboard/be3733a0-9efe-11e7-acb3-3dab96693fab] conflict" } } ] diff --git a/docs/apm/advanced-queries.asciidoc b/docs/apm/advanced-queries.asciidoc index add6f601489e1..f89f994e59e57 100644 --- a/docs/apm/advanced-queries.asciidoc +++ b/docs/apm/advanced-queries.asciidoc @@ -1,14 +1,11 @@ +[role="xpack"] [[advanced-queries]] -=== Advanced queries +=== Query your data -When querying in the APM app, you're simply searching and selecting data from fields in Elasticsearch documents. -Queries entered into the query bar are also added as parameters to the URL, -so it's easy to share a specific query or view with others. - -You can begin to see some of the transaction fields available for filtering: - -[role="screenshot"] -image::apm/images/apm-query-bar.png[Example of the Kibana Query bar in APM app in Kibana] +Querying your APM data is a powerful tool that can make finding bottlenecks in your code even easier. +Imagine you have a user that complains about a slow response time in a specific service. +With the query bar, you can easily filter the APM app to only display trace data for that user, +or, to only show transactions that are slower than a specified time threshold. [float] ==== Example APM app queries @@ -17,15 +14,24 @@ image::apm/images/apm-query-bar.png[Example of the Kibana Query bar in APM app i * Filter by response status code: `context.response.status_code >= 400` * Filter by single user ID: `context.user.id : 12` +When querying in the APM app, you're merely searching and selecting data from fields in Elasticsearch documents. +Queries entered into the query bar are also added as parameters to the URL, +so it's easy to share a specific query or view with others. + +When you type, you can begin to see some of the transaction fields available for filtering: + +[role="screenshot"] +image::apm/images/apm-query-bar.png[Example of the Kibana Query bar in APM app in Kibana] + TIP: Read the {kibana-ref}/kuery-query.html[Kibana Query Language Enhancements] documentation to learn more about the capabilities of the {kib} query language. [float] [[discover-advanced-queries]] === Querying in Discover -It may also be helpful to view your APM data in {kibana-ref}/discover.html[*Discover*]. +Alternatively, you can query your APM documents in {kibana-ref}/discover.html[*Discover*]. Querying documents in *Discover* works the same way as querying in the APM app, -and all of the example APM app queries can also be used in *Discover*. +and *Discover* supports all of the example APM app queries shown on this page. [float] ==== Example Discover query @@ -33,7 +39,7 @@ and all of the example APM app queries can also be used in *Discover*. One example where you may want to make use of *Discover*, is for viewing _all_ transactions for an endpoint, instead of just a sample. -TIP: Starting in v7.6, you can view 10 samples per bucket in the APM app, instead of just one. +TIP: Starting in v7.6, you can view ten samples per bucket in the APM app, instead of just one. Use the APM app to find a transaction name and time bucket that you're interested in learning more about. Then, switch to *Discover* and make a search: diff --git a/docs/apm/agent-configuration.asciidoc b/docs/apm/agent-configuration.asciidoc index 0d2834c1a400e..d911c2154ea4c 100644 --- a/docs/apm/agent-configuration.asciidoc +++ b/docs/apm/agent-configuration.asciidoc @@ -1,12 +1,16 @@ [role="xpack"] [[agent-configuration]] -=== APM Agent configuration +=== APM Agent central configuration -APM Agent configuration allows you to fine-tune your agent configuration directly in Kibana. -Best of all, changes are automatically propagated to your APM agents so there's no need to redeploy. +++++ +Configure APM agents with central config +++++ -To get started, simply choose the services and environments you wish to configure. -The APM app will let you know when your configurations have been applied by your agents. +APM Agent configuration allows you to fine-tune your agent configuration from within the APM app. +Changes are automatically propagated to your APM agents, so there's no need to redeploy. + +To get started, choose the services and environments you wish to configure. +The APM app will let you know when your agents have applied your configurations. [role="screenshot"] image::apm/images/apm-agent-configuration.png[APM Agent configuration in Kibana] @@ -14,29 +18,28 @@ image::apm/images/apm-agent-configuration.png[APM Agent configuration in Kibana] [float] ==== Precedence -Configurations set with APM Agent configuration take precedence over configurations set locally in the Agent. +Configurations set from the APM app take precedence over configurations set locally in each Agent. However, if APM Server is slow to respond, is offline, reports an error, etc., APM agents will use local defaults until they're able to update the configuration. -For this reason, it is still important to set custom default configurations locally in each of your agents. +For this reason, it is still essential to set custom default configurations locally in each of your agents. [float] ==== APM Server setup This feature requires {apm-server-ref}/setup-kibana-endpoint.html[Kibana endpoint configuration] in APM Server. -Why is additional configuration needed in APM Server? -That's because APM Server acts as a proxy between the agents and Kibana. +APM Server acts as a proxy between the agents and Kibana. Kibana communicates any changed settings to APM Server so that your agents only need to poll APM Server to determine which settings have changed. [float] ==== Supported configurations -Each Agent has its own list of supported configurations. +Each Agent has a list of supported configurations. After selecting a Service name and environment in the APM app, -a list of all available configuration options, +a list of all supported configuration options, including descriptions and default values, will be displayed. -Supported configurations are also marked in each Agent's configuration documentation: +Supported configurations are also tagged with the image:./images/dynamic-config.svg[] badge in each Agent's configuration reference: [horizontal] Go Agent:: {apm-go-ref}/configuration.html[Configuration reference] diff --git a/docs/apm/api.asciidoc b/docs/apm/api.asciidoc index a8f4f4bf0baaa..93733f5990a46 100644 --- a/docs/apm/api.asciidoc +++ b/docs/apm/api.asciidoc @@ -1,6 +1,10 @@ [role="xpack"] [[apm-api]] -== API +== APM app API + +++++ +REST API +++++ Some APM app features are provided via a REST API: diff --git a/docs/apm/apm-alerts.asciidoc b/docs/apm/apm-alerts.asciidoc index b8552c007b13d..75ce5f56c96c6 100644 --- a/docs/apm/apm-alerts.asciidoc +++ b/docs/apm/apm-alerts.asciidoc @@ -1,12 +1,16 @@ [role="xpack"] [[apm-alerts]] -=== Create an alert +=== Alerts + +++++ +Create an alert +++++ beta::[] -The APM app is integrated with Kibana's {kibana-ref}/alerting-getting-started.html[alerting and actions] feature. -It provides a set of built-in **actions** and APM specific threshold **alerts** for you to use, -and allows all alerts to be centrally managed from <>. +The APM app integrates with Kibana's {kibana-ref}/alerting-getting-started.html[alerting and actions] feature. +It provides a set of built-in **actions** and APM specific threshold **alerts** for you to use +and enables central management of all alerts from <>. [role="screenshot"] image::apm/images/apm-alert.png[Create an alert in the APM app] @@ -28,9 +32,9 @@ This guide creates an alert for the `opbeans-java` service based on the followin From the APM app, navigate to the `opbeans-java` service and select **Alerts** > **Create threshold alert** > **Transaction duration**. -The name of your alert will automatically be set as `Transaction duration | opbeans-java`, -and the alert will be tagged with `apm` and `service.name:opbeans-java`. -Feel free to edit either of these defaults. +`Transaction duration | opbeans-java` is automatically set as the name of the alert, +and `apm` and `service.name:opbeans-java` are added as tags. +It's fine to change the name of the alert, but do not edit the tags. Based on the alert criteria, define the following alert details: @@ -42,7 +46,7 @@ Based on the alert criteria, define the following alert details: * **FOR THE LAST** - `5 minutes` Select an action type. -Multiple action types can be selected, but in this example we want to post to a slack channel. +Multiple action types can be selected, but in this example, we want to post to a Slack channel. Select **Slack** > **Create a connector**. Enter a name for the connector, and paste the webhook URL. @@ -63,9 +67,9 @@ This guide creates an alert for the `opbeans-python` service based on the follow From the APM app, navigate to the `opbeans-python` service and select **Alerts** > **Create threshold alert** > **Error rate**. -The name of your alert will automatically be set as `Error rate | opbeans-python`, -and the alert will be tagged with `apm` and `service.name:opbeans-python`. -Feel free to edit either of these defaults. +`Error rate | opbeans-python` is automatically set as the name of the alert, +and `apm` and `service.name:opbeans-python` are added as tags. +It's fine to change the name of the alert, but do not edit the tags. Based on the alert criteria, define the following alert details: @@ -93,5 +97,5 @@ From this page, you can create, edit, disable, mute, and delete alerts, and crea See {kibana-ref}/alerting-getting-started.html[alerting and actions] for more information. NOTE: If you are using an **on-premise** Elastic Stack deployment with security, -TLS must be configured for communication between Elasticsearch and Kibana. +communication between Elasticsearch and Kibana must have TLS configured. More information is in the alerting {kibana-ref}/alerting-getting-started.html#alerting-setup-prerequisites[prerequisites]. \ No newline at end of file diff --git a/docs/apm/bottlenecks.asciidoc b/docs/apm/bottlenecks.asciidoc deleted file mode 100644 index fbde3e9ddcbd6..0000000000000 --- a/docs/apm/bottlenecks.asciidoc +++ /dev/null @@ -1,25 +0,0 @@ -[role="xpack"] -[[apm-bottlenecks]] -== Visualizing Application Bottlenecks - -Elastic APM captures different types of information from within instrumented applications: - -* {apm-overview-ref-v}/transaction-spans.html[*Spans*] contain information about a specific code path that has been executed. -They measure from the start to end of an activity, -and they can have a parent/child relationship with other spans. -* {apm-overview-ref-v}/transactions.html[*Transactions*] are a special kind of span that have extra metadata associated with them. -You can think of transactions as the highest level of work you’re measuring within a service. -As an example, a transaction could be a request to your server, a batch job, or a custom transaction type. -* {apm-overview-ref-v}/errors.html[*Errors*] contain information about the original exception that occurred or about a log created when the exception occurred. - -Each of these information types have a specific page associated with them in the APM app. -These various pages display the captured data in curated charts and tables that allow you to easily compare and debug your applications. - -For example, you can see information about response times, requests per minute, and status codes per endpoint. -You can even dive into a specific request sample and get a complete waterfall view of what your application is spending its time on. -You might see that your bottlenecks are in database queries, cache calls, or external requests. -For each incoming request and each application error, -you can also see contextual information such as the request header, user information, -system values, or custom data that you manually attached to the request. - -Having access to application-level insights with just a few clicks can drastically decrease the time you spend debugging errors, slow response times, and crashes. diff --git a/docs/apm/custom-links.asciidoc b/docs/apm/custom-links.asciidoc index 75c1c9d0009a2..4fdf39b643f94 100644 --- a/docs/apm/custom-links.asciidoc +++ b/docs/apm/custom-links.asciidoc @@ -1,6 +1,11 @@ +[role="xpack"] [[custom-links]] === Custom links +++++ +Create custom links +++++ + Elastic's custom link feature allows you to easily create up to 500 dynamic links based on your specific APM data. Custom links can be filtered to only appear in the APM app for relevant services, @@ -12,7 +17,7 @@ Ready to dive in? Jump straight to the <>. [[custom-links-create]] === Create a link -Each custom link consists of a label, url, and optional filter. +Each custom link consists of a label, URL, and optional filter. The easiest way to create a custom link is from within the actions dropdown in the transaction detail page. This method will automatically apply filters, scoping the link to that specific service, environment, transaction type, and transaction name. @@ -25,8 +30,7 @@ and selecting **Create custom link**. ==== Label The name of your custom link. -This text will be shown in the actions context menu, -so keep it as short as possible. +The actions context menu displays this text, so keep it as short as possible. TIP: Custom links are displayed alphabetically in the actions menu. @@ -39,8 +43,8 @@ URLs support dynamic field name variables, encapsulated in double curly brackets These variables will be replaced with transaction metadata when the link is clicked. Because everyone's data is different, -you'll need to examine your own traces to see what metadata is available for use. -The easiest way to do this is to select a trace in the APM app, and click **Metadata** in the **Trace Sample** table. +you'll need to examine your traces to see what metadata is available for use. +To do this, select a trace in the APM app, and click **Metadata** in the **Trace Sample** table. [role="screenshot"] image::apm/images/example-metadata.png[Example metadata] @@ -49,7 +53,7 @@ image::apm/images/example-metadata.png[Example metadata] [[custom-links-filters]] ==== Filters -Filter each link to only appear so it only appears for specific services or transactions. +Filter each link to only appear for specific services or transactions. You can filter on the following fields: * `service.name` @@ -57,7 +61,7 @@ You can filter on the following fields: * `transaction.type` * `transaction.name` -Multiple values are allowed when comma separated. +Multiple values are allowed when comma-separated. [float] [[custom-links-examples]] @@ -68,7 +72,7 @@ Multiple values are allowed when comma separated. :github-query-params: https://help.github.com/en/github/managing-your-work-on-github/about-automation-for-issues-and-pull-requests-with-query-parameters Not sure where to start with custom links? -Take a look at the examples below, and customize them to your liking! +Take a look at the examples below and customize them to your liking! [float] [[custom-links-examples-email]] diff --git a/docs/apm/deployment-annotations.asciidoc b/docs/apm/deployment-annotations.asciidoc new file mode 100644 index 0000000000000..6feadf8463226 --- /dev/null +++ b/docs/apm/deployment-annotations.asciidoc @@ -0,0 +1,17 @@ +[role="xpack"] +[[transactions-annotations]] +=== Track deployments with annotations + +++++ +Track deployments +++++ + +For enhanced visibility into your deployments, we offer deployment annotations on all transaction charts. +This feature automatically tags new deployments, so you can easily see if your deploy has increased response times +for an end-user, or if the memory/CPU footprint of your application has changed. +Being able to identify bad deployments quickly enables you to rollback and fix issues without causing costly outages. + +Deployment annotations are automatically enabled, and appear when the `service.version` of your app changes. + +[role="screenshot"] +image::apm/images/apm-transaction-annotation.png[Example view of transactions annotation in the APM app in Kibana] diff --git a/docs/apm/error-reports-watcher.asciidoc b/docs/apm/error-reports-watcher.asciidoc new file mode 100644 index 0000000000000..f41597932b751 --- /dev/null +++ b/docs/apm/error-reports-watcher.asciidoc @@ -0,0 +1,18 @@ +[role="xpack"] +[[errors-alerts-with-watcher]] +=== Error reports with Watcher + +++++ +Enable error reports +++++ + +You can use the power of the alerting features with Watcher to get reports on error occurrences. +The Watcher assistant, which is available on the errors overview, can help you set up a watch per service. + +Configure the watch with an occurrences threshold, time interval, and the desired actions, such as email or Slack notifications. +With Watcher, your team can set up reports within minutes. + +Watches are managed separately in the dedicated Watcher UI available in Advanced Settings. + +[role="screenshot"] +image::apm/images/apm-errors-watcher-assistant.png[Example view of the Watcher assistant for errors in APM app in Kibana] diff --git a/docs/apm/errors.asciidoc b/docs/apm/errors.asciidoc index 689fa1fffa89e..49351ec255858 100644 --- a/docs/apm/errors.asciidoc +++ b/docs/apm/errors.asciidoc @@ -1,7 +1,8 @@ +[role="xpack"] [[errors]] === Errors overview -TIP: {apm-overview-ref-v}/errors.html[Errors] are defined as groups of exceptions with matching exception or log messages. +TIP: {apm-overview-ref-v}/errors.html[Errors] are groups of exceptions with a similar exception or log message. The *Errors* overview provides a high-level view of the error message and culprit, the number of occurrences, and the most recent occurrence. @@ -20,7 +21,7 @@ image::apm/images/apm-error-group.png[Example view of the error group page in th Here, you'll see the error message, culprit, and the number of occurrences over time. Further down, you'll see the Error occurrence table. -This is where you can see the details of a sampled error within this group. +This table shows the details of a sampled error within this group. The error shown is always the most recent to occur. Each error occurrence features a breakdown of the exception, including the stack trace from when the error occurred, @@ -28,19 +29,4 @@ and additional contextual information to help debug the issue. In some cases, you might also see a Transaction sample ID. This feature allows you to make a connection between the errors and transactions, by linking you to the specific transaction where the error occurred. -This allows you to see the whole trace, including which services the request went through. - -[float] -[[errors-alerts-with-watcher]] -==== Error reports with Watcher - -You can use the power of the alerting features with Watcher to get reports on error occurrences. -The Watcher assistant, which is available on the errors overview, can help you set up a watch per service. - -Configure the watch with an occurrences threshold, time interval, and the desired actions, such as email or Slack notifications. -With Watcher, your team can set up reports within minutes. - -Watches are managed separately in the dedicated Watcher UI available in Advanced Settings. - -[role="screenshot"] -image::apm/images/apm-errors-watcher-assistant.png[Example view of the Watcher assistant for errors in APM app in Kibana] \ No newline at end of file +This allows you to see the whole trace, including which services the request went through. diff --git a/docs/apm/filters.asciidoc b/docs/apm/filters.asciidoc index 99ba827b0198d..d53adb439f0c8 100644 --- a/docs/apm/filters.asciidoc +++ b/docs/apm/filters.asciidoc @@ -1,6 +1,11 @@ +[role="xpack"] [[filters]] === Filters +++++ +Filter data +++++ + APM provides two different ways you can filter your data within the APM App: * <> @@ -42,7 +47,7 @@ It allows you to view only relevant data, and is especially useful for separatin By default, all environments are displayed. If there are no environment options, you'll see "not defined". Service environments are defined when configuring your APM agents. -It's very important to be consistent when naming environments in your agents. +It's vital to be consistent when naming environments in your agents. See the documentation for each agent you're using to learn how to configure service environments: * *Go:* {apm-go-ref}/configuration.html#config-environment[`ELASTIC_APM_ENVIRONMENT`] @@ -62,9 +67,9 @@ but only where they are applicable -- they are typically most useful in their or As an example, if you select a host on the Services overview, then select a transaction group, the host filter will still be applied. -These filters are very useful for quickly and easily removing noise from your data. +These filters are very useful for quickly and easily removing noise from your data. With just a click, you can filter your transactions by the transaction result, -host, container ID, and more. +host, container ID, and more. [role="screenshot"] image::apm/images/local-filter.png[Local filters available in the APM app in Kibana] \ No newline at end of file diff --git a/docs/apm/getting-started.asciidoc b/docs/apm/getting-started.asciidoc index 4a391f1a49672..89ce0be1499c5 100644 --- a/docs/apm/getting-started.asciidoc +++ b/docs/apm/getting-started.asciidoc @@ -1,22 +1,45 @@ [role="xpack"] [[apm-getting-started]] -== Getting Started +== Get started with the APM app -If you have not already installed and configured Elastic APM, -the *Setup Instructions* will get you started. +++++ +Get started +++++ -[role="screenshot"] -image::apm/images/apm-setup.png[Installation instructions on the APM page in Kibana] +Elastic APM captures different types of information from within instrumented applications: +* *Spans* contain information about the execution of a specific code path. +They measure from the start to end of an activity, +and they can have a parent/child relationship with other spans. +* *Transactions* are a special kind of span; +they are the first span for a particular service and have extra metadata associated with them. +As an example, a transaction could be a request to your server, a batch job, or a custom transaction type. +*Traces* link together related transactions to show an end-to-end performance of how a request was served and which services were part of it. +* *Errors* contain information about the original exception that occurred or about a log created when the exception occurred. -Index patterns tell Kibana which Elasticsearch indices you want to explore. -An APM index pattern is necessary for certain features in the APM app, like the query bar. -To set up the correct index pattern, -simply click *Load Kibana objects* at the bottom of the Setup Instructions. +Curated charts and tables display the different types of APM data, which allows you to compare and debug your applications easily. -After you install an Elastic APM agent library in your application, -the application automatically appears in the APM app in {kib}. -No further configuration is required. +* <> +* <> +* <> +* <> +* <> +* <> +* <> -[role="screenshot"] -image::apm/images/apm-index-pattern.png[Setup index pattern for APM in Kibana] +TIP: Want to learn more about the Elastic APM ecosystem? +See the {apm-get-started-ref}/overview.html[APM Overview]. + +include::services.asciidoc[] + +include::traces.asciidoc[] + +include::transactions.asciidoc[] + +include::spans.asciidoc[] + +include::errors.asciidoc[] + +include::metrics.asciidoc[] + +include::service-maps.asciidoc[] diff --git a/docs/apm/how-to-guides.asciidoc b/docs/apm/how-to-guides.asciidoc new file mode 100644 index 0000000000000..9b0efb4f7a359 --- /dev/null +++ b/docs/apm/how-to-guides.asciidoc @@ -0,0 +1,32 @@ +[role="xpack"] +[[apm-how-to]] +== How-to guides + +Learn how to perform common APM app tasks. + + +* <> +* <> +* <> +* <> +* <> +* <> +* <> +* <> + + +include::agent-configuration.asciidoc[] + +include::apm-alerts.asciidoc[] + +include::custom-links.asciidoc[] + +include::error-reports-watcher.asciidoc[] + +include::filters.asciidoc[] + +include::machine-learning.asciidoc[] + +include::advanced-queries.asciidoc[] + +include::deployment-annotations.asciidoc[] \ No newline at end of file diff --git a/docs/apm/images/dynamic-config.svg b/docs/apm/images/dynamic-config.svg new file mode 100644 index 0000000000000..df62a3c84f4b4 --- /dev/null +++ b/docs/apm/images/dynamic-config.svg @@ -0,0 +1 @@ + DynamicDynamic \ No newline at end of file diff --git a/docs/apm/index.asciidoc b/docs/apm/index.asciidoc index d3f0dc5b7f11f..79190efccdff2 100644 --- a/docs/apm/index.asciidoc +++ b/docs/apm/index.asciidoc @@ -4,25 +4,35 @@ [partintro] -- -Elastic Application Performance Monitoring (APM) automatically collects in-depth -performance metrics and errors from inside your applications. - -The **APM** app in {kib} is provided with the basic license. It -enables developers to drill down into the performance data for their applications -and quickly locate the performance bottlenecks. - -* <> -* <> -* <> - -NOTE: For more information about the components of Elastic APM, -see the {apm-get-started-ref}/overview.html[APM Overview]. +The APM app in {kib} is provided with the basic license. +It allows you to monitor your software services and applications in real-time; +visualize detailed performance information on your services, +identify and analyze errors, +and monitor host-level and agent-specific metrics like JVM and Go runtime metrics. + +[float] +[[apm-bottlenecks]] +== Visualizing application bottlenecks + +Having access to application-level insights with just a few clicks can drastically decrease the time you spend +debugging errors, slow response times, and crashes. + +For example, you can see information about response times, requests per minute, and status codes per endpoint. +You can even dive into a specific request sample and get a complete waterfall view of what your application is spending its time on. +You might see that your bottlenecks are in database queries, cache calls, or external requests. +For each incoming request and each application error, +you can also see contextual information such as the request header, user information, +system values, or custom data that you manually attached to the request. -- +include::set-up.asciidoc[] + include::getting-started.asciidoc[] -include::bottlenecks.asciidoc[] +include::how-to-guides.asciidoc[] -include::using-the-apm-ui.asciidoc[] +include::settings.asciidoc[] include::api.asciidoc[] + +include::troubleshooting.asciidoc[] diff --git a/docs/apm/machine-learning.asciidoc b/docs/apm/machine-learning.asciidoc new file mode 100644 index 0000000000000..9d347fc4f1111 --- /dev/null +++ b/docs/apm/machine-learning.asciidoc @@ -0,0 +1,27 @@ +[role="xpack"] +[[machine-learning-integration]] +=== Machine Learning integration + +++++ +Integrate with machine learning +++++ + +The Machine Learning integration will initiate a new job predefined to calculate anomaly scores on transaction response times. +The response time graph will show the expected bounds and add an annotation when the anomaly score is 75 or above. +Jobs can be created per transaction type, and based on the average response time. +Manage jobs in the *Machine Learning jobs management*. + +[role="screenshot"] +image::apm/images/apm-ml-integration.png[Example view of anomaly scores on response times in APM app in Kibana] + +[float] +[[create-ml-integration]] +=== Create a new machine learning job + +To enable machine learning anomaly detection, first choose a service to monitor. +Then, select **Integrations** > **Enable ML anomaly detection** and click **Create job**. +That's it! After a few minutes, the job will begin calculating results; +it might take additional time for results to appear on your graph. + +APM specific anomaly detection wizards are also available for certain Agents. +See the machine learning {ml-docs}/ootb-ml-jobs-apm.html[APM anomaly detection configurations] for more information. diff --git a/docs/apm/metrics.asciidoc b/docs/apm/metrics.asciidoc index ab394b785ef84..e82a4fbd5c291 100644 --- a/docs/apm/metrics.asciidoc +++ b/docs/apm/metrics.asciidoc @@ -1,3 +1,4 @@ +[role="xpack"] [[metrics]] === Metrics overview @@ -5,7 +6,7 @@ The *Metrics* overview provides agent-specific metrics, which lets you perform more in-depth root cause analysis investigations within the APM app. If you're experiencing a problem with your service, you can use this page to attempt to find the underlying cause. -For example, you might be able to correlate a high number of errors with a long transaction duration, high CPU usage, or a memory leak. +For example, you might be able to correlate a high number of errors with a long transaction duration, high CPU usage, or a memory leak. [role="screenshot"] image::apm/images/apm-metrics.png[Example view of the Metrics overview in APM app in Kibana] @@ -17,19 +18,3 @@ thread count, garbage collection rate, and garbage collection time spent per min [role="screenshot"] image::apm/images/jvm-metrics.png[Example view of the Metrics overview for the Java Agent] - -[[machine-learning-integration]] -=== Machine Learning integration - -The Machine Learning integration will initiate a new job predefined to calculate anomaly scores on transaction response times. -The response time graph will show the expected bounds and annotate the graph when the anomaly score is 75 or above. - -[role="screenshot"] -image::apm/images/apm-ml-integration.png[Example view of anomaly scores on response times in APM app in Kibana] - -Jobs can be created per transaction type and based on the average response time. -You can manage jobs in the *Machine Learning jobs management*. -It might take some time for results to appear on the graph. - -Machine learning is a platinum feature. For a comparison of the Elastic license levels, -see https://www.elastic.co/subscriptions[the subscription page]. \ No newline at end of file diff --git a/docs/apm/service-maps.asciidoc b/docs/apm/service-maps.asciidoc index e0d84f33b4dcb..be86b9d522ac5 100644 --- a/docs/apm/service-maps.asciidoc +++ b/docs/apm/service-maps.asciidoc @@ -1,37 +1,53 @@ +[role="xpack"] [[service-maps]] === Service maps beta::[] -A service map is a real-time diagram of the interactions occurring in your application’s architecture. -It allows you to easily visualize data flow and high-level statistics, like average transaction duration, -requests per minute, errors per minute, and metrics, allowing you to quickly assess the status of your services. +WARNING: Service map support for Internet Explorer 11 is extremely limited. +Please use Chrome or Firefox if available. -Our beta offering creates two types of service maps: +A service map is a real-time visual representation of the instrumented services in your application's architecture. +It shows you how these services are connected, along with high-level metrics like average transaction duration, +requests per minute, and errors per minute, that allow you to quickly assess the status of your services. -* Global: All services and connections are shown. -* Service-specific: Selecting a specific service will highlight it's connections. +We currently surface two types of service maps: + +* Global: All services instrumented with APM agents and the connections between them are shown. +* Service-specific: Highlight connections for a selected service. [role="screenshot"] image::apm/images/service-maps.png[Example view of service maps in the APM app in Kibana] +[float] +[[service-maps-how]] +=== How do service maps work? + +Service maps rely on distributed traces to draw connections between services. +As {apm-overview-ref-v}/distributed-tracing.html[distributed tracing] is enabled out-of-the-box for supported technologies, so are service maps. +However, if a service isn't instrumented, +or a `traceparent` header isn't being propagated to it, +distributed tracing will not work, and the connection will not be drawn on the map. + [float] [[visualize-your-architecture]] === Visualize your architecture Select the **Service Map** tab to get started. -By default, all services and connections are shown. -Whether your onboarding a new engineer, or just trying to grasp the big picture, +By default, all instrumented services and connections are shown. +Whether you're onboarding a new engineer, or just trying to grasp the big picture, click around, zoom in and out, and begin to visualize how your services are connected. If there's a specific service that interests you, select that service to highlight its connections. Clicking **Focus map** will refocus the map on that specific service and lock the connection highlighting. -From here, select **Service Details**, or click on the **Transaction** tab to jump to the Transaction overview. +From here, select **Service Details**, or click on the **Transaction** tab to jump to the Transaction overview +for the selected service. You can also use the tabs at the top of the page to easily jump to the **Errors** or **Metrics** overview. -While it's not possible to query in service maps, it is possible to filter by environment. +Filter out your maps by picking the environment from the environment drop-down filter. This can be useful if you have two or more services, in separate environments, but with the same name. -Use the environment drop down to only see the data you're interested in, like `dev` or `production`. +Use the environment drop-down to only see the data you're interested in, like `dev` or `production`. +Additional filters are not currently available for service maps. [role="screenshot"] image::apm/images/service-maps-java.png[Example view of service maps with Java highlighted in the APM app in Kibana] @@ -46,3 +62,18 @@ Nodes appear on the map in one of two shapes: * **Diamond**: Databases, external, and messaging. Interior icons represent the generic type, with specific icons for known entities, like Elasticsearch. Type and subtype are based on `span.type`, and `span.subtype`. + +[float] +[[service-maps-supported]] +=== Supported APM Agents + +Service maps are supported for the following Agent versions: + +[horizontal] +Go Agent:: >= v1.7.0 +Java Agent:: >= v1.13.0 +.NET Agent:: >= v1.3.0 +Node.js Agent:: >= v3.6.0 +Python Agent:: >= v5.5.0 +Ruby Agent:: >= v3.6.0 +Real User Monitoring (RUM) Agent:: >= v4.7.0 diff --git a/docs/apm/services.asciidoc b/docs/apm/services.asciidoc index 9af3e74562dab..395e23c379306 100644 --- a/docs/apm/services.asciidoc +++ b/docs/apm/services.asciidoc @@ -1,9 +1,9 @@ +[role="xpack"] [[services]] === Services overview -The *Services* overview gives you quick insights into the health and general performance of each service. - -You can add services by setting the `service.name` configuration in each of the {apm-agents-ref}[APM agents] you’re instrumenting. +The *Services* overview gives you quick insights into the health and general performance of all of your instrumented services. +Services are sorted by the `service.name` configured in each of the {apm-agents-ref}[APM agents] you’ve installed. [role="screenshot"] image::apm/images/apm-services-overview.png[Example view of services table the APM app in Kibana] \ No newline at end of file diff --git a/docs/apm/set-up.asciidoc b/docs/apm/set-up.asciidoc new file mode 100644 index 0000000000000..c5bf5e13b640b --- /dev/null +++ b/docs/apm/set-up.asciidoc @@ -0,0 +1,35 @@ +[role="xpack"] +[[apm-ui]] +== Set up the APM app + +++++ +Set up +++++ + +APM is available via the navigation sidebar in {Kib}. +If you have not already installed and configured Elastic APM, +the *Setup Instructions* in Kibana will get you started. + +[role="screenshot"] +image::apm/images/apm-setup.png[Installation instructions on the APM page in Kibana] + +[float] +[[apm-configure-index-pattern]] +=== Load the index pattern + +Index patterns tell Kibana which Elasticsearch indices you want to explore. +An APM index pattern is necessary for certain features in the APM app, like the query bar. +To set up the correct index pattern, +simply click *Load Kibana objects* at the bottom of the Setup Instructions. + +[role="screenshot"] +image::apm/images/apm-index-pattern.png[Setup index pattern for APM in Kibana] + +To use a custom index pattern, see <>. + +[float] +[[apm-getting-started-next]] +=== Next steps + +No further configuration in the APM app is required. +Install an APM Agent library in your service to begin visualizing and analyzing your data! diff --git a/docs/apm/settings.asciidoc b/docs/apm/settings.asciidoc index 37122fc9c635d..44da63143f63f 100644 --- a/docs/apm/settings.asciidoc +++ b/docs/apm/settings.asciidoc @@ -1,18 +1,23 @@ // Do not link directly to this page. // Link to the anchor in `/docs/settings/apm-settings.asciidoc` instead. +[role="xpack"] [[apm-settings-in-kibana]] -=== APM settings in Kibana +== APM app settings + +++++ +Settings +++++ You do not need to configure any settings to use the APM app. It is enabled by default. [float] [[apm-indices-settings]] -==== APM Indices +=== APM Indices include::./../settings/apm-settings.asciidoc[tag=apm-indices-settings] [float] [[general-apm-settings]] -==== General APM settings +=== General APM settings include::./../settings/apm-settings.asciidoc[tag=general-apm-settings] diff --git a/docs/apm/spans.asciidoc b/docs/apm/spans.asciidoc index ef21e1c5333e0..2eed339160fc4 100644 --- a/docs/apm/spans.asciidoc +++ b/docs/apm/spans.asciidoc @@ -1,7 +1,8 @@ +[role="xpack"] [[spans]] === Span timeline -TIP: A {apm-overview-ref-v}/transaction-spans.html[span] is defined as the duration of a single event. +TIP: A {apm-overview-ref-v}/transaction-spans.html[span] is the duration of a single event. Spans are automatically captured by APM agents, and you can also define custom spans. Each span has a type and is defined by a different color in the timeline/waterfall visualization. @@ -28,7 +29,7 @@ Services in a distributed trace are separated by color and listed in the order t [role="screenshot"] image::apm/images/apm-services-trace.png[Example of distributed trace colors in the APM app in Kibana] -Don't forget, a distributed trace includes more than one transaction. +Don't forget; a distributed trace includes more than one transaction. When viewing these distributed traces in the timeline waterfall, you'll see this image:apm/images/transaction-icon.png[APM icon] icon, which indicates the next transaction in the trace. These transactions can be expanded and viewed in detail by clicking on them. diff --git a/docs/apm/traces.asciidoc b/docs/apm/traces.asciidoc index 09d8f52b92840..8eef3d9bed4db 100644 --- a/docs/apm/traces.asciidoc +++ b/docs/apm/traces.asciidoc @@ -1,12 +1,17 @@ +[role="xpack"] [[traces]] === Traces overview +TIP: Traces link together related transactions to show an end-to-end performance of how a request was served +and which services were part of it. +In addition to the Traces overview, you can view your application traces in the <>. + The *Traces* overview displays the entry transaction for all traces in your application. If you're using <>, this view is key to finding the critical paths within your application. Transactions with the same name are grouped together and only shown once in this table. By default, transactions are sorted by _Impact_. -Impact helps show the most used and slowest endpoints in your service - in other words, +Impact helps show the most used and slowest endpoints in your service--in other words, it's the collective amount of pain a specific endpoint is causing your users. If there's a particular endpoint you're worried about, you can click on it to view the <>. @@ -33,4 +38,4 @@ You can use the <> to view a waterfall displa [role="screenshot"] image::apm/images/apm-distributed-tracing.png[Example view of the distributed tracing in APM app in Kibana] -TIP: Distributed tracing is supported by all APM agents and there’s no additional configuration needed. \ No newline at end of file +TIP: Distributed tracing is supported by all APM agents, and there's no additional configuration needed. \ No newline at end of file diff --git a/docs/apm/transactions.asciidoc b/docs/apm/transactions.asciidoc index 1eb037009efff..2e1022e6d684c 100644 --- a/docs/apm/transactions.asciidoc +++ b/docs/apm/transactions.asciidoc @@ -1,3 +1,4 @@ +[role="xpack"] [[transactions]] === Transaction overview @@ -56,20 +57,6 @@ For further details, including troubleshooting and custom implementation instruc refer to the documentation for each {apm-agents-ref}[APM Agent] you've implemented. ==== -[[transactions-annotations]] -==== Transaction annotations - -For enhanced visibility into your deployments, we offer deployment annotations on all transaction charts. -This feature automatically tags new deployments, so you can easily see if your deploy has increased response times -for an end-user, or if the memory/CPU footprint of your application has increased. -Being able to quickly identify bad deployments enables you to rollback and fix issues without causing costly outages. - -Deployment annotations are automatically enabled, and appear when the `service.version` of your app changes. - -[role="screenshot"] -image::apm/images/apm-transaction-annotation.png[Example view of transactions annotation in the APM app in Kibana] - - [[rum-transaction-overview]] ==== RUM Transaction overview @@ -82,7 +69,7 @@ image::apm/images/apm-geo-ui.jpg[average page load duration distribution] This data is available due to the geo-ip and user agent pipelines being enabled by default, which allows for the capture of geo-location and user agent data. These visualizations make it easy for you to visualize performance information about your -end users' experience based on their location. +end-users' experience based on their location. [[transaction-details]] ==== Transaction details @@ -103,7 +90,7 @@ The number of requests per bucket is displayed when hovering over the graph, and [role="screenshot"] image::apm/images/apm-transaction-duration-dist.png[Example view of transactions duration distribution graph] -This graph shows a typical distribution, and indicates most of our requests were served quickly - awesome! +This graph shows a typical distribution, and indicates most of our requests were served quickly--awesome! It's the requests on the right, the ones taking longer than average, that we probably want to focus on. When you select one of these buckets, diff --git a/docs/apm/troubleshooting.asciidoc b/docs/apm/troubleshooting.asciidoc index c6174e1786c78..eb4fb790afd7f 100644 --- a/docs/apm/troubleshooting.asciidoc +++ b/docs/apm/troubleshooting.asciidoc @@ -1,19 +1,24 @@ -[[troubleshooting]] -=== Troubleshooting common problems +[[troubleshooting]] +== Troubleshoot common problems + +++++ +Troubleshooting +++++ If you have something to add to this section, please consider creating a pull request with your proposed changes at https://github.com/elastic/kibana. -Also check out the https://discuss.elastic.co/c/apm[APM discussion forum]. +Also, check out the https://discuss.elastic.co/c/apm[APM discussion forum]. +[float] [[no-apm-data-found]] -==== No APM data found +=== No APM data found This section can help with any of the following: * Data isn't displaying in the APM app -* You're seeing a message like "No Services Found", -* You're seeing errors like "Fielddata is disabled on text fields by default..." +* You see a message like "No Services Found", +* You see errors like "Fielddata is disabled on text fields by default..." There are a number of factors that could be at play here. One important thing to double-check first is your index template. @@ -52,12 +57,13 @@ you can customize the indices that the APM app uses to display data. Navigate to *APM* > *Settings* > *Indices*, and change all `apm_oss.*Pattern` values to include the new index pattern. For example: `customIndexName-*`. -==== Unknown route +[float] +=== Unknown route The {apm-app-ref}/transactions.html[transaction overview] will only display helpful information when the transactions in your services are named correctly. If you're seeing "GET unknown route" or "unknown route" in the APM app, -it could be a sign that something isn't working like it should. +it could be a sign that something isn't working as it should. Elastic APM Agents come with built-in support for popular frameworks out-of-the-box. This means, among other things, that the Agent will try to automatically name HTTP requests. @@ -71,7 +77,8 @@ To resolve this, you'll need to head over to the relevant {apm-agents-ref}[Agent Specifically, view the Agent's supported technologies page. You can also use the Agent's public API to manually set a name for the transaction. -==== Fields are not searchable +[float] +=== Fields are not searchable In Elasticsearch, index templates are used to define settings and mappings that determine how fields should be analyzed. The recommended index template file for APM Server is installed by the APM Server packages. @@ -92,7 +99,7 @@ Selecting the `apm-*` index pattern shows a listing of every field defined in th *Ensure a field is searchable* There are two things you can do to if you'd like to ensure a field is searchable: -1. Index your additional data as {apm-overview-ref}/metadata.html[labels] instead. +1. Index your additional data as {apm-overview-ref-v}/metadata.html[labels] instead. These are dynamic by default, which means they will be indexed and become searchable and aggregatable. 2. Use the {apm-server-ref}/configuration-template.html[`append_fields`] feature. As an example, diff --git a/docs/apm/using-the-apm-ui.asciidoc b/docs/apm/using-the-apm-ui.asciidoc deleted file mode 100644 index 904718999069d..0000000000000 --- a/docs/apm/using-the-apm-ui.asciidoc +++ /dev/null @@ -1,51 +0,0 @@ -[role="xpack"] -[[apm-ui]] -== Using APM - -APM is designed to be as intuitive as possible, -but you might come across certain terms or concepts that don’t feel native to you. -Not to worry, we've created this guide to help you get the most out of Elastic APM. - -APM is available via the navigation sidebar in {Kib}. - -* <> -* <> -* <> -* <> -* <> -* <> -* <> -* <> -* <> -* <> -* <> -* <> -* <> - -include::filters.asciidoc[] - -include::services.asciidoc[] - -include::traces.asciidoc[] - -include::transactions.asciidoc[] - -include::spans.asciidoc[] - -include::service-maps.asciidoc[] - -include::errors.asciidoc[] - -include::metrics.asciidoc[] - -include::apm-alerts.asciidoc[] - -include::agent-configuration.asciidoc[] - -include::custom-links.asciidoc[] - -include::advanced-queries.asciidoc[] - -include::settings.asciidoc[] - -include::troubleshooting.asciidoc[] diff --git a/docs/dev-tools/painlesslab/images/painless-lab.png b/docs/dev-tools/painlesslab/images/painless-lab.png new file mode 100644 index 0000000000000..f65257852792e Binary files /dev/null and b/docs/dev-tools/painlesslab/images/painless-lab.png differ diff --git a/docs/dev-tools/painlesslab/index.asciidoc b/docs/dev-tools/painlesslab/index.asciidoc new file mode 100644 index 0000000000000..e55424baf3142 --- /dev/null +++ b/docs/dev-tools/painlesslab/index.asciidoc @@ -0,0 +1,17 @@ +[role="xpack"] +[[painlesslab]] +== Painless Lab + +beta::[] + +The Painless Lab is an interactive code editor that lets you test and +debug {ref}/modules-scripting-painless.html[Painless scripts] in real-time. +You can use the Painless scripting +language to create <>, +process {ref}/docs-reindex.html[reindexed data], define complex +<>, +and work with data in other contexts. + +To get started, go to *Dev Tools > Painless Lab*. + +image::dev-tools/painlesslab/images/painless-lab.png[Painless Lab] diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.md index c6bc13b98bc06..b67d0536fb336 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobject.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.md @@ -18,6 +18,7 @@ export interface SavedObject | [error](./kibana-plugin-core-public.savedobject.error.md) | {
message: string;
statusCode: number;
} | | | [id](./kibana-plugin-core-public.savedobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | | [migrationVersion](./kibana-plugin-core-public.savedobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [namespaces](./kibana-plugin-core-public.savedobject.namespaces.md) | string[] | Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. | | [references](./kibana-plugin-core-public.savedobject.references.md) | SavedObjectReference[] | A reference to another saved object. | | [type](./kibana-plugin-core-public.savedobject.type.md) | string | The type of Saved Object. Each plugin can define it's own custom Saved Object types. | | [updated\_at](./kibana-plugin-core-public.savedobject.updated_at.md) | string | Timestamp of the last time this document had been updated. | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.namespaces.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.namespaces.md new file mode 100644 index 0000000000000..257df45934506 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.namespaces.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObject](./kibana-plugin-core-public.savedobject.md) > [namespaces](./kibana-plugin-core-public.savedobject.namespaces.md) + +## SavedObject.namespaces property + +Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. + +Signature: + +```typescript +namespaces?: string[]; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.scopedhistory.createhref.md b/docs/development/core/public/kibana-plugin-core-public.scopedhistory.createhref.md index 7058656d09947..6bbab43ff6ffc 100644 --- a/docs/development/core/public/kibana-plugin-core-public.scopedhistory.createhref.md +++ b/docs/development/core/public/kibana-plugin-core-public.scopedhistory.createhref.md @@ -4,10 +4,12 @@ ## ScopedHistory.createHref property -Creates an href (string) to the location. +Creates an href (string) to the location. If `prependBasePath` is true (default), it will prepend the location's path with the scoped history basePath. Signature: ```typescript -createHref: (location: LocationDescriptorObject) => string; +createHref: (location: LocationDescriptorObject, { prependBasePath }?: { + prependBasePath?: boolean | undefined; + }) => string; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.scopedhistory.md b/docs/development/core/public/kibana-plugin-core-public.scopedhistory.md index 5ea47d2090d71..fa29b32c0bafc 100644 --- a/docs/development/core/public/kibana-plugin-core-public.scopedhistory.md +++ b/docs/development/core/public/kibana-plugin-core-public.scopedhistory.md @@ -28,7 +28,7 @@ export declare class ScopedHistory implements Hi | --- | --- | --- | --- | | [action](./kibana-plugin-core-public.scopedhistory.action.md) | | Action | The last action dispatched on the history stack. | | [block](./kibana-plugin-core-public.scopedhistory.block.md) | | (prompt?: string | boolean | History.TransitionPromptHook<HistoryLocationState> | undefined) => UnregisterCallback | Not supported. Use [AppMountParameters.onAppLeave](./kibana-plugin-core-public.appmountparameters.onappleave.md). | -| [createHref](./kibana-plugin-core-public.scopedhistory.createhref.md) | | (location: LocationDescriptorObject<HistoryLocationState>) => string | Creates an href (string) to the location. | +| [createHref](./kibana-plugin-core-public.scopedhistory.createhref.md) | | (location: LocationDescriptorObject<HistoryLocationState>, { prependBasePath }?: {
prependBasePath?: boolean | undefined;
}) => string | Creates an href (string) to the location. If prependBasePath is true (default), it will prepend the location's path with the scoped history basePath. | | [createSubHistory](./kibana-plugin-core-public.scopedhistory.createsubhistory.md) | | <SubHistoryLocationState = unknown>(basePath: string) => ScopedHistory<SubHistoryLocationState> | Creates a ScopedHistory for a subpath of this ScopedHistory. Useful for applications that may have sub-apps that do not need access to the containing application's history. | | [go](./kibana-plugin-core-public.scopedhistory.go.md) | | (n: number) => void | Send the user forward or backwards in the history stack. | | [goBack](./kibana-plugin-core-public.scopedhistory.goback.md) | | () => void | Send the user one location back in the history stack. Equivalent to calling [ScopedHistory.go(-1)](./kibana-plugin-core-public.scopedhistory.go.md). If no more entries are available backwards, this is a no-op. | diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.http.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.http.md index dcc1d754feb7c..a8028827cc0a4 100644 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.http.md +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.http.md @@ -9,5 +9,7 @@ Signature: ```typescript -http: HttpServiceSetup; +http: HttpServiceSetup & { + resources: HttpResources; + }; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.md index c10b460da8b4f..30c054345928b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.md @@ -20,7 +20,7 @@ export interface CoreSetupContextSetup | [ContextSetup](./kibana-plugin-core-server.contextsetup.md) | | [elasticsearch](./kibana-plugin-core-server.coresetup.elasticsearch.md) | ElasticsearchServiceSetup | [ElasticsearchServiceSetup](./kibana-plugin-core-server.elasticsearchservicesetup.md) | | [getStartServices](./kibana-plugin-core-server.coresetup.getstartservices.md) | StartServicesAccessor<TPluginsStart, TStart> | [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) | -| [http](./kibana-plugin-core-server.coresetup.http.md) | HttpServiceSetup | [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) | +| [http](./kibana-plugin-core-server.coresetup.http.md) | HttpServiceSetup & {
resources: HttpResources;
} | [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) | | [metrics](./kibana-plugin-core-server.coresetup.metrics.md) | MetricsServiceSetup | [MetricsServiceSetup](./kibana-plugin-core-server.metricsservicesetup.md) | | [savedObjects](./kibana-plugin-core-server.coresetup.savedobjects.md) | SavedObjectsServiceSetup | [SavedObjectsServiceSetup](./kibana-plugin-core-server.savedobjectsservicesetup.md) | | [status](./kibana-plugin-core-server.coresetup.status.md) | StatusServiceSetup | [StatusServiceSetup](./kibana-plugin-core-server.statusservicesetup.md) | diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresources.md b/docs/development/core/server/kibana-plugin-core-server.httpresources.md new file mode 100644 index 0000000000000..cb3170e989e17 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpresources.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpResources](./kibana-plugin-core-server.httpresources.md) + +## HttpResources interface + +HttpResources service is responsible for serving static & dynamic assets for Kibana application via HTTP. Provides API allowing plug-ins to respond with: - a pre-configured HTML page bootstrapping Kibana client app - custom HTML page - custom JS script file. + +Signature: + +```typescript +export interface HttpResources +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [register](./kibana-plugin-core-server.httpresources.register.md) | <P, Q, B>(route: RouteConfig<P, Q, B, 'get'>, handler: HttpResourcesRequestHandler<P, Q, B>) => void | To register a route handler executing passed function to form response. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresources.register.md b/docs/development/core/server/kibana-plugin-core-server.httpresources.register.md new file mode 100644 index 0000000000000..fe3803a6ffe52 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpresources.register.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpResources](./kibana-plugin-core-server.httpresources.md) > [register](./kibana-plugin-core-server.httpresources.register.md) + +## HttpResources.register property + +To register a route handler executing passed function to form response. + +Signature: + +```typescript +register: (route: RouteConfig, handler: HttpResourcesRequestHandler) => void; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresourcesrenderoptions.headers.md b/docs/development/core/server/kibana-plugin-core-server.httpresourcesrenderoptions.headers.md new file mode 100644 index 0000000000000..bb6dec504ff42 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpresourcesrenderoptions.headers.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpResourcesRenderOptions](./kibana-plugin-core-server.httpresourcesrenderoptions.md) > [headers](./kibana-plugin-core-server.httpresourcesrenderoptions.headers.md) + +## HttpResourcesRenderOptions.headers property + +HTTP Headers with additional information about response. + +Signature: + +```typescript +headers?: ResponseHeaders; +``` + +## Remarks + +All HTML pages are already pre-configured with `content-security-policy` header that cannot be overridden. + diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresourcesrenderoptions.md b/docs/development/core/server/kibana-plugin-core-server.httpresourcesrenderoptions.md new file mode 100644 index 0000000000000..6563e3c636a99 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpresourcesrenderoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpResourcesRenderOptions](./kibana-plugin-core-server.httpresourcesrenderoptions.md) + +## HttpResourcesRenderOptions interface + +Allows to configure HTTP response parameters + +Signature: + +```typescript +export interface HttpResourcesRenderOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [headers](./kibana-plugin-core-server.httpresourcesrenderoptions.headers.md) | ResponseHeaders | HTTP Headers with additional information about response. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresourcesrequesthandler.md b/docs/development/core/server/kibana-plugin-core-server.httpresourcesrequesthandler.md new file mode 100644 index 0000000000000..20f930382955e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpresourcesrequesthandler.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpResourcesRequestHandler](./kibana-plugin-core-server.httpresourcesrequesthandler.md) + +## HttpResourcesRequestHandler type + +Extended version of [RequestHandler](./kibana-plugin-core-server.requesthandler.md) having access to [HttpResourcesServiceToolkit](./kibana-plugin-core-server.httpresourcesservicetoolkit.md) to respond with HTML or JS resources. + +Signature: + +```typescript +export declare type HttpResourcesRequestHandler

= RequestHandler; +``` + +## Example + +\`\`\`typescript httpResources.register({ path: '/login', validate: { params: schema.object({ id: schema.string() }), }, }, async (context, request, response) => { //.. return response.renderCoreApp(); }); + diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresourcesresponseoptions.md b/docs/development/core/server/kibana-plugin-core-server.httpresourcesresponseoptions.md new file mode 100644 index 0000000000000..2ea3ea7e58c78 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpresourcesresponseoptions.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpResourcesResponseOptions](./kibana-plugin-core-server.httpresourcesresponseoptions.md) + +## HttpResourcesResponseOptions type + +HTTP Resources response parameters + +Signature: + +```typescript +export declare type HttpResourcesResponseOptions = HttpResponseOptions; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.md b/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.md new file mode 100644 index 0000000000000..1c221e13f534f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpResourcesServiceToolkit](./kibana-plugin-core-server.httpresourcesservicetoolkit.md) + +## HttpResourcesServiceToolkit interface + +Extended set of [KibanaResponseFactory](./kibana-plugin-core-server.kibanaresponsefactory.md) helpers used to respond with HTML or JS resource. + +Signature: + +```typescript +export interface HttpResourcesServiceToolkit +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [renderAnonymousCoreApp](./kibana-plugin-core-server.httpresourcesservicetoolkit.renderanonymouscoreapp.md) | (options?: HttpResourcesRenderOptions) => Promise<IKibanaResponse> | To respond with HTML page bootstrapping Kibana application without retrieving user-specific information. | +| [renderCoreApp](./kibana-plugin-core-server.httpresourcesservicetoolkit.rendercoreapp.md) | (options?: HttpResourcesRenderOptions) => Promise<IKibanaResponse> | To respond with HTML page bootstrapping Kibana application. | +| [renderHtml](./kibana-plugin-core-server.httpresourcesservicetoolkit.renderhtml.md) | (options: HttpResourcesResponseOptions) => IKibanaResponse | To respond with a custom HTML page. | +| [renderJs](./kibana-plugin-core-server.httpresourcesservicetoolkit.renderjs.md) | (options: HttpResourcesResponseOptions) => IKibanaResponse | To respond with a custom JS script file. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.renderanonymouscoreapp.md b/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.renderanonymouscoreapp.md new file mode 100644 index 0000000000000..3dce9d88c8036 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.renderanonymouscoreapp.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpResourcesServiceToolkit](./kibana-plugin-core-server.httpresourcesservicetoolkit.md) > [renderAnonymousCoreApp](./kibana-plugin-core-server.httpresourcesservicetoolkit.renderanonymouscoreapp.md) + +## HttpResourcesServiceToolkit.renderAnonymousCoreApp property + +To respond with HTML page bootstrapping Kibana application without retrieving user-specific information. + +Signature: + +```typescript +renderAnonymousCoreApp: (options?: HttpResourcesRenderOptions) => Promise; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.rendercoreapp.md b/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.rendercoreapp.md new file mode 100644 index 0000000000000..eb4f095bc19be --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.rendercoreapp.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpResourcesServiceToolkit](./kibana-plugin-core-server.httpresourcesservicetoolkit.md) > [renderCoreApp](./kibana-plugin-core-server.httpresourcesservicetoolkit.rendercoreapp.md) + +## HttpResourcesServiceToolkit.renderCoreApp property + +To respond with HTML page bootstrapping Kibana application. + +Signature: + +```typescript +renderCoreApp: (options?: HttpResourcesRenderOptions) => Promise; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.renderhtml.md b/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.renderhtml.md new file mode 100644 index 0000000000000..325d19625df44 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.renderhtml.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpResourcesServiceToolkit](./kibana-plugin-core-server.httpresourcesservicetoolkit.md) > [renderHtml](./kibana-plugin-core-server.httpresourcesservicetoolkit.renderhtml.md) + +## HttpResourcesServiceToolkit.renderHtml property + +To respond with a custom HTML page. + +Signature: + +```typescript +renderHtml: (options: HttpResourcesResponseOptions) => IKibanaResponse; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.renderjs.md b/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.renderjs.md new file mode 100644 index 0000000000000..f8d4418fc6cba --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.renderjs.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpResourcesServiceToolkit](./kibana-plugin-core-server.httpresourcesservicetoolkit.md) > [renderJs](./kibana-plugin-core-server.httpresourcesservicetoolkit.renderjs.md) + +## HttpResourcesServiceToolkit.renderJs property + +To respond with a custom JS script file. + +Signature: + +```typescript +renderJs: (options: HttpResourcesResponseOptions) => IKibanaResponse; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.irouter.handlelegacyerrors.md b/docs/development/core/server/kibana-plugin-core-server.irouter.handlelegacyerrors.md index 94cf3c94187b0..35d109975c83a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.irouter.handlelegacyerrors.md +++ b/docs/development/core/server/kibana-plugin-core-server.irouter.handlelegacyerrors.md @@ -9,5 +9,5 @@ Wrap a router handler to catch and converts legacy boom errors to proper custom Signature: ```typescript -handleLegacyErrors: (handler: RequestHandler) => RequestHandler; +handleLegacyErrors: RequestHandlerWrapper; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.irouter.md b/docs/development/core/server/kibana-plugin-core-server.irouter.md index 073f02f1a4191..4bade638a65a5 100644 --- a/docs/development/core/server/kibana-plugin-core-server.irouter.md +++ b/docs/development/core/server/kibana-plugin-core-server.irouter.md @@ -18,7 +18,7 @@ export interface IRouter | --- | --- | --- | | [delete](./kibana-plugin-core-server.irouter.delete.md) | RouteRegistrar<'delete'> | Register a route handler for DELETE request. | | [get](./kibana-plugin-core-server.irouter.get.md) | RouteRegistrar<'get'> | Register a route handler for GET request. | -| [handleLegacyErrors](./kibana-plugin-core-server.irouter.handlelegacyerrors.md) | <P, Q, B>(handler: RequestHandler<P, Q, B>) => RequestHandler<P, Q, B> | Wrap a router handler to catch and converts legacy boom errors to proper custom errors. | +| [handleLegacyErrors](./kibana-plugin-core-server.irouter.handlelegacyerrors.md) | RequestHandlerWrapper | Wrap a router handler to catch and converts legacy boom errors to proper custom errors. | | [patch](./kibana-plugin-core-server.irouter.patch.md) | RouteRegistrar<'patch'> | Register a route handler for PATCH request. | | [post](./kibana-plugin-core-server.irouter.post.md) | RouteRegistrar<'post'> | Register a route handler for POST request. | | [put](./kibana-plugin-core-server.irouter.put.md) | RouteRegistrar<'put'> | Register a route handler for PUT request. | diff --git a/docs/development/core/server/kibana-plugin-core-server.isavedobjecttyperegistry.md b/docs/development/core/server/kibana-plugin-core-server.isavedobjecttyperegistry.md index 245cb1a56439f..f9c621885c001 100644 --- a/docs/development/core/server/kibana-plugin-core-server.isavedobjecttyperegistry.md +++ b/docs/development/core/server/kibana-plugin-core-server.isavedobjecttyperegistry.md @@ -9,5 +9,5 @@ See [SavedObjectTypeRegistry](./kibana-plugin-core-server.savedobjecttyperegistr Signature: ```typescript -export declare type ISavedObjectTypeRegistry = Pick; +export declare type ISavedObjectTypeRegistry = Omit; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.iscopedrenderingclient.md b/docs/development/core/server/kibana-plugin-core-server.iscopedrenderingclient.md deleted file mode 100644 index 0632b5e5e2297..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.iscopedrenderingclient.md +++ /dev/null @@ -1,19 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IScopedRenderingClient](./kibana-plugin-core-server.iscopedrenderingclient.md) - -## IScopedRenderingClient interface - - -Signature: - -```typescript -export interface IScopedRenderingClient -``` - -## Methods - -| Method | Description | -| --- | --- | -| [render(options)](./kibana-plugin-core-server.iscopedrenderingclient.render.md) | Generate a KibanaResponse which renders an HTML page bootstrapped with the core bundle. Intended as a response body for HTTP route handlers. | - diff --git a/docs/development/core/server/kibana-plugin-core-server.iscopedrenderingclient.render.md b/docs/development/core/server/kibana-plugin-core-server.iscopedrenderingclient.render.md deleted file mode 100644 index ca114bed21149..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.iscopedrenderingclient.render.md +++ /dev/null @@ -1,41 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IScopedRenderingClient](./kibana-plugin-core-server.iscopedrenderingclient.md) > [render](./kibana-plugin-core-server.iscopedrenderingclient.render.md) - -## IScopedRenderingClient.render() method - -Generate a `KibanaResponse` which renders an HTML page bootstrapped with the `core` bundle. Intended as a response body for HTTP route handlers. - -Signature: - -```typescript -render(options?: Pick): Promise; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| options | Pick<IRenderOptions, 'includeUserSettings'> | | - -Returns: - -`Promise` - -## Example - - -```ts -router.get( - { path: '/', validate: false }, - (context, request, response) => - response.ok({ - body: await context.core.rendering.render(), - headers: { - 'content-security-policy': context.core.http.csp.header, - }, - }) -); - -``` - diff --git a/docs/development/core/server/kibana-plugin-core-server.legacyservicesetupdeps.md b/docs/development/core/server/kibana-plugin-core-server.legacyservicesetupdeps.md index f037b7b3e7cb2..a5c1d59be06d3 100644 --- a/docs/development/core/server/kibana-plugin-core-server.legacyservicesetupdeps.md +++ b/docs/development/core/server/kibana-plugin-core-server.legacyservicesetupdeps.md @@ -20,4 +20,5 @@ export interface LegacyServiceSetupDeps | --- | --- | --- | | [core](./kibana-plugin-core-server.legacyservicesetupdeps.core.md) | LegacyCoreSetup | | | [plugins](./kibana-plugin-core-server.legacyservicesetupdeps.plugins.md) | Record<string, unknown> | | +| [uiPlugins](./kibana-plugin-core-server.legacyservicesetupdeps.uiplugins.md) | UiPlugins | | diff --git a/docs/development/core/server/kibana-plugin-core-server.legacyservicesetupdeps.uiplugins.md b/docs/development/core/server/kibana-plugin-core-server.legacyservicesetupdeps.uiplugins.md new file mode 100644 index 0000000000000..d19a7dfcbfcfa --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.legacyservicesetupdeps.uiplugins.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [LegacyServiceSetupDeps](./kibana-plugin-core-server.legacyservicesetupdeps.md) > [uiPlugins](./kibana-plugin-core-server.legacyservicesetupdeps.uiplugins.md) + +## LegacyServiceSetupDeps.uiPlugins property + +Signature: + +```typescript +uiPlugins: UiPlugins; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index accab9bf0cb36..5450e84417f89 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -80,6 +80,9 @@ The plugin integrates with the core system via lifecycle events: `setup` | [EnvironmentMode](./kibana-plugin-core-server.environmentmode.md) | | | [ErrorHttpResponseOptions](./kibana-plugin-core-server.errorhttpresponseoptions.md) | HTTP response parameters | | [FakeRequest](./kibana-plugin-core-server.fakerequest.md) | Fake request object created manually by Kibana plugins. | +| [HttpResources](./kibana-plugin-core-server.httpresources.md) | HttpResources service is responsible for serving static & dynamic assets for Kibana application via HTTP. Provides API allowing plug-ins to respond with: - a pre-configured HTML page bootstrapping Kibana client app - custom HTML page - custom JS script file. | +| [HttpResourcesRenderOptions](./kibana-plugin-core-server.httpresourcesrenderoptions.md) | Allows to configure HTTP response parameters | +| [HttpResourcesServiceToolkit](./kibana-plugin-core-server.httpresourcesservicetoolkit.md) | Extended set of [KibanaResponseFactory](./kibana-plugin-core-server.kibanaresponsefactory.md) helpers used to respond with HTML or JS resource. | | [HttpResponseOptions](./kibana-plugin-core-server.httpresponseoptions.md) | HTTP response parameters | | [HttpServerInfo](./kibana-plugin-core-server.httpserverinfo.md) | | | [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) | Kibana HTTP Service provides own abstraction for work with HTTP stack. Plugins don't have direct access to hapi server and its primitives anymore. Moreover, plugins shouldn't rely on the fact that HTTP Service uses one or another library under the hood. This gives the platform flexibility to upgrade or changing our internal HTTP stack without breaking plugins. If the HTTP Service lacks functionality you need, we are happy to discuss and support your needs. | @@ -92,7 +95,6 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IndexSettingsDeprecationInfo](./kibana-plugin-core-server.indexsettingsdeprecationinfo.md) | | | [IRenderOptions](./kibana-plugin-core-server.irenderoptions.md) | | | [IRouter](./kibana-plugin-core-server.irouter.md) | Registers route handlers for specified resource path and method. See [RouteConfig](./kibana-plugin-core-server.routeconfig.md) and [RequestHandler](./kibana-plugin-core-server.requesthandler.md) for more information about arguments to route registrations. | -| [IScopedRenderingClient](./kibana-plugin-core-server.iscopedrenderingclient.md) | | | [IUiSettingsClient](./kibana-plugin-core-server.iuisettingsclient.md) | Server-side client that provides access to the advanced settings stored in elasticsearch. The settings provide control over the behavior of the Kibana application. For example, a user can specify how to display numeric or date fields. Users can adjust the settings via Management UI. | | [KibanaRequestEvents](./kibana-plugin-core-server.kibanarequestevents.md) | Request events. | | [KibanaRequestRoute](./kibana-plugin-core-server.kibanarequestroute.md) | Request specific route information exposed to a handler. | @@ -118,7 +120,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [PluginConfigDescriptor](./kibana-plugin-core-server.pluginconfigdescriptor.md) | Describes a plugin configuration properties. | | [PluginInitializerContext](./kibana-plugin-core-server.plugininitializercontext.md) | Context that's available to plugins during initialization stage. | | [PluginManifest](./kibana-plugin-core-server.pluginmanifest.md) | Describes the set of required and optional properties plugin can define in its mandatory JSON manifest file. | -| [RequestHandlerContext](./kibana-plugin-core-server.requesthandlercontext.md) | Plugin specific context passed to a route handler.Provides the following clients and services: - [rendering](./kibana-plugin-core-server.iscopedrenderingclient.md) - Rendering client which uses the data of the incoming request - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.dataClient](./kibana-plugin-core-server.scopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.adminClient](./kibana-plugin-core-server.scopedclusterclient.md) - Elasticsearch admin client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request | +| [RequestHandlerContext](./kibana-plugin-core-server.requesthandlercontext.md) | Plugin specific context passed to a route handler.Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.dataClient](./kibana-plugin-core-server.scopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.adminClient](./kibana-plugin-core-server.scopedclusterclient.md) - Elasticsearch admin client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request | | [RouteConfig](./kibana-plugin-core-server.routeconfig.md) | Route specific configuration. | | [RouteConfigOptions](./kibana-plugin-core-server.routeconfigoptions.md) | Additional route options. | | [RouteConfigOptionsBody](./kibana-plugin-core-server.routeconfigoptionsbody.md) | Additional body options for a route | @@ -130,6 +132,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) | Migration context provided when invoking a [migration handler](./kibana-plugin-core-server.savedobjectmigrationfn.md) | | [SavedObjectMigrationMap](./kibana-plugin-core-server.savedobjectmigrationmap.md) | A map of [migration functions](./kibana-plugin-core-server.savedobjectmigrationfn.md) to be used for a given type. The map's keys must be valid semver versions.For a given document, only migrations with a higher version number than that of the document will be applied. Migrations are executed in order, starting from the lowest version and ending with the highest one. | | [SavedObjectReference](./kibana-plugin-core-server.savedobjectreference.md) | A reference to another saved object. | +| [SavedObjectsAddToNamespacesOptions](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md) | | | [SavedObjectsBaseOptions](./kibana-plugin-core-server.savedobjectsbaseoptions.md) | | | [SavedObjectsBulkCreateObject](./kibana-plugin-core-server.savedobjectsbulkcreateobject.md) | | | [SavedObjectsBulkGetObject](./kibana-plugin-core-server.savedobjectsbulkgetobject.md) | | @@ -143,6 +146,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsCoreFieldMapping](./kibana-plugin-core-server.savedobjectscorefieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation. | | [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) | | | [SavedObjectsDeleteByNamespaceOptions](./kibana-plugin-core-server.savedobjectsdeletebynamespaceoptions.md) | | +| [SavedObjectsDeleteFromNamespacesOptions](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md) | | | [SavedObjectsDeleteOptions](./kibana-plugin-core-server.savedobjectsdeleteoptions.md) | | | [SavedObjectsExportOptions](./kibana-plugin-core-server.savedobjectsexportoptions.md) | Options controlling the export operation. | | [SavedObjectsExportResultDetails](./kibana-plugin-core-server.savedobjectsexportresultdetails.md) | Structure of the export result details entry | @@ -214,6 +218,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [HandlerFunction](./kibana-plugin-core-server.handlerfunction.md) | A function that accepts a context object and an optional number of additional arguments. Used for the generic types in [IContextContainer](./kibana-plugin-core-server.icontextcontainer.md) | | [HandlerParameters](./kibana-plugin-core-server.handlerparameters.md) | Extracts the types of the additional arguments of a [HandlerFunction](./kibana-plugin-core-server.handlerfunction.md), excluding the [HandlerContextType](./kibana-plugin-core-server.handlercontexttype.md). | | [Headers](./kibana-plugin-core-server.headers.md) | Http request headers to read. | +| [HttpResourcesRequestHandler](./kibana-plugin-core-server.httpresourcesrequesthandler.md) | Extended version of [RequestHandler](./kibana-plugin-core-server.requesthandler.md) having access to [HttpResourcesServiceToolkit](./kibana-plugin-core-server.httpresourcesservicetoolkit.md) to respond with HTML or JS resources. | +| [HttpResourcesResponseOptions](./kibana-plugin-core-server.httpresourcesresponseoptions.md) | HTTP Resources response parameters | | [HttpResponsePayload](./kibana-plugin-core-server.httpresponsepayload.md) | Data send to the client as a response payload. | | [IBasePath](./kibana-plugin-core-server.ibasepath.md) | Access or manipulate the Kibana base path[BasePath](./kibana-plugin-core-server.basepath.md) | | [IClusterClient](./kibana-plugin-core-server.iclusterclient.md) | Represents an Elasticsearch cluster API client created by the platform. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)).See [ClusterClient](./kibana-plugin-core-server.clusterclient.md). | @@ -243,6 +249,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [RequestHandler](./kibana-plugin-core-server.requesthandler.md) | A function executed when route path matched requested resource path. Request handler is expected to return a result of one of [KibanaResponseFactory](./kibana-plugin-core-server.kibanaresponsefactory.md) functions. | | [RequestHandlerContextContainer](./kibana-plugin-core-server.requesthandlercontextcontainer.md) | An object that handles registration of http request context providers. | | [RequestHandlerContextProvider](./kibana-plugin-core-server.requesthandlercontextprovider.md) | Context provider for request handler. Extends request context object with provided functionality or data. | +| [RequestHandlerWrapper](./kibana-plugin-core-server.requesthandlerwrapper.md) | Type-safe wrapper for [RequestHandler](./kibana-plugin-core-server.requesthandler.md) function. | | [ResponseError](./kibana-plugin-core-server.responseerror.md) | Error message and optional data send to the client in case of error. | | [ResponseErrorAttributes](./kibana-plugin-core-server.responseerrorattributes.md) | Additional data to provide error details. | | [ResponseHeaders](./kibana-plugin-core-server.responseheaders.md) | Http response headers to set. | @@ -262,6 +269,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsClientFactoryProvider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) | Provider to invoke to retrieve a [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md). | | [SavedObjectsClientWrapperFactory](./kibana-plugin-core-server.savedobjectsclientwrapperfactory.md) | Describes the factory used to create instances of Saved Objects Client Wrappers. | | [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation | +| [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global.Note: do not write logic that uses this value directly; instead, use the appropriate accessors in the [type registry](./kibana-plugin-core-server.savedobjecttyperegistry.md). | | [ScopeableRequest](./kibana-plugin-core-server.scopeablerequest.md) | A user credentials container. It accommodates the necessary auth credentials to impersonate the current user.See [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md). | | [ServiceStatusLevel](./kibana-plugin-core-server.servicestatuslevel.md) | A convenience type that represents the union of each value in [ServiceStatusLevels](./kibana-plugin-core-server.servicestatuslevels.md). | | [SharedGlobalConfig](./kibana-plugin-core-server.sharedglobalconfig.md) | | diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandler.md b/docs/development/core/server/kibana-plugin-core-server.requesthandler.md index 156f38fab0983..cecef7c923568 100644 --- a/docs/development/core/server/kibana-plugin-core-server.requesthandler.md +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandler.md @@ -9,7 +9,7 @@ A function executed when route path matched requested resource path. Request han Signature: ```typescript -export declare type RequestHandler

= (context: RequestHandlerContext, request: KibanaRequest, response: KibanaResponseFactory) => IKibanaResponse | Promise>; +export declare type RequestHandler

= (context: RequestHandlerContext, request: KibanaRequest, response: ResponseFactory) => IKibanaResponse | Promise>; ``` ## Example diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md index 3c6bee114b6ab..0d640e52c3a03 100644 --- a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md @@ -8,7 +8,6 @@ ```typescript core: { - rendering: IScopedRenderingClient; savedObjects: { client: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md index b65ae47f0e0c1..0966b91a4ebf2 100644 --- a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md @@ -6,7 +6,7 @@ Plugin specific context passed to a route handler. -Provides the following clients and services: - [rendering](./kibana-plugin-core-server.iscopedrenderingclient.md) - Rendering client which uses the data of the incoming request - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.dataClient](./kibana-plugin-core-server.scopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.adminClient](./kibana-plugin-core-server.scopedclusterclient.md) - Elasticsearch admin client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request +Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.dataClient](./kibana-plugin-core-server.scopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.adminClient](./kibana-plugin-core-server.scopedclusterclient.md) - Elasticsearch admin client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request Signature: @@ -18,5 +18,5 @@ export interface RequestHandlerContext | Property | Type | Description | | --- | --- | --- | -| [core](./kibana-plugin-core-server.requesthandlercontext.core.md) | {
rendering: IScopedRenderingClient;
savedObjects: {
client: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
};
elasticsearch: {
dataClient: IScopedClusterClient;
adminClient: IScopedClusterClient;
};
uiSettings: {
client: IUiSettingsClient;
};
} | | +| [core](./kibana-plugin-core-server.requesthandlercontext.core.md) | {
savedObjects: {
client: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
};
elasticsearch: {
dataClient: IScopedClusterClient;
adminClient: IScopedClusterClient;
};
uiSettings: {
client: IUiSettingsClient;
};
} | | diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandlerwrapper.md b/docs/development/core/server/kibana-plugin-core-server.requesthandlerwrapper.md new file mode 100644 index 0000000000000..a9fe188ee2bff --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandlerwrapper.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [RequestHandlerWrapper](./kibana-plugin-core-server.requesthandlerwrapper.md) + +## RequestHandlerWrapper type + +Type-safe wrapper for [RequestHandler](./kibana-plugin-core-server.requesthandler.md) function. + +Signature: + +```typescript +export declare type RequestHandlerWrapper = (handler: RequestHandler) => RequestHandler; +``` + +## Example + + +```typescript +export const wrapper: RequestHandlerWrapper = handler => { + return async (context, request, response) => { + // do some logic + ... + }; +} + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.responseheaders.md b/docs/development/core/server/kibana-plugin-core-server.responseheaders.md index 4551d1cab8632..fb7d6a10c6b6c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.responseheaders.md +++ b/docs/development/core/server/kibana-plugin-core-server.responseheaders.md @@ -9,9 +9,5 @@ Http response headers to set. Signature: ```typescript -export declare type ResponseHeaders = { - [header in KnownHeaders]?: string | string[]; -} & { - [header: string]: string | string[]; -}; +export declare type ResponseHeaders = Record | Record; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.md index 0df97b0d4221a..94d1c378899df 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.md @@ -18,6 +18,7 @@ export interface SavedObject | [error](./kibana-plugin-core-server.savedobject.error.md) | {
message: string;
statusCode: number;
} | | | [id](./kibana-plugin-core-server.savedobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | | [migrationVersion](./kibana-plugin-core-server.savedobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [namespaces](./kibana-plugin-core-server.savedobject.namespaces.md) | string[] | Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. | | [references](./kibana-plugin-core-server.savedobject.references.md) | SavedObjectReference[] | A reference to another saved object. | | [type](./kibana-plugin-core-server.savedobject.type.md) | string | The type of Saved Object. Each plugin can define it's own custom Saved Object types. | | [updated\_at](./kibana-plugin-core-server.savedobject.updated_at.md) | string | Timestamp of the last time this document had been updated. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.namespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.namespaces.md new file mode 100644 index 0000000000000..2a555db01df3b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.namespaces.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObject](./kibana-plugin-core-server.savedobject.md) > [namespaces](./kibana-plugin-core-server.savedobject.namespaces.md) + +## SavedObject.namespaces property + +Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. + +Signature: + +```typescript +namespaces?: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md new file mode 100644 index 0000000000000..711588bdd608c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsAddToNamespacesOptions](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md) + +## SavedObjectsAddToNamespacesOptions interface + + +Signature: + +```typescript +export interface SavedObjectsAddToNamespacesOptions extends SavedObjectsBaseOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [refresh](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.refresh.md) | MutatingOperationRefreshSetting | The Elasticsearch Refresh setting for this operation | +| [version](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.version.md) | string | An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.refresh.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.refresh.md new file mode 100644 index 0000000000000..c0a1008ab5331 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.refresh.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsAddToNamespacesOptions](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md) > [refresh](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.refresh.md) + +## SavedObjectsAddToNamespacesOptions.refresh property + +The Elasticsearch Refresh setting for this operation + +Signature: + +```typescript +refresh?: MutatingOperationRefreshSetting; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.version.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.version.md new file mode 100644 index 0000000000000..9432b4bf80da6 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.version.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsAddToNamespacesOptions](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md) > [version](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.version.md) + +## SavedObjectsAddToNamespacesOptions.version property + +An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. + +Signature: + +```typescript +version?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.addtonamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.addtonamespaces.md new file mode 100644 index 0000000000000..45c9c39f9626a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.addtonamespaces.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [addToNamespaces](./kibana-plugin-core-server.savedobjectsclient.addtonamespaces.md) + +## SavedObjectsClient.addToNamespaces() method + +Adds namespaces to a SavedObject + +Signature: + +```typescript +addToNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsAddToNamespacesOptions): Promise<{}>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | +| id | string | | +| namespaces | string[] | | +| options | SavedObjectsAddToNamespacesOptions | | + +Returns: + +`Promise<{}>` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md new file mode 100644 index 0000000000000..80b58d29d393b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [deleteFromNamespaces](./kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md) + +## SavedObjectsClient.deleteFromNamespaces() method + +Removes namespaces from a SavedObject + +Signature: + +```typescript +deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise<{}>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | +| id | string | | +| namespaces | string[] | | +| options | SavedObjectsDeleteFromNamespacesOptions | | + +Returns: + +`Promise<{}>` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md index 5a8a213d2bccc..7038c0c07012f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md @@ -25,11 +25,13 @@ The constructor for this class is marked as internal. Third-party code should no | Method | Modifiers | Description | | --- | --- | --- | +| [addToNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsclient.addtonamespaces.md) | | Adds namespaces to a SavedObject | | [bulkCreate(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkcreate.md) | | Persists multiple documents batched together as a single request | | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkget.md) | | Returns an array of objects by id | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkupdate.md) | | Bulk Updates multiple SavedObject at once | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.create.md) | | Persists a SavedObject | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.delete.md) | | Deletes a SavedObject | +| [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md) | | Removes namespaces from a SavedObject | | [find(options)](./kibana-plugin-core-server.savedobjectsclient.find.md) | | Find all SavedObjects matching the search query | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.get.md) | | Retrieves a single object | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.update.md) | | Updates an SavedObject | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md new file mode 100644 index 0000000000000..8a2afe6656fa4 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsDeleteFromNamespacesOptions](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md) + +## SavedObjectsDeleteFromNamespacesOptions interface + + +Signature: + +```typescript +export interface SavedObjectsDeleteFromNamespacesOptions extends SavedObjectsBaseOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [refresh](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.refresh.md) | MutatingOperationRefreshSetting | The Elasticsearch Refresh setting for this operation | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.refresh.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.refresh.md new file mode 100644 index 0000000000000..1175b79bc1abd --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.refresh.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsDeleteFromNamespacesOptions](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md) > [refresh](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.refresh.md) + +## SavedObjectsDeleteFromNamespacesOptions.refresh property + +The Elasticsearch Refresh setting for this operation + +Signature: + +```typescript +refresh?: MutatingOperationRefreshSetting; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md new file mode 100644 index 0000000000000..8e04282ce0c71 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [createConflictError](./kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md) + +## SavedObjectsErrorHelpers.createConflictError() method + +Signature: + +```typescript +static createConflictError(type: string, id: string): DecoratedError; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | +| id | string | | + +Returns: + +`DecoratedError` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.decorateescannotexecutescripterror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.decorateescannotexecutescripterror.md new file mode 100644 index 0000000000000..94060bba50067 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.decorateescannotexecutescripterror.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [decorateEsCannotExecuteScriptError](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateescannotexecutescripterror.md) + +## SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError() method + +Signature: + +```typescript +static decorateEsCannotExecuteScriptError(error: Error, reason?: string): DecoratedError; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| error | Error | | +| reason | string | | + +Returns: + +`DecoratedError` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.isescannotexecutescripterror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.isescannotexecutescripterror.md new file mode 100644 index 0000000000000..debb94fe4f8d8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.isescannotexecutescripterror.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [isEsCannotExecuteScriptError](./kibana-plugin-core-server.savedobjectserrorhelpers.isescannotexecutescripterror.md) + +## SavedObjectsErrorHelpers.isEsCannotExecuteScriptError() method + +Signature: + +```typescript +static isEsCannotExecuteScriptError(error: Error | DecoratedError): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| error | Error | DecoratedError | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md index 55a42e4f4eb7a..250b9d3899670 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md @@ -16,12 +16,14 @@ export declare class SavedObjectsErrorHelpers | Method | Modifiers | Description | | --- | --- | --- | | [createBadRequestError(reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.createbadrequesterror.md) | static | | +| [createConflictError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md) | static | | | [createEsAutoCreateIndexError()](./kibana-plugin-core-server.savedobjectserrorhelpers.createesautocreateindexerror.md) | static | | | [createGenericNotFoundError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.creategenericnotfounderror.md) | static | | | [createInvalidVersionError(versionInput)](./kibana-plugin-core-server.savedobjectserrorhelpers.createinvalidversionerror.md) | static | | | [createUnsupportedTypeError(type)](./kibana-plugin-core-server.savedobjectserrorhelpers.createunsupportedtypeerror.md) | static | | | [decorateBadRequestError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decoratebadrequesterror.md) | static | | | [decorateConflictError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateconflicterror.md) | static | | +| [decorateEsCannotExecuteScriptError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateescannotexecutescripterror.md) | static | | | [decorateEsUnavailableError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateesunavailableerror.md) | static | | | [decorateForbiddenError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateforbiddenerror.md) | static | | | [decorateGeneralError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorategeneralerror.md) | static | | @@ -30,6 +32,7 @@ export declare class SavedObjectsErrorHelpers | [isBadRequestError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isbadrequesterror.md) | static | | | [isConflictError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isconflicterror.md) | static | | | [isEsAutoCreateIndexError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isesautocreateindexerror.md) | static | | +| [isEsCannotExecuteScriptError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isescannotexecutescripterror.md) | static | | | [isEsUnavailableError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isesunavailableerror.md) | static | | | [isForbiddenError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isforbiddenerror.md) | static | | | [isInvalidVersionError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isinvalidversionerror.md) | static | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md new file mode 100644 index 0000000000000..173b9e19321d0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) + +## SavedObjectsNamespaceType type + +The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. + +Note: do not write logic that uses this value directly; instead, use the appropriate accessors in the [type registry](./kibana-plugin-core-server.savedobjecttyperegistry.md). + +Signature: + +```typescript +export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md new file mode 100644 index 0000000000000..bbb20d2bc3b96 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [addToNamespaces](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) + +## SavedObjectsRepository.addToNamespaces() method + +Adds one or more namespaces to a given multi-namespace saved object. This method and \[`deleteFromNamespaces`\][SavedObjectsRepository.deleteFromNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. + +Signature: + +```typescript +addToNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsAddToNamespacesOptions): Promise<{}>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | +| id | string | | +| namespaces | string[] | | +| options | SavedObjectsAddToNamespacesOptions | | + +Returns: + +`Promise<{}>` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md new file mode 100644 index 0000000000000..471c3e3c5092d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [deleteFromNamespaces](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) + +## SavedObjectsRepository.deleteFromNamespaces() method + +Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted entirely. This method and \[`addToNamespaces`\][SavedObjectsRepository.addToNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. + +Signature: + +```typescript +deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise<{}>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | +| id | string | | +| namespaces | string[] | | +| options | SavedObjectsDeleteFromNamespacesOptions | | + +Returns: + +`Promise<{}>` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md index 37547af2497e9..bd86ff3abbe9b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -15,12 +15,14 @@ export declare class SavedObjectsRepository | Method | Modifiers | Description | | --- | --- | --- | +| [addToNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) | | Adds one or more namespaces to a given multi-namespace saved object. This method and \[deleteFromNamespaces\][SavedObjectsRepository.deleteFromNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. | | [bulkCreate(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkcreate.md) | | Creates multiple documents at once | | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkget.md) | | Returns an array of objects by id | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkupdate.md) | | Updates multiple objects in bulk | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.create.md) | | Persists an object | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.delete.md) | | Deletes an object | | [deleteByNamespace(namespace, options)](./kibana-plugin-core-server.savedobjectsrepository.deletebynamespace.md) | | Deletes all objects from the provided namespace. | +| [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) | | Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted entirely. This method and \[addToNamespaces\][SavedObjectsRepository.addToNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. | | [find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, })](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object | | [incrementCounter(type, id, counterFieldName, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increases a counter field by one. Creates the document if one doesn't exist for the given id. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md index c24fa7e7038c6..57c9e04966c1b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md @@ -29,7 +29,7 @@ import * as migrations from './migrations'; export const myType: SavedObjectsType = { name: 'MyType', hidden: false, - namespaceAgnostic: true, + namespaceType: 'multiple', mappings: { properties: { textField: { diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md index ad7bf9a0f00d0..a8894286de910 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md @@ -25,5 +25,5 @@ This is only internal for now, and will only be public when we expose the regist | [mappings](./kibana-plugin-core-server.savedobjectstype.mappings.md) | SavedObjectsTypeMappingDefinition | The [mapping definition](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) for the type. | | [migrations](./kibana-plugin-core-server.savedobjectstype.migrations.md) | SavedObjectMigrationMap | An optional map of [migrations](./kibana-plugin-core-server.savedobjectmigrationfn.md) to be used to migrate the type. | | [name](./kibana-plugin-core-server.savedobjectstype.name.md) | string | The name of the type, which is also used as the internal id. | -| [namespaceAgnostic](./kibana-plugin-core-server.savedobjectstype.namespaceagnostic.md) | boolean | Is the type global (true), or namespaced (false). | +| [namespaceType](./kibana-plugin-core-server.savedobjectstype.namespacetype.md) | SavedObjectsNamespaceType | The [namespace type](./kibana-plugin-core-server.savedobjectsnamespacetype.md) for the type. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.namespaceagnostic.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.namespaceagnostic.md deleted file mode 100644 index 8f43db86449d0..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.namespaceagnostic.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsType](./kibana-plugin-core-server.savedobjectstype.md) > [namespaceAgnostic](./kibana-plugin-core-server.savedobjectstype.namespaceagnostic.md) - -## SavedObjectsType.namespaceAgnostic property - -Is the type global (true), or namespaced (false). - -Signature: - -```typescript -namespaceAgnostic: boolean; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.namespacetype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.namespacetype.md new file mode 100644 index 0000000000000..3a3b0f7f3a9a5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.namespacetype.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsType](./kibana-plugin-core-server.savedobjectstype.md) > [namespaceType](./kibana-plugin-core-server.savedobjectstype.namespacetype.md) + +## SavedObjectsType.namespaceType property + +The [namespace type](./kibana-plugin-core-server.savedobjectsnamespacetype.md) for the type. + +Signature: + +```typescript +namespaceType: SavedObjectsNamespaceType; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md new file mode 100644 index 0000000000000..6532c5251d816 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectTypeRegistry](./kibana-plugin-core-server.savedobjecttyperegistry.md) > [isMultiNamespace](./kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md) + +## SavedObjectTypeRegistry.isMultiNamespace() method + +Returns whether the type is multi-namespace (shareable); resolves to `false` if the type is not registered + +Signature: + +```typescript +isMultiNamespace(type: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md index 92dfa5465235a..859c7b9711816 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md @@ -4,7 +4,7 @@ ## SavedObjectTypeRegistry.isNamespaceAgnostic() method -Returns the `namespaceAgnostic` property for given type, or `false` if the type is not registered. +Returns whether the type is namespace-agnostic (global); resolves to `false` if the type is not registered Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.issinglenamespace.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.issinglenamespace.md new file mode 100644 index 0000000000000..18146b2fd6ea1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.issinglenamespace.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectTypeRegistry](./kibana-plugin-core-server.savedobjecttyperegistry.md) > [isSingleNamespace](./kibana-plugin-core-server.savedobjecttyperegistry.issinglenamespace.md) + +## SavedObjectTypeRegistry.isSingleNamespace() method + +Returns whether the type is single-namespace (isolated); resolves to `true` if the type is not registered + +Signature: + +```typescript +isSingleNamespace(type: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md index 410b709252b72..69a94e4ad8c88 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md @@ -22,6 +22,8 @@ export declare class SavedObjectTypeRegistry | [getType(type)](./kibana-plugin-core-server.savedobjecttyperegistry.gettype.md) | | Return the [type](./kibana-plugin-core-server.savedobjectstype.md) definition for given type name. | | [isHidden(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ishidden.md) | | Returns the hidden property for given type, or false if the type is not registered. | | [isImportableAndExportable(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isimportableandexportable.md) | | Returns the management.importableAndExportable property for given type, or false if the type is not registered or does not define a management section. | -| [isNamespaceAgnostic(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md) | | Returns the namespaceAgnostic property for given type, or false if the type is not registered. | +| [isMultiNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md) | | Returns whether the type is multi-namespace (shareable); resolves to false if the type is not registered | +| [isNamespaceAgnostic(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md) | | Returns whether the type is namespace-agnostic (global); resolves to false if the type is not registered | +| [isSingleNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.issinglenamespace.md) | | Returns whether the type is single-namespace (isolated); resolves to true if the type is not registered | | [registerType(type)](./kibana-plugin-core-server.savedobjecttyperegistry.registertype.md) | | Register a [type](./kibana-plugin-core-server.savedobjectstype.md) inside the registry. A type can only be registered once. subsequent calls with the same type name will throw an error. | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.indexpattern.md new file mode 100644 index 0000000000000..d1a1ee0905c6e --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.indexpattern.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [indexPattern](./kibana-plugin-plugins-data-public.indexpatternfield.indexpattern.md) + +## IndexPatternField.indexPattern property + +Signature: + +```typescript +indexPattern?: IndexPattern; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md index 430590c7a2505..df0de6ce0e541 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md @@ -27,9 +27,9 @@ export declare class Field implements IFieldType | [esTypes](./kibana-plugin-plugins-data-public.indexpatternfield.estypes.md) | | string[] | | | [filterable](./kibana-plugin-plugins-data-public.indexpatternfield.filterable.md) | | boolean | | | [format](./kibana-plugin-plugins-data-public.indexpatternfield.format.md) | | any | | +| [indexPattern](./kibana-plugin-plugins-data-public.indexpatternfield.indexpattern.md) | | IndexPattern | | | [lang](./kibana-plugin-plugins-data-public.indexpatternfield.lang.md) | | string | | | [name](./kibana-plugin-plugins-data-public.indexpatternfield.name.md) | | string | | -| [routes](./kibana-plugin-plugins-data-public.indexpatternfield.routes.md) | | Record<string, string> | | | [script](./kibana-plugin-plugins-data-public.indexpatternfield.script.md) | | string | | | [scripted](./kibana-plugin-plugins-data-public.indexpatternfield.scripted.md) | | boolean | | | [searchable](./kibana-plugin-plugins-data-public.indexpatternfield.searchable.md) | | boolean | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.routes.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.routes.md deleted file mode 100644 index 664a7b7b7ca0e..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.routes.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [routes](./kibana-plugin-plugins-data-public.indexpatternfield.routes.md) - -## IndexPatternField.routes property - -Signature: - -```typescript -routes: Record; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.md index 4b7184b7dc151..478b73f5f8581 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.md @@ -24,5 +24,5 @@ export declare class FieldList extends Array implements IFieldList | [getByName](./kibana-plugin-plugins-data-public.indexpatternfieldlist.getbyname.md) | | (name: string) => Field | undefined | | | [getByType](./kibana-plugin-plugins-data-public.indexpatternfieldlist.getbytype.md) | | (type: string) => any[] | | | [remove](./kibana-plugin-plugins-data-public.indexpatternfieldlist.remove.md) | | (field: IFieldType) => void | | -| [update](./kibana-plugin-plugins-data-public.indexpatternfieldlist.update.md) | | (field: Field) => void | | +| [update](./kibana-plugin-plugins-data-public.indexpatternfieldlist.update.md) | | (field: Record<string, any>) => void | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.update.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.update.md index ca03ec4b72893..d5156ed41e493 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.update.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.update.md @@ -7,5 +7,5 @@ Signature: ```typescript -update: (field: Field) => void; +update: (field: Record) => void; ``` diff --git a/docs/images/add_remote_cluster.png b/docs/images/add_remote_cluster.png index 376b1d8392366..160d29b741c62 100755 Binary files a/docs/images/add_remote_cluster.png and b/docs/images/add_remote_cluster.png differ diff --git a/docs/images/auto_follow_pattern.png b/docs/images/auto_follow_pattern.png index 3bf86458eddd7..f80de9352280f 100755 Binary files a/docs/images/auto_follow_pattern.png and b/docs/images/auto_follow_pattern.png differ diff --git a/docs/images/cross-cluster-replication-list-view.png b/docs/images/cross-cluster-replication-list-view.png new file mode 100755 index 0000000000000..4c45174cff7f1 Binary files /dev/null and b/docs/images/cross-cluster-replication-list-view.png differ diff --git a/docs/images/follower_indices.png b/docs/images/follower_indices.png old mode 100644 new mode 100755 index f103bb3cf2acf..505adeb45ae23 Binary files a/docs/images/follower_indices.png and b/docs/images/follower_indices.png differ diff --git a/docs/images/remote-clusters-list-view.png b/docs/images/remote-clusters-list-view.png new file mode 100755 index 0000000000000..c28379863b74b Binary files /dev/null and b/docs/images/remote-clusters-list-view.png differ diff --git a/docs/images/tutorial-ilm-custom-policy.png b/docs/images/tutorial-ilm-custom-policy.png new file mode 100644 index 0000000000000..03b67829f605c Binary files /dev/null and b/docs/images/tutorial-ilm-custom-policy.png differ diff --git a/docs/images/tutorial-ilm-delete-phase-creation.png b/docs/images/tutorial-ilm-delete-phase-creation.png new file mode 100644 index 0000000000000..91a55733c284e Binary files /dev/null and b/docs/images/tutorial-ilm-delete-phase-creation.png differ diff --git a/docs/images/tutorial-ilm-delete-rollover.png b/docs/images/tutorial-ilm-delete-rollover.png new file mode 100644 index 0000000000000..ba021ecc2ac5c Binary files /dev/null and b/docs/images/tutorial-ilm-delete-rollover.png differ diff --git a/docs/images/tutorial-ilm-hotphaserollover-default.png b/docs/images/tutorial-ilm-hotphaserollover-default.png new file mode 100644 index 0000000000000..a9088c63d885d Binary files /dev/null and b/docs/images/tutorial-ilm-hotphaserollover-default.png differ diff --git a/docs/images/tutorial-ilm-modify-default-warm-phase-rollover.png b/docs/images/tutorial-ilm-modify-default-warm-phase-rollover.png new file mode 100644 index 0000000000000..c6f1e9b40e977 Binary files /dev/null and b/docs/images/tutorial-ilm-modify-default-warm-phase-rollover.png differ diff --git a/docs/ingest_manager/index.asciidoc b/docs/ingest_manager/index.asciidoc index 1254f412e14c5..22afa88c919e4 100644 --- a/docs/ingest_manager/index.asciidoc +++ b/docs/ingest_manager/index.asciidoc @@ -113,7 +113,7 @@ Ingest Management enforces an indexing strategy to allow the system to automatic {type}-{dataset}-{namespace} ``` -The `{type}` can be `logs` or `metrics`. The `{namespace}` is the part where the user can use free form. The only two requirement are that it has only characters allowed in an Elasticsearch index name and does NOT contain a `-`. The `dataset` is defined by the data that is indexed. The same requirements as for the namespace apply. It is expected that the fields for type, namespace and dataset are part of each event and are constant keywords. +The `{type}` can be `logs` or `metrics`. The `{namespace}` is the part where the user can use free form. The only two requirement are that it has only characters allowed in an Elasticsearch index name and does NOT contain a `-`. The `dataset` is defined by the data that is indexed. The same requirements as for the namespace apply. It is expected that the fields for type, namespace and dataset are part of each event and are constant keywords. If there is a dataset or a namespace with a `-` inside, it is recommended to replace it either by a `.` or a `_`. Note: More `{type}`s might be added in the future like `apm` and `endpoint`. @@ -126,6 +126,8 @@ This indexing strategy has a few advantages: * Having a global metrics and logs template, allows to create new indices on demand which still follow the convention. This is common in the case of k8s as an example. * Constant keywords allow to narrow down the indices we need to access for querying very efficiently. This is especially relevant in environments which a large number of indices or with indices on slower nodes. +Overall it creates smaller indices in size, makes querying more efficient and allows users to define their own naming parts in namespace and still benefiting from all features that can be built on top of the indexing startegy. + === Ingest Pipeline The ingest pipelines for a specific dataset will have the following naming scheme: diff --git a/docs/management/index-lifecycle-policies/example-index-lifecycle-policy.asciidoc b/docs/management/index-lifecycle-policies/example-index-lifecycle-policy.asciidoc index f68708f1b6394..e6d94e9ca61a3 100644 --- a/docs/management/index-lifecycle-policies/example-index-lifecycle-policy.asciidoc +++ b/docs/management/index-lifecycle-policies/example-index-lifecycle-policy.asciidoc @@ -1,23 +1,179 @@ [role="xpack"] + [[example-using-index-lifecycle-policy]] -=== Example of using an index lifecycle policy +=== Tutorial: Use {ilm-init} to manage {filebeat} time-based indices + +With {ilm} ({ilm-init}), you can create policies that perform actions automatically +on indices as they age and grow. {ilm-init} policies help you to manage +performance, resilience, and retention of your data during its lifecycle. This tutorial shows +you how to use {kib}’s *Index Lifecycle Policies* to modify and create {ilm-init} +policies. You can learn more about all of the actions, benefits, and lifecycle +phases in the {ref}/overview-index-lifecycle-management.html[{ilm-init} overview]. + + +[discrete] +[[example-using-index-lifecycle-policy-scenario]] +==== Scenario + +You’re tasked with sending syslog files to an {es} cluster. This +log data has the following data retention guidelines: + +* Keep logs on hot data nodes for 30 days +* Roll over to a new index if the size reaches 50GB +* After 30 days: +** Move the logs to warm data nodes +** Set {ref}/glossary.html#glossary-replica-shard[replica shards] to 1 +** {ref}/indices-forcemerge.html[Force merge] multiple index segments to free up the space used by deleted documents +* Delete logs after 90 days + + +[discrete] +[[example-using-index-lifecycle-policy-prerequisites]] +==== Prerequisites + +To complete this tutorial, you'll need: + +* An {es} cluster with hot and warm nodes configured for shard allocation +awareness. If you’re using {cloud}/ec-getting-started-templates-hot-warm.html[{ess}], +choose the hot-warm architecture deployment template. + ++ +For a self-managed cluster, add node attributes as described for {ref}/shard-allocation-filtering.html[shard allocation filtering] +to label data nodes as hot or warm. This step is required to migrate shards between +nodes configured with specific hardware for the hot or warm phases. ++ +For example, you can set this in your `elasticsearch.yml` for each data node: ++ +[source,yaml] +-------------------------------------------------------------------------------- +node.attr.data: "warm" +-------------------------------------------------------------------------------- + +* A server with {filebeat} installed and configured to send logs to the `elasticsearch` +output as described in {filebeat-ref}/filebeat-getting-started.html[Getting Started with {filebeat}]. + +[discrete] +[[example-using-index-lifecycle-policy-view-fb-ilm-policy]] +==== View the {filebeat} {ilm-init} policy + +{filebeat} includes a default {ilm-init} policy that enables rollover. {ilm-init} +is enabled automatically if you’re using the default `filebeat.yml` and index template. + +To view the default policy in {kib}, go to *Management > Index Lifecycle Policies*, +search for _filebeat_, and choose the _filebeat-version_ policy. + +This policy initiates the rollover action when the index size reaches 50GB or +becomes 30 days old. + +[role="screenshot"] +image::images/tutorial-ilm-hotphaserollover-default.png["Default policy"] + + +[float] +==== Modify the policy + +The default policy is enough to prevent the creation of many tiny daily indices. +You can modify the policy to meet more complex requirements. + +. Activate the warm phase. + ++ +. Set either of the following options to control when the index moves to the warm phase: + +** Provide a value for *Timing for warm phase*. Setting this to *15* keeps the +indices on hot nodes for a range of 15-45 days, depending on when the initial +rollover occurred. + +** Enable *Move to warm phase on rollover*. The index might move to the warm phase +more quickly than intended if it reaches the *Maximum index size* before the +the *Maximum age*. + +. In the *Select a node attribute to control shard allocation* dropdown, select +*data:warm(2)* to migrate shards to warm data nodes. + +. Change *Number of replicas* to *1*. + +. Enable *Force merge data* and set *Number of segments* to *1*. ++ +NOTE: When rollover is enabled in the hot phase, action timing in the other phases +is based on the rollover date. + ++ +[role="screenshot"] +image::images/tutorial-ilm-modify-default-warm-phase-rollover.png["Modify to add warm phase"] + +. Activate the delete phase and set *Timing for delete phase* to *90* days. ++ +[role="screenshot"] +image::images/tutorial-ilm-delete-rollover.png["Add a delete phase"] + +[float] +==== Create a custom policy + +If meeting a specific retention time period is most important, you can create a +custom policy. For this option, you will use {filebeat} daily indices without +rollover. + +. Create a custom policy in {kib}, go to *Management > Index Lifecycle Policies > +Create Policy*. + +. Activate the warm phase and configure it as follows: ++ +|=== +|*Setting* |*Value* + +|Timing for warm phase +|30 days from index creation + +|Node attribute +|`data:warm` + +|Number of replicas +|1 + +|Force merge data +|enable + +|Number of segments +|1 +|=== + ++ +[role="screenshot"] +image::images/tutorial-ilm-custom-policy.png["Modify the custom policy to add a warm phase"] + -A common use case for managing index lifecycle policies is when you’re using -{beats-ref}/beats-reference.html[Beats] to continually send time-series data, -such as metrics and log data, to {es}. When you create the Beats packages, an -index template is installed. The template includes a default policy to apply -when new indices are created. ++ +. Activate the delete phase and set the timing. ++ +|=== +|*Setting* |*Value* +|Timing for delete phase +|90 +|=== -You can edit the policy in {kib}'s *Index Lifecycle Policies*. For example, you might: ++ +[role="screenshot"] +image::images/tutorial-ilm-delete-phase-creation.png["Delete phase"] -* Rollover the index when it reaches 50 GB in size or is 30 days old. These -settings are the default for the Beats lifecycle policy. This avoids -having 1000s of tiny indices. When a rollover occurs, a new “hot” index is -created and added to the index alias. +. Configure the index to use the new policy in *{kib} > Management > Index Lifecycle +Policies* -* Move the index into the warm phase, shrink the index down to a single shard, -and force merge to a single segment. +.. Find your {ilm-init} policy. +.. Click the *Actions* link next to your policy name. +.. Choose *Add policy to index template*. +.. Select your {filebeat} index template name from the *Index template* list. For example, `filebeat-7.5.x`. +.. Click *Add Policy* to save the changes. -* After 60 days, move the index into the cold phase and onto less expensive hardware. ++ +NOTE: If you initially used the default {filebeat} {ilm-init} policy, you will +see a notice that the template already has a policy associated with it. Confirm +that you want to overwrite that configuration. -* Delete the index after 90 days. ++ ++ +TIP: When you change the policy associated with the index template, the active +index will continue to use the policy it was associated with at index creation +unless you manually update it. The next new index will use the updated policy. +For more reasons that your {ilm-init} policy changes might be delayed, see +{ref}/update-lifecycle-policy.html#update-lifecycle-policy[Update Lifecycle Policy]. diff --git a/docs/management/managing-ccr.asciidoc b/docs/management/managing-ccr.asciidoc new file mode 100644 index 0000000000000..b2db5a80cfe7e --- /dev/null +++ b/docs/management/managing-ccr.asciidoc @@ -0,0 +1,73 @@ +[role="xpack"] +[[managing-cross-cluster-replication]] +== Cross-Cluster Replication + +Use *Cross-Cluster Replication* to reproduce indices in +remote clusters on a local cluster. {ref}/xpack-ccr.html[Cross-cluster replication] +is commonly used to provide remote backups for disaster recovery and for +geo-proximite copies of data. + +To get started, go to *Management > Cross-Cluster Replication*. + +[role="screenshot"] +image::images/cross-cluster-replication-list-view.png[][Cross-cluster replication list view] + +[float] +=== Prerequisites + +* You must have a {ref}/modules-remote-clusters.html[remote cluster]. +* Leader indices must meet {ref}/ccr-requirements.html[these requirements]. +* 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] +[[configure-replication]] +=== Configure replication + +Replication requires a leader index, the index being replicated, and a +follower index, which will contain the leader index's replicated data. +The follower index is passive in that it can read requests and searches, +but cannot accept direct writes. Only the leader index is active for direct writes. + +You can configure follower indices in two ways: + +* Create specific follower indices +* Create follower indices from an auto-follow pattern + +[float] +==== Create specific follower indices + +To replicate data from existing indices, or set up local followers on a case-by-case basis, +go to *Follower indices*. When you create the follower index, you must reference the +remote cluster and the leader index that you created in the remote cluster. + +[role="screenshot"] +image::images/follower_indices.png[][UI for adding follower indices] + +[float] +==== Create follower indices from an auto-follow pattern + +To automatically detect and follow new indices when they are created on a remote cluster, +go to *Auto-follow patterns*. Creating an auto-follow pattern is useful when you have +time series data, like event logs, on the remote cluster that is created or rolled over on a daily basis. + +When creating the pattern, you must reference the remote cluster that you +connected to your local cluster. You must also specify a collection of index patterns +that match the indices you want to automatically follow. + +Once you configure an +auto-follow pattern, any time a new index with a name that matches the pattern is +created in the remote cluster, a follower index is automatically configured in the local cluster. + +[role="screenshot"] +image::images/auto_follow_pattern.png[UI for adding an auto-follow pattern] + +[float] +[[manage-replication]] +=== Manage replication + +Use the list views in *Cross-Cluster Replication* to monitor whether the replication is active and +pause and resume replication. You can also edit and remove the follower indices and auto-follow patterns. + +For an example of cross-cluster replication, +refer to https://www.elastic.co/blog/bi-directional-replication-with-elasticsearch-cross-cluster-replication-ccr[Bi-directional replication with Elasticsearch cross-cluster replication]. diff --git a/docs/management/managing-remote-clusters.asciidoc b/docs/management/managing-remote-clusters.asciidoc index 6b69cfef5b768..00ec5c7d2ddea 100644 --- a/docs/management/managing-remote-clusters.asciidoc +++ b/docs/management/managing-remote-clusters.asciidoc @@ -1,67 +1,39 @@ [[working-remote-clusters]] == Remote Clusters -{kib} *Management* provides user interfaces for working with data from remote -clusters and managing the {ccr} process. You can replicate indices from a -leader remote cluster to a follower index in a local cluster. The local follower indices -can be used to provide remote backups for disaster recovery or for geo-proximite copies of data. +Use *Remote Clusters* to establish a unidirectional +connection from your cluster to other clusters. This functionality is +required for {ref}/xpack-ccr.html[cross-cluster replication] and +{ref}/modules-cross-cluster-search.html[cross-cluster search]. -Before using these features, you should be familiar with the following concepts: +To get started, go to *Management > Remote Clusters*. -* {ref}/xpack-ccr.html[{ccr-cap}] -* {ref}/modules-cross-cluster-search.html[{ccs-cap}] -* {ref}/cross-cluster-configuring.html[Cross-cluster security requirements] +[role="screenshot"] +image::images/remote-clusters-list-view.png[Remote Clusters list view, including Add a remote cluster button] [float] [[managing-remote-clusters]] -== Managing remote clusters - -*Remote clusters* helps you manage remote clusters for use with -{ccs} and {ccr}. You can add and remove remote clusters and check their connectivity. +=== Add a remote cluster -Before you use this feature, you should be familiar with the concept of -{ref}/modules-remote-clusters.html[remote clusters]. +A {ref}/modules-remote-clusters.html[remote cluster] connection works by configuring a remote cluster and +connecting to a limited number of nodes, called {ref}/modules-remote-clusters.html#sniff-mode[seed nodes], +in that cluster. +Alternatively, you can define a single proxy address for the remote cluster. -Go to *Management > Elasticsearch > Remote clusters* to create or manage your remotes. +By default, a cross-cluster request, such as a cross-cluster search or +replication request, fails if any cluster in the request is unavailable. +To skip a cluster when its unavailable, +set *Skip if unavailable* to true. -To set up a new remote, click *Add a remote cluster*. Give the cluster a unique name -and define the seed nodes for cluster discovery. You can edit or remove your remote clusters -from the *Remote clusters* list view. +Once you add a remote cluster, you can configure <> +to reproduce indices in the remote cluster on a local cluster. [role="screenshot"] image::images/add_remote_cluster.png[][UI for adding a remote cluster] -Once a remote cluster is registered, you can use the tools under *{ccr-cap}* -to add and manage follower indices on the local cluster, and replicate data from -indices on the remote cluster based on an auto-follow index pattern. - [float] -[[managing-cross-cluster-replication]] -== [xpack]#Managing {ccr}# - -*{ccr-cap}* helps you create and manage the {ccr} process. -If you want to replicate data from existing indices, or set up -local followers on a case-by-case basis, go to *Follower indices*. -If you want to automatically detect and follow new indices when they are created -on a remote cluster, you can do so from *Auto-follow patterns*. - -Creating an auto-follow pattern is useful when you have time-series data, like a logs index, on the -remote cluster that is created or rolled over on a daily basis. Once you have configured an -auto-follow pattern, any time a new index with a name that matches the pattern is -created in the remote cluster, a follower index is automatically configured in the local cluster. - -From the same view, you can also see a list of your saved auto-follow patterns for -a given remote cluster, and monitor whether the replication is active. +[[manage-remote-clusters]] +=== Manage remote clusters -Before you use these features, you should be familiar with the following concepts: - -* {ref}/ccr-requirements.html[Requirements for leader indices] -* {ref}/ccr-auto-follow.html[Automatically following indices] - -To get started, go to *Management > Elasticsearch > {ccr-cap}*. - -[role="screenshot"] -image::images/auto_follow_pattern.png[][UI for adding an auto-follow pattern] - -[role="screenshot"] -image::images/follower_indices.png[][UI for adding follower indices] +From the *Remote Clusters* list view, you can drill down into each cluster and +view its status. You can also edit and delete a cluster. diff --git a/docs/redirects.asciidoc b/docs/redirects.asciidoc index fd835bde83322..a5503969a3ec1 100644 --- a/docs/redirects.asciidoc +++ b/docs/redirects.asciidoc @@ -23,7 +23,7 @@ For more {kib} configuration settings, see <>. [role="exclude",id="uptime-security"] == Uptime security -This page has moved. Please see the new section in the {uptime-guide}/uptime-security.html[Uptime Monitoring Guide]. +This page has moved. Please see the new section in the {heartbeat-ref}/securing-heartbeat.html[Uptime Monitoring Guide]. [role="exclude",id="infra-read-only-access"] == Configure source read-only access diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index d7f1ec637d1df..d4dbe9407b7a9 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -9,7 +9,7 @@ Alerts and actions are enabled by default in {kib}, but require you configure th . <>. . <>. -. <>. +. <>. You can configure the following settings in the `kibana.yml` file. @@ -18,7 +18,7 @@ You can configure the following settings in the `kibana.yml` file. [[general-alert-action-settings]] ==== General settings -`xpack.encrypted_saved_objects.encryptionKey`:: +`xpack.encryptedSavedObjects.encryptionKey`:: A string of 32 or more characters used to encrypt sensitive properties on alerts and actions before they're stored in {es}. Third party credentials — such as the username and password used to connect to an SMTP service — are an example of encrypted properties. + diff --git a/docs/settings/apm-settings.asciidoc b/docs/settings/apm-settings.asciidoc index 91bbef5690fd5..fd53c3aeb3605 100644 --- a/docs/settings/apm-settings.asciidoc +++ b/docs/settings/apm-settings.asciidoc @@ -5,7 +5,10 @@ APM settings ++++ -You do not need to configure any settings to use the APM app. It is enabled by default. +These settings allow the APM app to function, and specify the data that it surfaces. +Unless you've customized your setup, +you do not need to configure any settings to use the APM app. +It is enabled by default. [float] [[apm-indices-settings-kb]] @@ -33,29 +36,29 @@ image::settings/images/apm-settings.png[APM app settings in Kibana] If you'd like to change any of the default values, copy and paste the relevant settings into your `kibana.yml` configuration file. +Changing these settings may disable features of the APM App. -xpack.apm.enabled:: Set to `false` to disabled the APM plugin {kib}. Defaults to -`true`. +xpack.apm.enabled:: Set to `false` to disable the APM app. Defaults to `true`. -xpack.apm.ui.enabled:: Set to `false` to hide the APM plugin {kib} from the menu. Defaults to -`true`. +xpack.apm.ui.enabled:: Set to `false` to hide the APM app from the menu. Defaults to `true`. -xpack.apm.ui.transactionGroupBucketSize:: Number of top transaction groups displayed in APM plugin in Kibana. Defaults to `100`. +xpack.apm.ui.transactionGroupBucketSize:: Number of top transaction groups displayed in the APM app. Defaults to `100`. -xpack.apm.ui.maxTraceItems:: Max number of child items displayed when viewing trace details. Defaults to `1000`. +xpack.apm.ui.maxTraceItems:: Maximum number of child items displayed when viewing trace details. Defaults to `1000`. -apm_oss.indexPattern:: Index pattern is used for integrations with Machine Learning and Kuery Bar. It must match all apm indices. Defaults to `apm-*`. +apm_oss.indexPattern:: The index pattern used for integrations with Machine Learning and Query Bar. +It must match all apm indices. Defaults to `apm-*`. -apm_oss.errorIndices:: Matcher for indices containing error documents. Defaults to `apm-*`. +apm_oss.errorIndices:: Matcher for all {apm-server-ref}/error-indices.html[error indices]. Defaults to `apm-*`. -apm_oss.onboardingIndices:: Matcher for indices containing onboarding documents. Defaults to `apm-*`. +apm_oss.onboardingIndices:: Matcher for all onboarding indices. Defaults to `apm-*`. -apm_oss.spanIndices:: Matcher for indices containing span documents. Defaults to `apm-*`. +apm_oss.spanIndices:: Matcher for all {apm-server-ref}/span-indices.html[span indices]. Defaults to `apm-*`. -apm_oss.transactionIndices:: Matcher for indices containing transaction documents. Defaults to `apm-*`. +apm_oss.transactionIndices:: Matcher for all {apm-server-ref}/transaction-indices.html[transaction indices]. Defaults to `apm-*`. -apm_oss.metricsIndices:: Matcher for indices containing metric documents. Defaults to `apm-*`. +apm_oss.metricsIndices:: Matcher for all {apm-server-ref}/metricset-indices.html[metrics indices]. Defaults to `apm-*`. -apm_oss.sourcemapIndices:: Matcher for indices containing sourcemap documents. Defaults to `apm-*`. +apm_oss.sourcemapIndices:: Matcher for all {apm-server-ref}/sourcemap-indices.html[source map indices]. Defaults to `apm-*`. // end::general-apm-settings[] diff --git a/docs/uptime-guide/index.asciidoc b/docs/uptime-guide/index.asciidoc index 7bbc01bb303f1..09763182fa88f 100644 --- a/docs/uptime-guide/index.asciidoc +++ b/docs/uptime-guide/index.asciidoc @@ -12,4 +12,3 @@ include::install.asciidoc[] include::deployment-arch.asciidoc[] -include::security.asciidoc[] diff --git a/docs/uptime-guide/install.asciidoc b/docs/uptime-guide/install.asciidoc index e7c50bb7604ce..0ed1270ca92ce 100644 --- a/docs/uptime-guide/install.asciidoc +++ b/docs/uptime-guide/install.asciidoc @@ -56,6 +56,11 @@ Additional information is available in {heartbeat-ref}/heartbeat-configuration.h [role="screenshot"] image::images/uptime-setup.png[Installation instructions on the Uptime page in Kibana] +[[setup-security]] +=== Step 4: Setup Security + +Secure your installation by following the {heartbeat-ref}/securing-heartbeat.html[Secure Heartbeat] documentation. + [float] ==== Important considerations diff --git a/docs/uptime-guide/security.asciidoc b/docs/uptime-guide/security.asciidoc deleted file mode 100644 index 0c6fa4c6c4f56..0000000000000 --- a/docs/uptime-guide/security.asciidoc +++ /dev/null @@ -1,60 +0,0 @@ -[[uptime-security]] -== Elasticsearch Security - -If you use Elasticsearch security, you'll need to enable certain privileges for users -that would like to access the Uptime app. For example, create user and support roles to implement the privileges: - -[float] -=== Create a role - -You'll need a role that lets you access the Heartbeat indices, which by default are `heartbeat-*`. -You can create this with the following request: - -["source","sh",subs="attributes,callouts"] ---------------------------------------------------------------- -PUT /_security/role/uptime -{ "indices" : [ - { - "names" : [ - "heartbeat-*" - ], - "privileges" : [ - "read", - "view_index_metadata" - ], - "field_security" : { - "grant" : [ - "*" - ] - }, - "allow_restricted_indices" : false - } - ], - "transient_metadata" : { - "enabled" : true - } -} ---------------------------------------------------------------- -// CONSOLE - -[float] -=== Assign the role to a user - -Next, you'll need to create a user with both the `uptime` role, and another role with sufficient {kibana-ref}/kibana-privileges.html[Kibana privileges], -such as the `kibana_admin` role. -You can do this with the following request: - -["source","sh",subs="attributes,callouts"] ---------------------------------------------------------------- -PUT /_security/user/jacknich -{ - "password" : "j@rV1s", - "roles" : [ "uptime", "kibana_admin" ], - "full_name" : "Jack Nicholson", - "email" : "jacknich@example.com", - "metadata" : { - "intelligence" : 7 - } -} ---------------------------------------------------------------- -// CONSOLE diff --git a/docs/uptime/images/alert-flyout.png b/docs/uptime/images/alert-flyout.png new file mode 100644 index 0000000000000..7fc1e3d9aefe2 Binary files /dev/null and b/docs/uptime/images/alert-flyout.png differ diff --git a/docs/uptime/images/check-history.png b/docs/uptime/images/check-history.png index 6418495eee9ed..91565bf59aa7f 100644 Binary files a/docs/uptime/images/check-history.png and b/docs/uptime/images/check-history.png differ diff --git a/docs/uptime/images/error-list.png b/docs/uptime/images/error-list.png deleted file mode 100644 index 99f017f2945a5..0000000000000 Binary files a/docs/uptime/images/error-list.png and /dev/null differ diff --git a/docs/uptime/images/monitor-charts.png b/docs/uptime/images/monitor-charts.png index dbfa43f47656e..522f34662657e 100644 Binary files a/docs/uptime/images/monitor-charts.png and b/docs/uptime/images/monitor-charts.png differ diff --git a/docs/uptime/images/observability_integrations.png b/docs/uptime/images/observability_integrations.png index d5c612c7589ca..6589c0c5565dd 100644 Binary files a/docs/uptime/images/observability_integrations.png and b/docs/uptime/images/observability_integrations.png differ diff --git a/docs/uptime/images/settings.png b/docs/uptime/images/settings.png new file mode 100644 index 0000000000000..dd36f0a6d702b Binary files /dev/null and b/docs/uptime/images/settings.png differ diff --git a/docs/uptime/images/snapshot-view.png b/docs/uptime/images/snapshot-view.png index 020396d0f3e4c..1fce2e9592c14 100644 Binary files a/docs/uptime/images/snapshot-view.png and b/docs/uptime/images/snapshot-view.png differ diff --git a/docs/uptime/images/status-bar.png b/docs/uptime/images/status-bar.png index e0e9b27555900..8d242789cdccd 100644 Binary files a/docs/uptime/images/status-bar.png and b/docs/uptime/images/status-bar.png differ diff --git a/docs/uptime/index.asciidoc b/docs/uptime/index.asciidoc index 785b9f818f5bf..a355f8ecf4843 100644 --- a/docs/uptime/index.asciidoc +++ b/docs/uptime/index.asciidoc @@ -12,8 +12,10 @@ To get started with Elastic Uptime, refer to {uptime-guide}/install-uptime.html[ * <> * <> +* <> -- include::overview.asciidoc[] include::monitor.asciidoc[] +include::settings.asciidoc[] diff --git a/docs/uptime/monitor.asciidoc b/docs/uptime/monitor.asciidoc index d54fd02c7c069..8a4be1f11a721 100644 --- a/docs/uptime/monitor.asciidoc +++ b/docs/uptime/monitor.asciidoc @@ -5,21 +5,24 @@ The Monitor page will help you get further insight into the performance of a specific network endpoint. You'll see a detailed visualization of the monitor's request duration over time, as well as the `up`/`down` -status over time. +status over time. You can also also detect anomalies in response time data +by configuring Machine Learning jobs on this page. [float] -=== Status bar +=== Status panel [role="screenshot"] image::uptime/images/status-bar.png[Status bar] -The Status bar displays a quick summary of the latest information +The Status panel displays a quick summary of the latest information regarding your monitor. You can view its latest status, click a link to visit the targeted URL, see its most recent request duration, and determine the amount of time that has elapsed since the last check. -You can use the Status bar to get a quick summary of current performance, -beyond simply knowing if the monitor is `up` or `down`. +When two Heartbeat instances are configured in different geographic locations +the map will show each location as a pinpoint on the map, along with the +amount of time elapsed since data was last received from that location. + [float] === Monitor charts @@ -32,12 +35,14 @@ date range. These charts can help you gain insight into how quickly requests are by the targeted endpoint, and give you a sense of how frequently a host or endpoint was down in your selected timespan. -The first chart displays request duration information for your monitor. +The Monitor duration chart displays request duration information for your monitor. The area surrounding the line is the range of request time for the corresponding -bucket. The line is the average time. +bucket. The line is the average time. Anomaly detection using Machine Learning +can be configured in the upper right hand of this panel. When response times change +in an unexpected way the time range in which they occurred will be given filled with a color. -Next, is a graphical representation of the check statuses over time. Hover over -the charts to display crosshairs with more specific numeric data. +The pings over time chart is a graphical representation of the check statuses over time. +Hover over the charts to display crosshairs with more specific numeric data. [role="screenshot"] image::uptime/images/crosshair-example.png[Chart crosshair] @@ -49,6 +54,6 @@ image::uptime/images/crosshair-example.png[Chart crosshair] image::uptime/images/check-history.png[Check history view] The Check history displays the total count of this monitor's checks for the selected -date range. You can additionally filter the checks by `status` to help find recent problems +date range. You can additionally filter the checks by status and location to help find recent problems on a per-check basis. This table can help you gain some insight into more granular details about recent individual data points Heartbeat is logging about your host or endpoint. diff --git a/docs/uptime/overview.asciidoc b/docs/uptime/overview.asciidoc index 098ce12a56991..71c09c968e512 100644 --- a/docs/uptime/overview.asciidoc +++ b/docs/uptime/overview.asciidoc @@ -21,12 +21,12 @@ This control allows you to use automated filter options, as well as input custom text to select specific monitors by field, URL, ID, and other attributes. [float] -=== Snapshot view +=== Snapshot panel [role="screenshot"] image::uptime/images/snapshot-view.png[Snapshot view] -This view is intended to quickly give you a sense of the overall +This panel is intended to quickly give you a sense of the overall status of the environment you're monitoring, or a subset of those monitors. Here, you can see the total number of detected monitors within the selected Uptime date range. In addition to the total, the counts for the number of monitors @@ -49,6 +49,17 @@ way to navigate to a more in-depth visualization for interesting hosts or endpoi This table includes information like the most recent status, when the monitor was last checked, its ID and URL, its IP address, and a dedicated sparkline showing its check status over time. +[float] +=== Creating and managing alerts + +[role="screenshot"] +image::uptime/images/alert-flyout.png[Create alert flyout] + +To receive alerts when a monitor goes down, use the alerting menu at the top of the +overview page. Use a query in the alert flyout to determine which monitors to check +with your alert. If you already have a query in the overview page search bar it will +be carried over into this box. + [float] === Observability integrations @@ -60,14 +71,3 @@ Docker related host information, it will provide links to open the Metrics app o for this host. Additionally, this feature supplies links to simply filter the other views on the host's IP address, to help you quickly determine if these other solutions contain data relevant to your current interest. - -[float] -=== Error list - -[role="screenshot"] -image::uptime/images/error-list.png[Error list] - -The Error list displays aggregations of errors that Heartbeat has logged. Errors are -displayed by Error type, monitor ID, and message. Clicking a monitor's ID will take you -to the corresponding Monitor view, which can provide you richer information about the individual -data points that are resulting in the displayed errors. diff --git a/docs/uptime/settings.asciidoc b/docs/uptime/settings.asciidoc new file mode 100644 index 0000000000000..55da6e802bec6 --- /dev/null +++ b/docs/uptime/settings.asciidoc @@ -0,0 +1,27 @@ +[role="xpack"] +[[uptime-settings]] + +== Settings + +[role="screenshot"] +image::uptime/images/settings.png[Filter bar] + +The Uptime settings page lets you change which Heartbeat indices are displayed +by the uptime app. Users must have the 'all' permission to modify items on this page. +Uptime settings apply to the current space only. Use different settings in different +spaces to segment different uptime use cases and domains. + +As an example, imagine your organization has one team for internal IT services, and another +for public services. Each team operates independently and is only responsible for its +own services. In this scenario, you might set up separate Heartbeat instances for each team, +writing out to index patterns named `it-heartbeat-\*`, and `external-heartbeat-\*`. You would +create separate roles and users for each in Elasticsearch, each with access to their own spaces, +named `it` and `external` respectively. Within each space you would navigate to the settings page +and set the correct index pattern to match only the indices that space is allowed to access. + +Note that the pattern set here only restricts what the Uptime app shows. Users may still be able +to manually query Elasticsearch for data outside this pattern! + +See the <> +and {heartbeat-ref}/securing-heartbeat.html[Heartbeat security] +docs for more information. diff --git a/docs/user/alerting/action-types.asciidoc b/docs/user/alerting/action-types.asciidoc index 02c09736e1fa0..2913bf28dd765 100644 --- a/docs/user/alerting/action-types.asciidoc +++ b/docs/user/alerting/action-types.asciidoc @@ -2,181 +2,46 @@ [[action-types]] == Action and connector types -{kib} provides the following types of actions: +Actions are Kibana services or integrations with third-party systems that run as background tasks on the Kibana server when alert conditions are met. {kib} provides the following types of actions: -* <> -* <> -* <> -* <> -* <> -* <> +[cols="2"] +|=== -This section describes how to configure connectors and actions for each type. +a| <> -[NOTE] -============================================== -Some action types are paid commercial features, while others are free. -For a comparison of the Elastic license levels, -see https://www.elastic.co/subscriptions[the subscription page]. -============================================== - -[float] -[[email-action-type]] -=== Email - -The email action type uses the SMTP protocol to send mail message, using an integration of https://nodemailer.com/[Nodemailer]. Email message text is sent as both plain text and html text. - -[float] -[[email-connector-configuration]] -==== Connector configuration - -Email connectors have the following configuration properties: - -Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. -Sender:: The from address for all emails sent with this connector, specified in `user@host-name` format. -Host:: Host name of the service provider. If you are using the <> setting, make sure this hostname is whitelisted. -Port:: The port to connect to on the service provider. -Secure:: If true the connection will use TLS when connecting to the service provider. See https://nodemailer.com/smtp/#tls-options[nodemailer TLS documentation] for more information. -Username:: username for 'login' type authentication. -Password:: password for 'login' type authentication. - -[float] -[[email-action-configuration]] -==== Action configuration - -Email actions have the following configuration properties: - -To, CC, BCC:: Each is a list of addresses. Addresses can be specified in `user@host-name` format, or in `name ` format. One of To, CC, or BCC must contain an entry. -Subject:: The subject line of the email. -Message:: The message text of the email. Markdown format is supported. - -[float] -[[index-action-type]] -=== Index - -The index action type will index a document into {es}. - -[float] -[[index-connector-configuration]] -==== Connector configuration - -Index connectors have the following configuration properties: - -Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. -Index:: The {es} index to be written to. -Refresh:: Setting for the {ref}/docs-refresh.html[refresh] policy for the write request. -Execution time field:: This field will be automatically set to the time the alert condition was detected. - -[float] -[[index-action-configuration]] -==== Action configuration - -Index actions have the following properties: - -Document:: The document to index in json format. - -[float] -[[pagerduty-action-type]] -=== PagerDuty - -The PagerDuty action type uses the https://v2.developer.pagerduty.com/docs/events-api-v2[v2 Events API] to trigger, acknowledge, and resolve PagerDuty alerts. - -[float] -[[pagerduty-connector-configuration]] -==== Connector configuration +| Send email from your server. -PagerDuty connectors have the following configuration properties: +a| <> -Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. -API URL:: An optional PagerDuty event URL. Defaults to `https://events.pagerduty.com/v2/enqueue`. If you are using the <> setting, make sure the hostname is whitelisted. -Routing Key:: A 32 character PagerDuty Integration Key for an integration on a service or on a global ruleset. +| Index data into Elasticsearch. -[float] -[[pagerduty-action-configuration]] -==== Action configuration +a| <> -PagerDuty actions have the following properties: +| Send an event in PagerDuty. -Severity:: The perceived severity of on the affected system. This can be one of `Critical`, `Error`, `Warning` or `Info`(default). -Event action:: One of `Trigger` (default), `Resolve`, or `Acknowledge`. See https://v2.developer.pagerduty.com/docs/events-api-v2#event-action[event action] for more details. -Dedup Key:: All actions sharing this key will be associated with the same PagerDuty alert. This value is used to correlate trigger and resolution. This value is *optional*, and if unset defaults to `action:`. The maximum length is *255* characters. See https://v2.developer.pagerduty.com/docs/events-api-v2#alert-de-duplication[alert deduplication] for details. -Timestamp:: An *optional* https://v2.developer.pagerduty.com/v2/docs/types#datetime[ISO-8601 format date-time], indicating the time the event was detected or generated. -Component:: An *optional* value indicating the component of the source machine that is responsible for the event, for example `mysql` or `eth0`. -Group:: An *optional* value indicating the logical grouping of components of a service, for example `app-stack`. -Source:: An *optional* value indicating the affected system, preferably a hostname or fully qualified domain name. Defaults to the {kib} saved object id of the action. -Summary:: An *optional* text summary of the event, defaults to `No summary provided`. The maximum length is 1024 characters. -Class:: An *optional* value indicating the class/type of the event, for example `ping failure` or `cpu load`. +a| <> -For more details on these properties, see https://v2.developer.pagerduty.com/v2/docs/send-an-event-events-api-v2[PagerDuty v2 event parameters]. +| Add a message to a Kibana log. -[float] -[[server-log-action-type]] -=== Server log +a| <> -This action type writes and entry to the {kib} server log. +| Send a message to a Slack channel or user. -[float] -[[server-log-connector-configuration]] -==== Connector configuration +a| <> -Server log connectors have the following configuration properties: +| Send a request to a web service. +|=== -Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. - -[float] -[[server-log-action-configuration]] -==== Action configuration - -Server log actions have the following properties: - -Message:: The message to log. - -[float] -[[slack-action-type]] -=== Slack - -The Slack action type uses https://api.slack.com/incoming-webhooks[Slack Incoming Webhooks]. - -[float] -[[slack-connector-configuration]] -==== Connector configuration - -Slack connectors have the following configuration properties: - -Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. -Webhook URL:: The URL of the incoming webhook. See https://api.slack.com/messaging/webhooks#getting_started[Slack Incoming Webhooks] for instructions on generating this URL. If you are using the <> setting, make sure the hostname is whitelisted. - -[float] -[[slack-action-configuration]] -==== Action configuration - -Slack actions have the following properties: - -Message:: The message text, converted to the `text` field in the Webhook JSON payload. Currently only the text field is supported. Markdown, images, and other advanced formatting are not yet supported. - -[float] -[[webhook-action-type]] -=== Webhook - -The Webhook action type uses https://github.com/axios/axios[axios] to send a POST or PUT request to a web service. - -[float] -[[webhook-connector-configuration]] -==== Connector configuration - -Webhook connectors have the following configuration properties: - -Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. -URL:: The request URL. If you are using the <> setting, make sure the hostname is whitelisted. -Method:: HTTP request method, either `post`(default) or `put`. -Headers:: A set of key-value pairs sent as headers with the request -User:: An optional username. If set, HTTP basic authentication is used. Currently only basic authentication is supported. -Password:: An optional password. If set, HTTP basic authentication is used. Currently only basic authentication is supported. - -[float] -[[webhook-action-configuration]] -==== Action configuration - -Webhook actions have the following properties: +[NOTE] +============================================== +Some action types are paid commercial features, while others are free. +For a comparison of the Elastic subscription levels, +see https://www.elastic.co/subscriptions[the subscription page]. +============================================== -Body:: A json payload sent to the request URL. \ No newline at end of file +include::action-types/email.asciidoc[] +include::action-types/index.asciidoc[] +include::action-types/pagerduty.asciidoc[] +include::action-types/server-log.asciidoc[] +include::action-types/slack.asciidoc[] +include::action-types/webhook.asciidoc[] diff --git a/docs/user/alerting/action-types/email.asciidoc b/docs/user/alerting/action-types/email.asciidoc new file mode 100644 index 0000000000000..be3623dd9e59c --- /dev/null +++ b/docs/user/alerting/action-types/email.asciidoc @@ -0,0 +1,29 @@ +[role="xpack"] +[[email-action-type]] +== Email action type + +The email action type uses the SMTP protocol to send mail message, using an integration of https://nodemailer.com/[Nodemailer]. Email message text is sent as both plain text and html text. + +[float] +[[email-connector-configuration]] +==== Connector configuration + +Email connectors have the following configuration properties: + +Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. +Sender:: The from address for all emails sent with this connector, specified in `user@host-name` format. +Host:: Host name of the service provider. If you are using the <> setting, make sure this hostname is whitelisted. +Port:: The port to connect to on the service provider. +Secure:: If true the connection will use TLS when connecting to the service provider. See https://nodemailer.com/smtp/#tls-options[nodemailer TLS documentation] for more information. +Username:: username for 'login' type authentication. +Password:: password for 'login' type authentication. + +[float] +[[email-action-configuration]] +==== Action configuration + +Email actions have the following configuration properties: + +To, CC, BCC:: Each is a list of addresses. Addresses can be specified in `user@host-name` format, or in `name ` format. One of To, CC, or BCC must contain an entry. +Subject:: The subject line of the email. +Message:: The message text of the email. Markdown format is supported. \ No newline at end of file diff --git a/docs/user/alerting/action-types/index.asciidoc b/docs/user/alerting/action-types/index.asciidoc new file mode 100644 index 0000000000000..75d9e57b1f212 --- /dev/null +++ b/docs/user/alerting/action-types/index.asciidoc @@ -0,0 +1,24 @@ +[role="xpack"] +[[index-action-type]] +== Index action type + +The index action type will index a document into {es}. + +[float] +[[index-connector-configuration]] +==== Connector configuration + +Index connectors have the following configuration properties: + +Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. +Index:: The {es} index to be written to. +Refresh:: Setting for the {ref}/docs-refresh.html[refresh] policy for the write request. +Execution time field:: This field will be automatically set to the time the alert condition was detected. + +[float] +[[index-action-configuration]] +==== Action configuration + +Index actions have the following properties: + +Document:: The document to index in json format. \ No newline at end of file diff --git a/docs/user/alerting/action-types/pagerduty.asciidoc b/docs/user/alerting/action-types/pagerduty.asciidoc new file mode 100644 index 0000000000000..50a1f31e4a9ae --- /dev/null +++ b/docs/user/alerting/action-types/pagerduty.asciidoc @@ -0,0 +1,33 @@ +[role="xpack"] +[[pagerduty-action-type]] +== PagerDuty action type + +The PagerDuty action type uses the https://v2.developer.pagerduty.com/docs/events-api-v2[v2 Events API] to trigger, acknowledge, and resolve PagerDuty alerts. + +[float] +[[pagerduty-connector-configuration]] +==== Connector configuration + +PagerDuty connectors have the following configuration properties: + +Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. +API URL:: An optional PagerDuty event URL. Defaults to `https://events.pagerduty.com/v2/enqueue`. If you are using the <> setting, make sure the hostname is whitelisted. +Routing Key:: A 32 character PagerDuty Integration Key for an integration on a service or on a global ruleset. + +[float] +[[pagerduty-action-configuration]] +==== Action configuration + +PagerDuty actions have the following properties: + +Severity:: The perceived severity of on the affected system. This can be one of `Critical`, `Error`, `Warning` or `Info`(default). +Event action:: One of `Trigger` (default), `Resolve`, or `Acknowledge`. See https://v2.developer.pagerduty.com/docs/events-api-v2#event-action[event action] for more details. +Dedup Key:: All actions sharing this key will be associated with the same PagerDuty alert. This value is used to correlate trigger and resolution. This value is *optional*, and if unset defaults to `action:`. The maximum length is *255* characters. See https://v2.developer.pagerduty.com/docs/events-api-v2#alert-de-duplication[alert deduplication] for details. +Timestamp:: An *optional* https://v2.developer.pagerduty.com/v2/docs/types#datetime[ISO-8601 format date-time], indicating the time the event was detected or generated. +Component:: An *optional* value indicating the component of the source machine that is responsible for the event, for example `mysql` or `eth0`. +Group:: An *optional* value indicating the logical grouping of components of a service, for example `app-stack`. +Source:: An *optional* value indicating the affected system, preferably a hostname or fully qualified domain name. Defaults to the {kib} saved object id of the action. +Summary:: An *optional* text summary of the event, defaults to `No summary provided`. The maximum length is 1024 characters. +Class:: An *optional* value indicating the class/type of the event, for example `ping failure` or `cpu load`. + +For more details on these properties, see https://v2.developer.pagerduty.com/v2/docs/send-an-event-events-api-v2[PagerDuty v2 event parameters]. \ No newline at end of file diff --git a/docs/user/alerting/action-types/server-log.asciidoc b/docs/user/alerting/action-types/server-log.asciidoc new file mode 100644 index 0000000000000..4efbdf3bea099 --- /dev/null +++ b/docs/user/alerting/action-types/server-log.asciidoc @@ -0,0 +1,21 @@ +[role="xpack"] +[[server-log-action-type]] +== Server log action type + +This action type writes and entry to the {kib} server log. + +[float] +[[server-log-connector-configuration]] +==== Connector configuration + +Server log connectors have the following configuration properties: + +Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. + +[float] +[[server-log-action-configuration]] +==== Action configuration + +Server log actions have the following properties: + +Message:: The message to log. \ No newline at end of file diff --git a/docs/user/alerting/action-types/slack.asciidoc b/docs/user/alerting/action-types/slack.asciidoc new file mode 100644 index 0000000000000..a4bacbf162e46 --- /dev/null +++ b/docs/user/alerting/action-types/slack.asciidoc @@ -0,0 +1,22 @@ +[role="xpack"] +[[slack-action-type]] +== Slack action type + +The Slack action type uses https://api.slack.com/incoming-webhooks[Slack Incoming Webhooks]. + +[float] +[[slack-connector-configuration]] +==== Connector configuration + +Slack connectors have the following configuration properties: + +Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. +Webhook URL:: The URL of the incoming webhook. See https://api.slack.com/messaging/webhooks#getting_started[Slack Incoming Webhooks] for instructions on generating this URL. If you are using the <> setting, make sure the hostname is whitelisted. + +[float] +[[slack-action-configuration]] +==== Action configuration + +Slack actions have the following properties: + +Message:: The message text, converted to the `text` field in the Webhook JSON payload. Currently only the text field is supported. Markdown, images, and other advanced formatting are not yet supported. \ No newline at end of file diff --git a/docs/user/alerting/action-types/webhook.asciidoc b/docs/user/alerting/action-types/webhook.asciidoc new file mode 100644 index 0000000000000..8c211aa83af89 --- /dev/null +++ b/docs/user/alerting/action-types/webhook.asciidoc @@ -0,0 +1,26 @@ +[role="xpack"] +[[webhook-action-type]] +== Webhook action type + +The Webhook action type uses https://github.com/axios/axios[axios] to send a POST or PUT request to a web service. + +[float] +[[webhook-connector-configuration]] +==== Connector configuration + +Webhook connectors have the following configuration properties: + +Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. +URL:: The request URL. If you are using the <> setting, make sure the hostname is whitelisted. +Method:: HTTP request method, either `post`(default) or `put`. +Headers:: A set of key-value pairs sent as headers with the request +User:: An optional username. If set, HTTP basic authentication is used. Currently only basic authentication is supported. +Password:: An optional password. If set, HTTP basic authentication is used. Currently only basic authentication is supported. + +[float] +[[webhook-action-configuration]] +==== Action configuration + +Webhook actions have the following properties: + +Body:: A json payload sent to the request URL. \ No newline at end of file diff --git a/docs/user/alerting/index.asciidoc b/docs/user/alerting/index.asciidoc index c7cf1186a44be..df11f5f03a7de 100644 --- a/docs/user/alerting/index.asciidoc +++ b/docs/user/alerting/index.asciidoc @@ -154,10 +154,13 @@ Pre-packaged *alert types* simplify setup, hide the details complex domain-speci [[alerting-setup-prerequisites]] == Setup and prerequisites +If you are using an *on-premises* Elastic Stack deployment: + +* In the kibana.yml configuration file, add the <> setting. + If you are using an *on-premises* Elastic Stack deployment with <>: -* TLS must be configured for communication <>. {kib} alerting uses <> to secure background alert checks and actions, and API keys require {ref}/configuring-tls.html#tls-http[TLS on the HTTP interface]. -* In the kibana.yml configuration file, add the <> +* Transport Layer Security (TLS) must be configured for communication <>. {kib} alerting uses <> to secure background alert checks and actions, and API keys require {ref}/configuring-tls.html#tls-http[TLS on the HTTP interface]. [float] [[alerting-security]] diff --git a/docs/user/dev-tools.asciidoc b/docs/user/dev-tools.asciidoc index 77a781fd069e4..0ee7fbc741e00 100644 --- a/docs/user/dev-tools.asciidoc +++ b/docs/user/dev-tools.asciidoc @@ -4,13 +4,29 @@ [partintro] -- -The *Dev Tools* page contains development tools that you can use to interact -with your data in Kibana. +*Dev Tools* contains tools that you can use to interact +with your data. -* <> -* <> -* <> +[cols="2"] +|=== +a| <> + +| Interact with the REST API of Elasticsearch, including sending requests +and viewing API documentation. + +a| <> + +| Inspect and analyze your search queries. + +a| <> + +| Build and debug grok patterns before you use them in your data processing pipelines. + +a| <> + +| beta:[] Test and debug Painless scripts in real-time. +|=== -- @@ -19,3 +35,5 @@ include::{kib-repo-dir}/dev-tools/console/console.asciidoc[] include::{kib-repo-dir}/dev-tools/searchprofiler/index.asciidoc[] include::{kib-repo-dir}/dev-tools/grokdebugger/index.asciidoc[] + +include::{kib-repo-dir}/dev-tools/painlesslab/index.asciidoc[] diff --git a/docs/user/management.asciidoc b/docs/user/management.asciidoc index fa34802abe2a9..a4ba320e826b1 100644 --- a/docs/user/management.asciidoc +++ b/docs/user/management.asciidoc @@ -13,7 +13,7 @@ indices, clusters, licenses, UI settings, index patterns, spaces, and more. [cols="50, 50"] |=== -a| <> +a| <> Replicate indices on a remote cluster and copy them to a follower index on a local cluster. This is important for @@ -85,7 +85,8 @@ set the timespan for notification messages, and much more. | <> -Centrally manage your alerts from across {kib}. Create and manage re-usable connectors for triggering actions. +Centrally manage your alerts across {kib}. Create and manage reusable +connectors for triggering actions. | <> @@ -125,6 +126,8 @@ include::{kib-repo-dir}/management/alerting/connector-management.asciidoc[] include::{kib-repo-dir}/management/managing-beats.asciidoc[] +include::{kib-repo-dir}/management/managing-ccr.asciidoc[] + include::{kib-repo-dir}/management/index-lifecycle-policies/intro-to-lifecycle-policies.asciidoc[] include::{kib-repo-dir}/management/index-lifecycle-policies/create-policy.asciidoc[] diff --git a/docs/user/reporting/chromium-sandbox.asciidoc b/docs/user/reporting/chromium-sandbox.asciidoc index 5d4fbfb153a0b..bfef5b8b86c6b 100644 --- a/docs/user/reporting/chromium-sandbox.asciidoc +++ b/docs/user/reporting/chromium-sandbox.asciidoc @@ -11,12 +11,12 @@ sandboxing techniques differ for each operating system. The Linux sandbox depends on user namespaces, which were introduced with the 3.8 Linux kernel. However, many distributions don't have user namespaces enabled by default, or they require the CAP_SYS_ADMIN capability. {reporting} will automatically disable the sandbox when it is running on Debian and CentOS as additional steps are required to enable -unprivileged usernamespaces. In these situations, you'll see the following message in your {kib} logs: -`Enabling the Chromium sandbox provides an additional layer of protection`. +unprivileged usernamespaces. In these situations, you'll see the following message in your {kib} startup logs: +`Chromium sandbox provides an additional layer of protection, but is not supported for your OS. +Automatically setting 'xpack.reporting.capture.browser.chromium.disableSandbox: true'.` -If your kernel is 3.8 or newer, it's -recommended to enable usernamespaces and set `xpack.reporting.capture.browser.chromium.disableSandbox: false` in your -`kibana.yml` to enable the sandbox. +Reporting will automatically enable the Chromium sandbox at startup when a supported OS is detected. However, if your kernel is 3.8 or newer, it's +recommended to set `xpack.reporting.capture.browser.chromium.disableSandbox: false` in your `kibana.yml` to explicitly enable usernamespaces. ==== Docker When running {kib} in a Docker container, all container processes are run within a usernamespace with seccomp-bpf and diff --git a/examples/embeddable_examples/common/index.ts b/examples/embeddable_examples/common/index.ts new file mode 100644 index 0000000000000..726420fb9bdc3 --- /dev/null +++ b/examples/embeddable_examples/common/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 { TodoSavedObjectAttributes } from './todo_saved_object_attributes'; diff --git a/examples/embeddable_examples/common/todo_saved_object_attributes.ts b/examples/embeddable_examples/common/todo_saved_object_attributes.ts new file mode 100644 index 0000000000000..21b6df20fea90 --- /dev/null +++ b/examples/embeddable_examples/common/todo_saved_object_attributes.ts @@ -0,0 +1,26 @@ +/* + * 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 { SavedObjectAttributes } from '../../../src/core/types'; + +export interface TodoSavedObjectAttributes extends SavedObjectAttributes { + task: string; + icon?: string; + title?: string; +} diff --git a/examples/embeddable_examples/kibana.json b/examples/embeddable_examples/kibana.json index c70bc7009ff51..f446e7f31ac8e 100644 --- a/examples/embeddable_examples/kibana.json +++ b/examples/embeddable_examples/kibana.json @@ -3,7 +3,7 @@ "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["embeddable_examples"], - "server": false, + "server": true, "ui": true, "requiredPlugins": ["embeddable"], "optionalPlugins": [] diff --git a/examples/embeddable_examples/public/create_sample_data.ts b/examples/embeddable_examples/public/create_sample_data.ts new file mode 100644 index 0000000000000..bd5ade18aa91e --- /dev/null +++ b/examples/embeddable_examples/public/create_sample_data.ts @@ -0,0 +1,36 @@ +/* + * 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 { SavedObjectsClientContract } from 'kibana/public'; +import { TodoSavedObjectAttributes } from '../common'; + +export async function createSampleData(client: SavedObjectsClientContract) { + await client.create( + 'todo', + { + task: 'Take the garbage out', + title: 'Garbage', + icon: 'trash', + }, + { + id: 'sample-todo-saved-object', + overwrite: true, + } + ); +} diff --git a/examples/embeddable_examples/public/index.ts b/examples/embeddable_examples/public/index.ts index 5fcd454b17a5c..4aac63fb52e2b 100644 --- a/examples/embeddable_examples/public/index.ts +++ b/examples/embeddable_examples/public/index.ts @@ -17,7 +17,6 @@ * under the License. */ -import { PluginInitializer } from 'kibana/public'; export { HELLO_WORLD_EMBEDDABLE, HelloWorldEmbeddable, @@ -26,18 +25,8 @@ export { export { ListContainer, LIST_CONTAINER } from './list_container'; export { TODO_EMBEDDABLE } from './todo'; -import { - EmbeddableExamplesPlugin, - EmbeddableExamplesSetupDependencies, - EmbeddableExamplesStartDependencies, -} from './plugin'; +import { EmbeddableExamplesPlugin } from './plugin'; export { SearchableListContainer, SEARCHABLE_LIST_CONTAINER } from './searchable_list_container'; export { MULTI_TASK_TODO_EMBEDDABLE } from './multi_task_todo'; - -export const plugin: PluginInitializer< - void, - void, - EmbeddableExamplesSetupDependencies, - EmbeddableExamplesStartDependencies -> = () => new EmbeddableExamplesPlugin(); +export const plugin = () => new EmbeddableExamplesPlugin(); diff --git a/examples/embeddable_examples/public/plugin.ts b/examples/embeddable_examples/public/plugin.ts index 31a3037332dda..75d34d2d6878f 100644 --- a/examples/embeddable_examples/public/plugin.ts +++ b/examples/embeddable_examples/public/plugin.ts @@ -21,12 +21,20 @@ import { EmbeddableSetup, EmbeddableStart } from '../../../src/plugins/embeddabl import { Plugin, CoreSetup, CoreStart } from '../../../src/core/public'; import { HelloWorldEmbeddableFactory, HELLO_WORLD_EMBEDDABLE } from './hello_world'; import { TODO_EMBEDDABLE, TodoEmbeddableFactory, TodoInput, TodoOutput } from './todo'; -import { MULTI_TASK_TODO_EMBEDDABLE, MultiTaskTodoEmbeddableFactory } from './multi_task_todo'; +import { + MULTI_TASK_TODO_EMBEDDABLE, + MultiTaskTodoEmbeddableFactory, + MultiTaskTodoInput, + MultiTaskTodoOutput, +} from './multi_task_todo'; import { SEARCHABLE_LIST_CONTAINER, SearchableListContainerFactory, } from './searchable_list_container'; import { LIST_CONTAINER, ListContainerFactory } from './list_container'; +import { createSampleData } from './create_sample_data'; +import { TodoRefInput, TodoRefOutput, TODO_REF_EMBEDDABLE } from './todo/todo_ref_embeddable'; +import { TodoRefEmbeddableFactory } from './todo/todo_ref_embeddable_factory'; export interface EmbeddableExamplesSetupDependencies { embeddable: EmbeddableSetup; @@ -36,9 +44,18 @@ export interface EmbeddableExamplesStartDependencies { embeddable: EmbeddableStart; } +export interface EmbeddableExamplesStart { + createSampleData: () => Promise; +} + export class EmbeddableExamplesPlugin implements - Plugin { + Plugin< + void, + EmbeddableExamplesStart, + EmbeddableExamplesSetupDependencies, + EmbeddableExamplesStartDependencies + > { public setup( core: CoreSetup, deps: EmbeddableExamplesSetupDependencies @@ -48,7 +65,7 @@ export class EmbeddableExamplesPlugin new HelloWorldEmbeddableFactory() ); - deps.embeddable.registerEmbeddableFactory( + deps.embeddable.registerEmbeddableFactory( MULTI_TASK_TODO_EMBEDDABLE, new MultiTaskTodoEmbeddableFactory() ); @@ -73,9 +90,21 @@ export class EmbeddableExamplesPlugin openModal: (await core.getStartServices())[0].overlays.openModal, })) ); + + deps.embeddable.registerEmbeddableFactory( + TODO_REF_EMBEDDABLE, + new TodoRefEmbeddableFactory(async () => ({ + savedObjectsClient: (await core.getStartServices())[0].savedObjects.client, + getEmbeddableFactory: (await core.getStartServices())[1].embeddable.getEmbeddableFactory, + })) + ); } - public start(core: CoreStart, deps: EmbeddableExamplesStartDependencies) {} + public start(core: CoreStart, deps: EmbeddableExamplesStartDependencies) { + return { + createSampleData: () => createSampleData(core.savedObjects.client), + }; + } public stop() {} } diff --git a/examples/embeddable_examples/public/todo/README.md b/examples/embeddable_examples/public/todo/README.md new file mode 100644 index 0000000000000..e782511f093b3 --- /dev/null +++ b/examples/embeddable_examples/public/todo/README.md @@ -0,0 +1,43 @@ +There are two examples in here: + - TodoEmbeddable + - TodoRefEmbeddable + + # TodoEmbeddable + + The first example you should review is the HelloWorldEmbeddable. That is as basic an embeddable as you can get. + This embeddable is the next step up - an embeddable that renders dynamic input data. The data is simple: + - a required task string + - an optional title + - an optional icon string + - an optional search string + +It also has output data, which is `hasMatch` - whether or not the search string has matched any input data. + +`hasMatch` is a better fit for output data than input data, because it's state that is _derived_ from input data. + +For example, if it was input data, you could create a TodoEmbeddable with input like this: + +```ts + todoEmbeddableFactory.create({ task: 'take out the garabage', search: 'garbage', hasMatch: false }); +``` + +That's wrong because there is actually a match from the search string inside the task. + +The TodoEmbeddable component itself doesn't do anything with the `hasMatch` variable other than set it, but +if you check out `SearchableListContainer`, you can see an example where this output data is being used. + +## TodoRefEmbeddable + +This is an example of an embeddable based off of a saved object. The input is just the `savedObjectId` and +the `search` string. It has even more output parameters, and this time, it does read it's own output parameters in +order to calculate `hasMatch`. + +Output: +```ts +{ + hasMatch: boolean, + savedAttributes?: TodoSavedAttributes +} +``` + +`savedAttributes` is optional because it's possible a TodoSavedObject could not be found with the given savedObjectId. diff --git a/examples/embeddable_examples/public/todo/todo_ref_component.tsx b/examples/embeddable_examples/public/todo/todo_ref_component.tsx new file mode 100644 index 0000000000000..8e0a17be1ec72 --- /dev/null +++ b/examples/embeddable_examples/public/todo/todo_ref_component.tsx @@ -0,0 +1,86 @@ +/* + * 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 { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; + +import { EuiText } from '@elastic/eui'; +import { EuiAvatar } from '@elastic/eui'; +import { EuiIcon } from '@elastic/eui'; +import { EuiFlexGrid } from '@elastic/eui'; +import { withEmbeddableSubscription } from '../../../../src/plugins/embeddable/public'; +import { TodoRefInput, TodoRefOutput, TodoRefEmbeddable } from './todo_ref_embeddable'; + +interface Props { + embeddable: TodoRefEmbeddable; + input: TodoRefInput; + output: TodoRefOutput; +} + +function wrapSearchTerms(task?: string, search?: string) { + if (!search) return task; + if (!task) return task; + const parts = task.split(new RegExp(`(${search})`, 'g')); + return parts.map((part, i) => + part === search ? ( + + {part} + + ) : ( + part + ) + ); +} + +export function TodoRefEmbeddableComponentInner({ + input: { search }, + output: { savedAttributes }, +}: Props) { + const icon = savedAttributes?.icon; + const title = savedAttributes?.title; + const task = savedAttributes?.task; + return ( + + + {icon ? ( + + ) : ( + + )} + + + + + +

{wrapSearchTerms(title || '', search)}

+ + + + {wrapSearchTerms(task, search)} + + + + + ); +} + +export const TodoRefEmbeddableComponent = withEmbeddableSubscription< + TodoRefInput, + TodoRefOutput, + TodoRefEmbeddable +>(TodoRefEmbeddableComponentInner); diff --git a/examples/embeddable_examples/public/todo/todo_ref_embeddable.tsx b/examples/embeddable_examples/public/todo/todo_ref_embeddable.tsx new file mode 100644 index 0000000000000..da2dfb2c1a290 --- /dev/null +++ b/examples/embeddable_examples/public/todo/todo_ref_embeddable.tsx @@ -0,0 +1,153 @@ +/* + * 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 ReactDOM from 'react-dom'; +import { Subscription } from 'rxjs'; +import { TodoSavedObjectAttributes } from 'examples/embeddable_examples/common'; +import { SavedObjectsClientContract } from 'kibana/public'; +import { + Embeddable, + IContainer, + EmbeddableOutput, + SavedObjectEmbeddableInput, +} from '../../../../src/plugins/embeddable/public'; +import { TodoRefEmbeddableComponent } from './todo_ref_component'; + +// Notice this is not the same value as the 'todo' saved object type. Many of our +// cases in prod today use the same value, but this is unnecessary. +export const TODO_REF_EMBEDDABLE = 'TODO_REF_EMBEDDABLE'; + +export interface TodoRefInput extends SavedObjectEmbeddableInput { + /** + * Optional search string which will be used to highlight search terms as + * well as calculate `output.hasMatch`. + */ + search?: string; +} + +export interface TodoRefOutput extends EmbeddableOutput { + /** + * Should be true if input.search is defined and the task or title contain + * search as a substring. + */ + hasMatch: boolean; + /** + * Will contain the saved object attributes of the Todo Saved Object that matches + * `input.savedObjectId`. If the id is invalid, this may be undefined. + */ + savedAttributes?: TodoSavedObjectAttributes; +} + +/** + * Returns whether any attributes contain the search string. If search is empty, true is returned. If + * there are no savedAttributes, false is returned. + * @param search - the search string + * @param savedAttributes - the saved object attributes for the saved object with id `input.savedObjectId` + */ +function getHasMatch(search?: string, savedAttributes?: TodoSavedObjectAttributes): boolean { + if (!search) return true; + if (!savedAttributes) return false; + return Boolean( + (savedAttributes.task && savedAttributes.task.match(search)) || + (savedAttributes.title && savedAttributes.title.match(search)) + ); +} + +/** + * This is an example of an embeddable that is backed by a saved object. It's essentially the + * same as `TodoEmbeddable` but that is "by value", while this is "by reference". + */ +export class TodoRefEmbeddable extends Embeddable { + public readonly type = TODO_REF_EMBEDDABLE; + private subscription: Subscription; + private node?: HTMLElement; + private savedObjectsClient: SavedObjectsClientContract; + private savedObjectId?: string; + + constructor( + initialInput: TodoRefInput, + { + parent, + savedObjectsClient, + }: { + parent?: IContainer; + savedObjectsClient: SavedObjectsClientContract; + } + ) { + super(initialInput, { hasMatch: false }, parent); + this.savedObjectsClient = savedObjectsClient; + + this.subscription = this.getInput$().subscribe(async () => { + // There is a little more work today for this embeddable because it has + // more output it needs to update in response to input state changes. + let savedAttributes: TodoSavedObjectAttributes | undefined; + + // Since this is an expensive task, we save a local copy of the previous + // savedObjectId locally and only retrieve the new saved object if the id + // actually changed. + if (this.savedObjectId !== this.input.savedObjectId) { + this.savedObjectId = this.input.savedObjectId; + const todoSavedObject = await this.savedObjectsClient.get( + 'todo', + this.input.savedObjectId + ); + savedAttributes = todoSavedObject?.attributes; + } + + // The search string might have changed as well so we need to make sure we recalculate + // hasMatch. + this.updateOutput({ + hasMatch: getHasMatch(this.input.search, savedAttributes), + savedAttributes, + }); + }); + } + + public render(node: HTMLElement) { + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + this.node = node; + ReactDOM.render(, node); + } + + /** + * Lets re-sync our saved object to make sure it's up to date! + */ + public async reload() { + this.savedObjectId = this.input.savedObjectId; + const todoSavedObject = await this.savedObjectsClient.get( + 'todo', + this.input.savedObjectId + ); + const savedAttributes = todoSavedObject?.attributes; + this.updateOutput({ + hasMatch: getHasMatch(this.input.search, savedAttributes), + savedAttributes, + }); + } + + public destroy() { + super.destroy(); + this.subscription.unsubscribe(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } +} diff --git a/examples/embeddable_examples/public/todo/todo_ref_embeddable_factory.tsx b/examples/embeddable_examples/public/todo/todo_ref_embeddable_factory.tsx new file mode 100644 index 0000000000000..e585ddd89674f --- /dev/null +++ b/examples/embeddable_examples/public/todo/todo_ref_embeddable_factory.tsx @@ -0,0 +1,83 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { SavedObjectsClientContract } from 'kibana/public'; +import { TodoSavedObjectAttributes } from 'examples/embeddable_examples/common'; +import { + IContainer, + EmbeddableStart, + ErrorEmbeddable, + EmbeddableFactoryDefinition, +} from '../../../../src/plugins/embeddable/public'; +import { + TodoRefEmbeddable, + TODO_REF_EMBEDDABLE, + TodoRefInput, + TodoRefOutput, +} from './todo_ref_embeddable'; + +interface StartServices { + getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']; + savedObjectsClient: SavedObjectsClientContract; +} + +export class TodoRefEmbeddableFactory + implements + EmbeddableFactoryDefinition< + TodoRefInput, + TodoRefOutput, + TodoRefEmbeddable, + TodoSavedObjectAttributes + > { + public readonly type = TODO_REF_EMBEDDABLE; + public readonly savedObjectMetaData = { + name: 'Todo', + includeFields: ['task', 'icon', 'title'], + type: 'todo', + getIconForSavedObject: () => 'pencil', + }; + + constructor(private getStartServices: () => Promise) {} + + public async isEditable() { + return true; + } + + public createFromSavedObject = ( + savedObjectId: string, + input: Partial & { id: string }, + parent?: IContainer + ): Promise => { + return this.create({ ...input, savedObjectId }, parent); + }; + + public async create(input: TodoRefInput, parent?: IContainer) { + const { savedObjectsClient } = await this.getStartServices(); + return new TodoRefEmbeddable(input, { + parent, + savedObjectsClient, + }); + } + + public getDisplayName() { + return i18n.translate('embeddableExamples.todo.displayName', { + defaultMessage: 'Todo (by reference)', + }); + } +} diff --git a/examples/embeddable_examples/server/index.ts b/examples/embeddable_examples/server/index.ts new file mode 100644 index 0000000000000..9ddc3bc2cf715 --- /dev/null +++ b/examples/embeddable_examples/server/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { PluginInitializer } from 'kibana/server'; + +import { EmbeddableExamplesPlugin } from './plugin'; + +export const plugin: PluginInitializer = () => new EmbeddableExamplesPlugin(); diff --git a/examples/embeddable_examples/server/plugin.ts b/examples/embeddable_examples/server/plugin.ts new file mode 100644 index 0000000000000..d956b834d0d3c --- /dev/null +++ b/examples/embeddable_examples/server/plugin.ts @@ -0,0 +1,31 @@ +/* + * 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 { Plugin, CoreSetup, CoreStart } from 'kibana/server'; +import { todoSavedObject } from './todo_saved_object'; + +export class EmbeddableExamplesPlugin implements Plugin { + public setup(core: CoreSetup) { + core.savedObjects.registerType(todoSavedObject); + } + + public start(core: CoreStart) {} + + public stop() {} +} diff --git a/examples/embeddable_examples/server/todo_saved_object.ts b/examples/embeddable_examples/server/todo_saved_object.ts new file mode 100644 index 0000000000000..58da2014de498 --- /dev/null +++ b/examples/embeddable_examples/server/todo_saved_object.ts @@ -0,0 +1,40 @@ +/* + * 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 { SavedObjectsType } from 'kibana/server'; + +export const todoSavedObject: SavedObjectsType = { + name: 'todo', + hidden: false, + namespaceType: 'agnostic', + mappings: { + properties: { + title: { + type: 'keyword', + }, + task: { + type: 'text', + }, + icon: { + type: 'keyword', + }, + }, + }, + migrations: {}, +}; diff --git a/examples/embeddable_examples/tsconfig.json b/examples/embeddable_examples/tsconfig.json index 091130487791b..7fa03739119b4 100644 --- a/examples/embeddable_examples/tsconfig.json +++ b/examples/embeddable_examples/tsconfig.json @@ -6,6 +6,7 @@ }, "include": [ "index.ts", + "common/**/*.ts", "public/**/*.ts", "public/**/*.tsx", "server/**/*.ts", diff --git a/examples/embeddable_explorer/public/plugin.tsx b/examples/embeddable_explorer/public/plugin.tsx index 7c75b108d9912..bba1b1748e207 100644 --- a/examples/embeddable_explorer/public/plugin.tsx +++ b/examples/embeddable_explorer/public/plugin.tsx @@ -18,6 +18,7 @@ */ import { Plugin, CoreSetup, AppMountParameters } from 'kibana/public'; +import { EmbeddableExamplesStart } from 'examples/embeddable_examples/public/plugin'; import { UiActionsService } from '../../../src/plugins/ui_actions/public'; import { EmbeddableStart } from '../../../src/plugins/embeddable/public'; import { Start as InspectorStart } from '../../../src/plugins/inspector/public'; @@ -26,6 +27,7 @@ interface StartDeps { uiActions: UiActionsService; embeddable: EmbeddableStart; inspector: InspectorStart; + embeddableExamples: EmbeddableExamplesStart; } export class EmbeddableExplorerPlugin implements Plugin { @@ -36,6 +38,7 @@ export class EmbeddableExplorerPlugin implements Plugin { if (!filePath) { @@ -54852,81 +54845,6 @@ module.exports = (input, options) => { "use strict"; -const processFn = (fn, options) => function (...args) { - const P = options.promiseModule; - - return new P((resolve, reject) => { - if (options.multiArgs) { - args.push((...result) => { - if (options.errorFirst) { - if (result[0]) { - reject(result); - } else { - result.shift(); - resolve(result); - } - } else { - resolve(result); - } - }); - } else if (options.errorFirst) { - args.push((error, result) => { - if (error) { - reject(error); - } else { - resolve(result); - } - }); - } else { - args.push(resolve); - } - - fn.apply(this, args); - }); -}; - -module.exports = (input, options) => { - options = Object.assign({ - exclude: [/.+(Sync|Stream)$/], - errorFirst: true, - promiseModule: Promise - }, options); - - const objType = typeof input; - if (!(input !== null && (objType === 'object' || objType === 'function'))) { - throw new TypeError(`Expected \`input\` to be a \`Function\` or \`Object\`, got \`${input === null ? 'null' : objType}\``); - } - - const filter = key => { - const match = pattern => typeof pattern === 'string' ? key === pattern : pattern.test(key); - return options.include ? options.include.some(match) : !options.exclude.some(match); - }; - - let ret; - if (objType === 'function') { - ret = function (...args) { - return options.excludeMain ? input(...args) : processFn(input, options).apply(this, args); - }; - } else { - ret = Object.create(Object.getPrototypeOf(input)); - } - - for (const key in input) { // eslint-disable-line guard-for-in - const property = input[key]; - ret[key] = typeof property === 'function' && filter(key) ? processFn(property, options) : property; - } - - return ret; -}; - - -/***/ }), -/* 561 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - // detect either spaces or tabs but not both to properly handle tabs // for indentation and spaces for alignment const INDENT_RE = /^(?:( )+|\t+)/; @@ -55050,7 +54968,7 @@ module.exports = str => { /***/ }), -/* 562 */ +/* 561 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55059,7 +54977,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "runScriptInPackage", function() { return runScriptInPackage; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "runScriptInPackageStreaming", function() { return runScriptInPackageStreaming; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "yarnWorkspacesInfo", function() { return yarnWorkspacesInfo; }); -/* harmony import */ var _child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(563); +/* harmony import */ var _child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(562); /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -55129,7 +55047,7 @@ async function yarnWorkspacesInfo(directory) { } /***/ }), -/* 563 */ +/* 562 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55140,9 +55058,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(chalk__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var execa__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(351); /* harmony import */ var execa__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(execa__WEBPACK_IMPORTED_MODULE_1__); -/* harmony import */ var log_symbols__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(564); +/* harmony import */ var log_symbols__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(563); /* harmony import */ var log_symbols__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(log_symbols__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var strong_log_transformer__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(569); +/* harmony import */ var strong_log_transformer__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(568); /* harmony import */ var strong_log_transformer__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(strong_log_transformer__WEBPACK_IMPORTED_MODULE_3__); function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } @@ -55208,12 +55126,12 @@ function spawnStreaming(command, args, opts, { } /***/ }), -/* 564 */ +/* 563 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const chalk = __webpack_require__(565); +const chalk = __webpack_require__(564); const isSupported = process.platform !== 'win32' || process.env.CI || process.env.TERM === 'xterm-256color'; @@ -55235,16 +55153,16 @@ module.exports = isSupported ? main : fallbacks; /***/ }), -/* 565 */ +/* 564 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const escapeStringRegexp = __webpack_require__(3); -const ansiStyles = __webpack_require__(566); -const stdoutColor = __webpack_require__(567).stdout; +const ansiStyles = __webpack_require__(565); +const stdoutColor = __webpack_require__(566).stdout; -const template = __webpack_require__(568); +const template = __webpack_require__(567); const isSimpleWindowsTerm = process.platform === 'win32' && !(process.env.TERM || '').toLowerCase().startsWith('xterm'); @@ -55470,7 +55388,7 @@ module.exports.default = module.exports; // For TypeScript /***/ }), -/* 566 */ +/* 565 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -55643,7 +55561,7 @@ Object.defineProperty(module, 'exports', { /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(5)(module))) /***/ }), -/* 567 */ +/* 566 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -55785,7 +55703,7 @@ module.exports = { /***/ }), -/* 568 */ +/* 567 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -55920,7 +55838,7 @@ module.exports = (chalk, tmp) => { /***/ }), -/* 569 */ +/* 568 */ /***/ (function(module, exports, __webpack_require__) { // Copyright IBM Corp. 2014,2018. All Rights Reserved. @@ -55928,12 +55846,12 @@ module.exports = (chalk, tmp) => { // This file is licensed under the Apache License 2.0. // License text available at https://opensource.org/licenses/Apache-2.0 -module.exports = __webpack_require__(570); -module.exports.cli = __webpack_require__(574); +module.exports = __webpack_require__(569); +module.exports.cli = __webpack_require__(573); /***/ }), -/* 570 */ +/* 569 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -55948,9 +55866,9 @@ var stream = __webpack_require__(27); var util = __webpack_require__(29); var fs = __webpack_require__(23); -var through = __webpack_require__(571); -var duplexer = __webpack_require__(572); -var StringDecoder = __webpack_require__(573).StringDecoder; +var through = __webpack_require__(570); +var duplexer = __webpack_require__(571); +var StringDecoder = __webpack_require__(572).StringDecoder; module.exports = Logger; @@ -56139,7 +56057,7 @@ function lineMerger(host) { /***/ }), -/* 571 */ +/* 570 */ /***/ (function(module, exports, __webpack_require__) { var Stream = __webpack_require__(27) @@ -56253,7 +56171,7 @@ function through (write, end, opts) { /***/ }), -/* 572 */ +/* 571 */ /***/ (function(module, exports, __webpack_require__) { var Stream = __webpack_require__(27) @@ -56346,13 +56264,13 @@ function duplex(writer, reader) { /***/ }), -/* 573 */ +/* 572 */ /***/ (function(module, exports) { module.exports = require("string_decoder"); /***/ }), -/* 574 */ +/* 573 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -56363,11 +56281,11 @@ module.exports = require("string_decoder"); -var minimist = __webpack_require__(575); +var minimist = __webpack_require__(574); var path = __webpack_require__(16); -var Logger = __webpack_require__(570); -var pkg = __webpack_require__(576); +var Logger = __webpack_require__(569); +var pkg = __webpack_require__(575); module.exports = cli; @@ -56421,7 +56339,7 @@ function usage($0, p) { /***/ }), -/* 575 */ +/* 574 */ /***/ (function(module, exports) { module.exports = function (args, opts) { @@ -56663,13 +56581,13 @@ function isNumber (x) { /***/ }), -/* 576 */ +/* 575 */ /***/ (function(module) { module.exports = JSON.parse("{\"name\":\"strong-log-transformer\",\"version\":\"2.1.0\",\"description\":\"Stream transformer that prefixes lines with timestamps and other things.\",\"author\":\"Ryan Graham \",\"license\":\"Apache-2.0\",\"repository\":{\"type\":\"git\",\"url\":\"git://github.com/strongloop/strong-log-transformer\"},\"keywords\":[\"logging\",\"streams\"],\"bugs\":{\"url\":\"https://github.com/strongloop/strong-log-transformer/issues\"},\"homepage\":\"https://github.com/strongloop/strong-log-transformer\",\"directories\":{\"test\":\"test\"},\"bin\":{\"sl-log-transformer\":\"bin/sl-log-transformer.js\"},\"main\":\"index.js\",\"scripts\":{\"test\":\"tap --100 test/test-*\"},\"dependencies\":{\"duplexer\":\"^0.1.1\",\"minimist\":\"^1.2.0\",\"through\":\"^2.3.4\"},\"devDependencies\":{\"tap\":\"^12.0.1\"},\"engines\":{\"node\":\">=4\"}}"); /***/ }), -/* 577 */ +/* 576 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56682,7 +56600,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var util__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(29); /* harmony import */ var util__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(util__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(578); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(577); /* harmony import */ var _fs__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(20); /* harmony import */ var _package_json__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(516); /* harmony import */ var _projects__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(500); @@ -56777,7 +56695,7 @@ function packagesFromGlobPattern({ } /***/ }), -/* 578 */ +/* 577 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56847,7 +56765,7 @@ function getProjectPaths({ } /***/ }), -/* 579 */ +/* 578 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56855,13 +56773,13 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "getAllChecksums", function() { return getAllChecksums; }); /* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(23); /* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var crypto__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(580); +/* harmony import */ var crypto__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(579); /* harmony import */ var crypto__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(crypto__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var util__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(29); /* harmony import */ var util__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(util__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var execa__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(351); /* harmony import */ var execa__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(execa__WEBPACK_IMPORTED_MODULE_3__); -/* harmony import */ var _yarn_lock__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(581); +/* harmony import */ var _yarn_lock__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(580); /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -57087,19 +57005,19 @@ async function getAllChecksums(kbn, log) { } /***/ }), -/* 580 */ +/* 579 */ /***/ (function(module, exports) { module.exports = require("crypto"); /***/ }), -/* 581 */ +/* 580 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "readYarnLock", function() { return readYarnLock; }); -/* harmony import */ var _yarnpkg_lockfile__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(582); +/* harmony import */ var _yarnpkg_lockfile__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(581); /* harmony import */ var _yarnpkg_lockfile__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_yarnpkg_lockfile__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var _utils_fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(20); /* @@ -57143,7 +57061,7 @@ async function readYarnLock(kbn) { } /***/ }), -/* 582 */ +/* 581 */ /***/ (function(module, exports, __webpack_require__) { module.exports = @@ -58702,7 +58620,7 @@ module.exports = invariant; /* 9 */ /***/ (function(module, exports) { -module.exports = __webpack_require__(580); +module.exports = __webpack_require__(579); /***/ }), /* 10 */, @@ -61026,7 +60944,7 @@ function onceStrict (fn) { /* 63 */ /***/ (function(module, exports) { -module.exports = __webpack_require__(583); +module.exports = __webpack_require__(582); /***/ }), /* 64 */, @@ -67421,13 +67339,13 @@ module.exports = process && support(supportLevel); /******/ ]); /***/ }), -/* 583 */ +/* 582 */ /***/ (function(module, exports) { module.exports = require("buffer"); /***/ }), -/* 584 */ +/* 583 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -67524,7 +67442,7 @@ class BootstrapCacheFile { } /***/ }), -/* 585 */ +/* 584 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -67532,9 +67450,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "CleanCommand", function() { return CleanCommand; }); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(chalk__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(586); +/* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(585); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(del__WEBPACK_IMPORTED_MODULE_1__); -/* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(674); +/* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(673); /* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(ora__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(16); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_3__); @@ -67633,21 +67551,21 @@ const CleanCommand = { }; /***/ }), -/* 586 */ +/* 585 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const {promisify} = __webpack_require__(29); const path = __webpack_require__(16); -const globby = __webpack_require__(587); -const isGlob = __webpack_require__(604); -const slash = __webpack_require__(665); +const globby = __webpack_require__(586); +const isGlob = __webpack_require__(603); +const slash = __webpack_require__(664); const gracefulFs = __webpack_require__(22); -const isPathCwd = __webpack_require__(667); -const isPathInside = __webpack_require__(668); -const rimraf = __webpack_require__(669); -const pMap = __webpack_require__(670); +const isPathCwd = __webpack_require__(666); +const isPathInside = __webpack_require__(667); +const rimraf = __webpack_require__(668); +const pMap = __webpack_require__(669); const rimrafP = promisify(rimraf); @@ -67761,19 +67679,19 @@ module.exports.sync = (patterns, {force, dryRun, cwd = process.cwd(), ...options /***/ }), -/* 587 */ +/* 586 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(23); -const arrayUnion = __webpack_require__(588); -const merge2 = __webpack_require__(589); -const glob = __webpack_require__(590); -const fastGlob = __webpack_require__(595); -const dirGlob = __webpack_require__(661); -const gitignore = __webpack_require__(663); -const {FilterStream, UniqueStream} = __webpack_require__(666); +const arrayUnion = __webpack_require__(587); +const merge2 = __webpack_require__(588); +const glob = __webpack_require__(589); +const fastGlob = __webpack_require__(594); +const dirGlob = __webpack_require__(660); +const gitignore = __webpack_require__(662); +const {FilterStream, UniqueStream} = __webpack_require__(665); const DEFAULT_FILTER = () => false; @@ -67946,7 +67864,7 @@ module.exports.gitignore = gitignore; /***/ }), -/* 588 */ +/* 587 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -67958,7 +67876,7 @@ module.exports = (...arguments_) => { /***/ }), -/* 589 */ +/* 588 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68072,7 +67990,7 @@ function pauseStreams (streams, options) { /***/ }), -/* 590 */ +/* 589 */ /***/ (function(module, exports, __webpack_require__) { // Approach: @@ -68121,13 +68039,13 @@ var fs = __webpack_require__(23) var rp = __webpack_require__(502) var minimatch = __webpack_require__(504) var Minimatch = minimatch.Minimatch -var inherits = __webpack_require__(591) +var inherits = __webpack_require__(590) var EE = __webpack_require__(379).EventEmitter var path = __webpack_require__(16) var assert = __webpack_require__(30) var isAbsolute = __webpack_require__(510) -var globSync = __webpack_require__(593) -var common = __webpack_require__(594) +var globSync = __webpack_require__(592) +var common = __webpack_require__(593) var alphasort = common.alphasort var alphasorti = common.alphasorti var setopts = common.setopts @@ -68868,7 +68786,7 @@ Glob.prototype._stat2 = function (f, abs, er, stat, cb) { /***/ }), -/* 591 */ +/* 590 */ /***/ (function(module, exports, __webpack_require__) { try { @@ -68878,12 +68796,12 @@ try { module.exports = util.inherits; } catch (e) { /* istanbul ignore next */ - module.exports = __webpack_require__(592); + module.exports = __webpack_require__(591); } /***/ }), -/* 592 */ +/* 591 */ /***/ (function(module, exports) { if (typeof Object.create === 'function') { @@ -68916,7 +68834,7 @@ if (typeof Object.create === 'function') { /***/ }), -/* 593 */ +/* 592 */ /***/ (function(module, exports, __webpack_require__) { module.exports = globSync @@ -68926,12 +68844,12 @@ var fs = __webpack_require__(23) var rp = __webpack_require__(502) var minimatch = __webpack_require__(504) var Minimatch = minimatch.Minimatch -var Glob = __webpack_require__(590).Glob +var Glob = __webpack_require__(589).Glob var util = __webpack_require__(29) var path = __webpack_require__(16) var assert = __webpack_require__(30) var isAbsolute = __webpack_require__(510) -var common = __webpack_require__(594) +var common = __webpack_require__(593) var alphasort = common.alphasort var alphasorti = common.alphasorti var setopts = common.setopts @@ -69408,7 +69326,7 @@ GlobSync.prototype._makeAbs = function (f) { /***/ }), -/* 594 */ +/* 593 */ /***/ (function(module, exports, __webpack_require__) { exports.alphasort = alphasort @@ -69654,17 +69572,17 @@ function childrenIgnored (self, path) { /***/ }), -/* 595 */ +/* 594 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const taskManager = __webpack_require__(596); -const async_1 = __webpack_require__(624); -const stream_1 = __webpack_require__(657); -const sync_1 = __webpack_require__(658); -const settings_1 = __webpack_require__(660); -const utils = __webpack_require__(597); +const taskManager = __webpack_require__(595); +const async_1 = __webpack_require__(623); +const stream_1 = __webpack_require__(656); +const sync_1 = __webpack_require__(657); +const settings_1 = __webpack_require__(659); +const utils = __webpack_require__(596); function FastGlob(source, options) { try { assertPatternsInput(source); @@ -69722,13 +69640,13 @@ module.exports = FastGlob; /***/ }), -/* 596 */ +/* 595 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const utils = __webpack_require__(597); +const utils = __webpack_require__(596); function generate(patterns, settings) { const positivePatterns = getPositivePatterns(patterns); const negativePatterns = getNegativePatternsAsPositive(patterns, settings.ignore); @@ -69796,28 +69714,28 @@ exports.convertPatternGroupToTask = convertPatternGroupToTask; /***/ }), -/* 597 */ +/* 596 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const array = __webpack_require__(598); +const array = __webpack_require__(597); exports.array = array; -const errno = __webpack_require__(599); +const errno = __webpack_require__(598); exports.errno = errno; -const fs = __webpack_require__(600); +const fs = __webpack_require__(599); exports.fs = fs; -const path = __webpack_require__(601); +const path = __webpack_require__(600); exports.path = path; -const pattern = __webpack_require__(602); +const pattern = __webpack_require__(601); exports.pattern = pattern; -const stream = __webpack_require__(623); +const stream = __webpack_require__(622); exports.stream = stream; /***/ }), -/* 598 */ +/* 597 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69830,7 +69748,7 @@ exports.flatten = flatten; /***/ }), -/* 599 */ +/* 598 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69843,7 +69761,7 @@ exports.isEnoentCodeError = isEnoentCodeError; /***/ }), -/* 600 */ +/* 599 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69868,7 +69786,7 @@ exports.createDirentFromStats = createDirentFromStats; /***/ }), -/* 601 */ +/* 600 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69889,16 +69807,16 @@ exports.makeAbsolute = makeAbsolute; /***/ }), -/* 602 */ +/* 601 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const path = __webpack_require__(16); -const globParent = __webpack_require__(603); -const isGlob = __webpack_require__(604); -const micromatch = __webpack_require__(606); +const globParent = __webpack_require__(602); +const isGlob = __webpack_require__(603); +const micromatch = __webpack_require__(605); const GLOBSTAR = '**'; function isStaticPattern(pattern) { return !isDynamicPattern(pattern); @@ -69987,13 +69905,13 @@ exports.matchAny = matchAny; /***/ }), -/* 603 */ +/* 602 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isGlob = __webpack_require__(604); +var isGlob = __webpack_require__(603); var pathPosixDirname = __webpack_require__(16).posix.dirname; var isWin32 = __webpack_require__(11).platform() === 'win32'; @@ -70028,7 +69946,7 @@ module.exports = function globParent(str) { /***/ }), -/* 604 */ +/* 603 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -70038,7 +69956,7 @@ module.exports = function globParent(str) { * Released under the MIT License. */ -var isExtglob = __webpack_require__(605); +var isExtglob = __webpack_require__(604); var chars = { '{': '}', '(': ')', '[': ']'}; var strictRegex = /\\(.)|(^!|\*|[\].+)]\?|\[[^\\\]]+\]|\{[^\\}]+\}|\(\?[:!=][^\\)]+\)|\([^|]+\|[^\\)]+\))/; var relaxedRegex = /\\(.)|(^!|[*?{}()[\]]|\(\?)/; @@ -70082,7 +70000,7 @@ module.exports = function isGlob(str, options) { /***/ }), -/* 605 */ +/* 604 */ /***/ (function(module, exports) { /*! @@ -70108,16 +70026,16 @@ module.exports = function isExtglob(str) { /***/ }), -/* 606 */ +/* 605 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const util = __webpack_require__(29); -const braces = __webpack_require__(607); -const picomatch = __webpack_require__(617); -const utils = __webpack_require__(620); +const braces = __webpack_require__(606); +const picomatch = __webpack_require__(616); +const utils = __webpack_require__(619); const isEmptyString = val => typeof val === 'string' && (val === '' || val === './'); /** @@ -70582,16 +70500,16 @@ module.exports = micromatch; /***/ }), -/* 607 */ +/* 606 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const stringify = __webpack_require__(608); -const compile = __webpack_require__(610); -const expand = __webpack_require__(614); -const parse = __webpack_require__(615); +const stringify = __webpack_require__(607); +const compile = __webpack_require__(609); +const expand = __webpack_require__(613); +const parse = __webpack_require__(614); /** * Expand the given pattern or create a regex-compatible string. @@ -70759,13 +70677,13 @@ module.exports = braces; /***/ }), -/* 608 */ +/* 607 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const utils = __webpack_require__(609); +const utils = __webpack_require__(608); module.exports = (ast, options = {}) => { let stringify = (node, parent = {}) => { @@ -70798,7 +70716,7 @@ module.exports = (ast, options = {}) => { /***/ }), -/* 609 */ +/* 608 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70917,14 +70835,14 @@ exports.flatten = (...args) => { /***/ }), -/* 610 */ +/* 609 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const fill = __webpack_require__(611); -const utils = __webpack_require__(609); +const fill = __webpack_require__(610); +const utils = __webpack_require__(608); const compile = (ast, options = {}) => { let walk = (node, parent = {}) => { @@ -70981,7 +70899,7 @@ module.exports = compile; /***/ }), -/* 611 */ +/* 610 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70995,7 +70913,7 @@ module.exports = compile; const util = __webpack_require__(29); -const toRegexRange = __webpack_require__(612); +const toRegexRange = __webpack_require__(611); const isObject = val => val !== null && typeof val === 'object' && !Array.isArray(val); @@ -71237,7 +71155,7 @@ module.exports = fill; /***/ }), -/* 612 */ +/* 611 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71250,7 +71168,7 @@ module.exports = fill; -const isNumber = __webpack_require__(613); +const isNumber = __webpack_require__(612); const toRegexRange = (min, max, options) => { if (isNumber(min) === false) { @@ -71532,7 +71450,7 @@ module.exports = toRegexRange; /***/ }), -/* 613 */ +/* 612 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71557,15 +71475,15 @@ module.exports = function(num) { /***/ }), -/* 614 */ +/* 613 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const fill = __webpack_require__(611); -const stringify = __webpack_require__(608); -const utils = __webpack_require__(609); +const fill = __webpack_require__(610); +const stringify = __webpack_require__(607); +const utils = __webpack_require__(608); const append = (queue = '', stash = '', enclose = false) => { let result = []; @@ -71677,13 +71595,13 @@ module.exports = expand; /***/ }), -/* 615 */ +/* 614 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const stringify = __webpack_require__(608); +const stringify = __webpack_require__(607); /** * Constants @@ -71705,7 +71623,7 @@ const { CHAR_SINGLE_QUOTE, /* ' */ CHAR_NO_BREAK_SPACE, CHAR_ZERO_WIDTH_NOBREAK_SPACE -} = __webpack_require__(616); +} = __webpack_require__(615); /** * parse @@ -72017,7 +71935,7 @@ module.exports = parse; /***/ }), -/* 616 */ +/* 615 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -72081,26 +71999,26 @@ module.exports = { /***/ }), -/* 617 */ +/* 616 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -module.exports = __webpack_require__(618); +module.exports = __webpack_require__(617); /***/ }), -/* 618 */ +/* 617 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(16); -const scan = __webpack_require__(619); -const parse = __webpack_require__(622); -const utils = __webpack_require__(620); +const scan = __webpack_require__(618); +const parse = __webpack_require__(621); +const utils = __webpack_require__(619); /** * Creates a matcher function from one or more glob patterns. The @@ -72403,7 +72321,7 @@ picomatch.toRegex = (source, options) => { * @return {Object} */ -picomatch.constants = __webpack_require__(621); +picomatch.constants = __webpack_require__(620); /** * Expose "picomatch" @@ -72413,13 +72331,13 @@ module.exports = picomatch; /***/ }), -/* 619 */ +/* 618 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const utils = __webpack_require__(620); +const utils = __webpack_require__(619); const { CHAR_ASTERISK, /* * */ @@ -72437,7 +72355,7 @@ const { CHAR_RIGHT_CURLY_BRACE, /* } */ CHAR_RIGHT_PARENTHESES, /* ) */ CHAR_RIGHT_SQUARE_BRACKET /* ] */ -} = __webpack_require__(621); +} = __webpack_require__(620); const isPathSeparator = code => { return code === CHAR_FORWARD_SLASH || code === CHAR_BACKWARD_SLASH; @@ -72639,7 +72557,7 @@ module.exports = (input, options) => { /***/ }), -/* 620 */ +/* 619 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -72651,7 +72569,7 @@ const { REGEX_SPECIAL_CHARS, REGEX_SPECIAL_CHARS_GLOBAL, REGEX_REMOVE_BACKSLASH -} = __webpack_require__(621); +} = __webpack_require__(620); exports.isObject = val => val !== null && typeof val === 'object' && !Array.isArray(val); exports.hasRegexChars = str => REGEX_SPECIAL_CHARS.test(str); @@ -72689,7 +72607,7 @@ exports.escapeLast = (input, char, lastIdx) => { /***/ }), -/* 621 */ +/* 620 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -72875,14 +72793,14 @@ module.exports = { /***/ }), -/* 622 */ +/* 621 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const utils = __webpack_require__(620); -const constants = __webpack_require__(621); +const utils = __webpack_require__(619); +const constants = __webpack_require__(620); /** * Constants @@ -73893,13 +73811,13 @@ module.exports = parse; /***/ }), -/* 623 */ +/* 622 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const merge2 = __webpack_require__(589); +const merge2 = __webpack_require__(588); function merge(streams) { const mergedStream = merge2(streams); streams.forEach((stream) => { @@ -73911,14 +73829,14 @@ exports.merge = merge; /***/ }), -/* 624 */ +/* 623 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const stream_1 = __webpack_require__(625); -const provider_1 = __webpack_require__(652); +const stream_1 = __webpack_require__(624); +const provider_1 = __webpack_require__(651); class ProviderAsync extends provider_1.default { constructor() { super(...arguments); @@ -73946,16 +73864,16 @@ exports.default = ProviderAsync; /***/ }), -/* 625 */ +/* 624 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const stream_1 = __webpack_require__(27); -const fsStat = __webpack_require__(626); -const fsWalk = __webpack_require__(631); -const reader_1 = __webpack_require__(651); +const fsStat = __webpack_require__(625); +const fsWalk = __webpack_require__(630); +const reader_1 = __webpack_require__(650); class ReaderStream extends reader_1.default { constructor() { super(...arguments); @@ -74008,15 +73926,15 @@ exports.default = ReaderStream; /***/ }), -/* 626 */ +/* 625 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const async = __webpack_require__(627); -const sync = __webpack_require__(628); -const settings_1 = __webpack_require__(629); +const async = __webpack_require__(626); +const sync = __webpack_require__(627); +const settings_1 = __webpack_require__(628); exports.Settings = settings_1.default; function stat(path, optionsOrSettingsOrCallback, callback) { if (typeof optionsOrSettingsOrCallback === 'function') { @@ -74039,7 +73957,7 @@ function getSettings(settingsOrOptions = {}) { /***/ }), -/* 627 */ +/* 626 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74077,7 +73995,7 @@ function callSuccessCallback(callback, result) { /***/ }), -/* 628 */ +/* 627 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74106,13 +74024,13 @@ exports.read = read; /***/ }), -/* 629 */ +/* 628 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fs = __webpack_require__(630); +const fs = __webpack_require__(629); class Settings { constructor(_options = {}) { this._options = _options; @@ -74129,7 +74047,7 @@ exports.default = Settings; /***/ }), -/* 630 */ +/* 629 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74152,16 +74070,16 @@ exports.createFileSystemAdapter = createFileSystemAdapter; /***/ }), -/* 631 */ +/* 630 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const async_1 = __webpack_require__(632); -const stream_1 = __webpack_require__(647); -const sync_1 = __webpack_require__(648); -const settings_1 = __webpack_require__(650); +const async_1 = __webpack_require__(631); +const stream_1 = __webpack_require__(646); +const sync_1 = __webpack_require__(647); +const settings_1 = __webpack_require__(649); exports.Settings = settings_1.default; function walk(dir, optionsOrSettingsOrCallback, callback) { if (typeof optionsOrSettingsOrCallback === 'function') { @@ -74191,13 +74109,13 @@ function getSettings(settingsOrOptions = {}) { /***/ }), -/* 632 */ +/* 631 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const async_1 = __webpack_require__(633); +const async_1 = __webpack_require__(632); class AsyncProvider { constructor(_root, _settings) { this._root = _root; @@ -74228,17 +74146,17 @@ function callSuccessCallback(callback, entries) { /***/ }), -/* 633 */ +/* 632 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const events_1 = __webpack_require__(379); -const fsScandir = __webpack_require__(634); -const fastq = __webpack_require__(643); -const common = __webpack_require__(645); -const reader_1 = __webpack_require__(646); +const fsScandir = __webpack_require__(633); +const fastq = __webpack_require__(642); +const common = __webpack_require__(644); +const reader_1 = __webpack_require__(645); class AsyncReader extends reader_1.default { constructor(_root, _settings) { super(_root, _settings); @@ -74328,15 +74246,15 @@ exports.default = AsyncReader; /***/ }), -/* 634 */ +/* 633 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const async = __webpack_require__(635); -const sync = __webpack_require__(640); -const settings_1 = __webpack_require__(641); +const async = __webpack_require__(634); +const sync = __webpack_require__(639); +const settings_1 = __webpack_require__(640); exports.Settings = settings_1.default; function scandir(path, optionsOrSettingsOrCallback, callback) { if (typeof optionsOrSettingsOrCallback === 'function') { @@ -74359,16 +74277,16 @@ function getSettings(settingsOrOptions = {}) { /***/ }), -/* 635 */ +/* 634 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fsStat = __webpack_require__(626); -const rpl = __webpack_require__(636); -const constants_1 = __webpack_require__(637); -const utils = __webpack_require__(638); +const fsStat = __webpack_require__(625); +const rpl = __webpack_require__(635); +const constants_1 = __webpack_require__(636); +const utils = __webpack_require__(637); function read(dir, settings, callback) { if (!settings.stats && constants_1.IS_SUPPORT_READDIR_WITH_FILE_TYPES) { return readdirWithFileTypes(dir, settings, callback); @@ -74457,7 +74375,7 @@ function callSuccessCallback(callback, result) { /***/ }), -/* 636 */ +/* 635 */ /***/ (function(module, exports) { module.exports = runParallel @@ -74511,7 +74429,7 @@ function runParallel (tasks, cb) { /***/ }), -/* 637 */ +/* 636 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74527,18 +74445,18 @@ exports.IS_SUPPORT_READDIR_WITH_FILE_TYPES = MAJOR_VERSION > 10 || (MAJOR_VERSIO /***/ }), -/* 638 */ +/* 637 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fs = __webpack_require__(639); +const fs = __webpack_require__(638); exports.fs = fs; /***/ }), -/* 639 */ +/* 638 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74563,15 +74481,15 @@ exports.createDirentFromStats = createDirentFromStats; /***/ }), -/* 640 */ +/* 639 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fsStat = __webpack_require__(626); -const constants_1 = __webpack_require__(637); -const utils = __webpack_require__(638); +const fsStat = __webpack_require__(625); +const constants_1 = __webpack_require__(636); +const utils = __webpack_require__(637); function read(dir, settings) { if (!settings.stats && constants_1.IS_SUPPORT_READDIR_WITH_FILE_TYPES) { return readdirWithFileTypes(dir, settings); @@ -74622,15 +74540,15 @@ exports.readdir = readdir; /***/ }), -/* 641 */ +/* 640 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const path = __webpack_require__(16); -const fsStat = __webpack_require__(626); -const fs = __webpack_require__(642); +const fsStat = __webpack_require__(625); +const fs = __webpack_require__(641); class Settings { constructor(_options = {}) { this._options = _options; @@ -74653,7 +74571,7 @@ exports.default = Settings; /***/ }), -/* 642 */ +/* 641 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74678,13 +74596,13 @@ exports.createFileSystemAdapter = createFileSystemAdapter; /***/ }), -/* 643 */ +/* 642 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var reusify = __webpack_require__(644) +var reusify = __webpack_require__(643) function fastqueue (context, worker, concurrency) { if (typeof context === 'function') { @@ -74858,7 +74776,7 @@ module.exports = fastqueue /***/ }), -/* 644 */ +/* 643 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74898,7 +74816,7 @@ module.exports = reusify /***/ }), -/* 645 */ +/* 644 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74929,13 +74847,13 @@ exports.joinPathSegments = joinPathSegments; /***/ }), -/* 646 */ +/* 645 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const common = __webpack_require__(645); +const common = __webpack_require__(644); class Reader { constructor(_root, _settings) { this._root = _root; @@ -74947,14 +74865,14 @@ exports.default = Reader; /***/ }), -/* 647 */ +/* 646 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const stream_1 = __webpack_require__(27); -const async_1 = __webpack_require__(633); +const async_1 = __webpack_require__(632); class StreamProvider { constructor(_root, _settings) { this._root = _root; @@ -74984,13 +74902,13 @@ exports.default = StreamProvider; /***/ }), -/* 648 */ +/* 647 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const sync_1 = __webpack_require__(649); +const sync_1 = __webpack_require__(648); class SyncProvider { constructor(_root, _settings) { this._root = _root; @@ -75005,15 +74923,15 @@ exports.default = SyncProvider; /***/ }), -/* 649 */ +/* 648 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fsScandir = __webpack_require__(634); -const common = __webpack_require__(645); -const reader_1 = __webpack_require__(646); +const fsScandir = __webpack_require__(633); +const common = __webpack_require__(644); +const reader_1 = __webpack_require__(645); class SyncReader extends reader_1.default { constructor() { super(...arguments); @@ -75071,14 +74989,14 @@ exports.default = SyncReader; /***/ }), -/* 650 */ +/* 649 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const path = __webpack_require__(16); -const fsScandir = __webpack_require__(634); +const fsScandir = __webpack_require__(633); class Settings { constructor(_options = {}) { this._options = _options; @@ -75104,15 +75022,15 @@ exports.default = Settings; /***/ }), -/* 651 */ +/* 650 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const path = __webpack_require__(16); -const fsStat = __webpack_require__(626); -const utils = __webpack_require__(597); +const fsStat = __webpack_require__(625); +const utils = __webpack_require__(596); class Reader { constructor(_settings) { this._settings = _settings; @@ -75144,17 +75062,17 @@ exports.default = Reader; /***/ }), -/* 652 */ +/* 651 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const path = __webpack_require__(16); -const deep_1 = __webpack_require__(653); -const entry_1 = __webpack_require__(654); -const error_1 = __webpack_require__(655); -const entry_2 = __webpack_require__(656); +const deep_1 = __webpack_require__(652); +const entry_1 = __webpack_require__(653); +const error_1 = __webpack_require__(654); +const entry_2 = __webpack_require__(655); class Provider { constructor(_settings) { this._settings = _settings; @@ -75199,13 +75117,13 @@ exports.default = Provider; /***/ }), -/* 653 */ +/* 652 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const utils = __webpack_require__(597); +const utils = __webpack_require__(596); class DeepFilter { constructor(_settings, _micromatchOptions) { this._settings = _settings; @@ -75265,13 +75183,13 @@ exports.default = DeepFilter; /***/ }), -/* 654 */ +/* 653 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const utils = __webpack_require__(597); +const utils = __webpack_require__(596); class EntryFilter { constructor(_settings, _micromatchOptions) { this._settings = _settings; @@ -75326,13 +75244,13 @@ exports.default = EntryFilter; /***/ }), -/* 655 */ +/* 654 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const utils = __webpack_require__(597); +const utils = __webpack_require__(596); class ErrorFilter { constructor(_settings) { this._settings = _settings; @@ -75348,13 +75266,13 @@ exports.default = ErrorFilter; /***/ }), -/* 656 */ +/* 655 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const utils = __webpack_require__(597); +const utils = __webpack_require__(596); class EntryTransformer { constructor(_settings) { this._settings = _settings; @@ -75381,15 +75299,15 @@ exports.default = EntryTransformer; /***/ }), -/* 657 */ +/* 656 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const stream_1 = __webpack_require__(27); -const stream_2 = __webpack_require__(625); -const provider_1 = __webpack_require__(652); +const stream_2 = __webpack_require__(624); +const provider_1 = __webpack_require__(651); class ProviderStream extends provider_1.default { constructor() { super(...arguments); @@ -75417,14 +75335,14 @@ exports.default = ProviderStream; /***/ }), -/* 658 */ +/* 657 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const sync_1 = __webpack_require__(659); -const provider_1 = __webpack_require__(652); +const sync_1 = __webpack_require__(658); +const provider_1 = __webpack_require__(651); class ProviderSync extends provider_1.default { constructor() { super(...arguments); @@ -75447,15 +75365,15 @@ exports.default = ProviderSync; /***/ }), -/* 659 */ +/* 658 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fsStat = __webpack_require__(626); -const fsWalk = __webpack_require__(631); -const reader_1 = __webpack_require__(651); +const fsStat = __webpack_require__(625); +const fsWalk = __webpack_require__(630); +const reader_1 = __webpack_require__(650); class ReaderSync extends reader_1.default { constructor() { super(...arguments); @@ -75497,7 +75415,7 @@ exports.default = ReaderSync; /***/ }), -/* 660 */ +/* 659 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75557,13 +75475,13 @@ exports.default = Settings; /***/ }), -/* 661 */ +/* 660 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(16); -const pathType = __webpack_require__(662); +const pathType = __webpack_require__(661); const getExtensions = extensions => extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0]; @@ -75639,7 +75557,7 @@ module.exports.sync = (input, options) => { /***/ }), -/* 662 */ +/* 661 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75689,7 +75607,7 @@ exports.isSymlinkSync = isTypeSync.bind(null, 'lstatSync', 'isSymbolicLink'); /***/ }), -/* 663 */ +/* 662 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75697,9 +75615,9 @@ exports.isSymlinkSync = isTypeSync.bind(null, 'lstatSync', 'isSymbolicLink'); const {promisify} = __webpack_require__(29); const fs = __webpack_require__(23); const path = __webpack_require__(16); -const fastGlob = __webpack_require__(595); -const gitIgnore = __webpack_require__(664); -const slash = __webpack_require__(665); +const fastGlob = __webpack_require__(594); +const gitIgnore = __webpack_require__(663); +const slash = __webpack_require__(664); const DEFAULT_IGNORE = [ '**/node_modules/**', @@ -75813,7 +75731,7 @@ module.exports.sync = options => { /***/ }), -/* 664 */ +/* 663 */ /***/ (function(module, exports) { // A simple implementation of make-array @@ -76404,7 +76322,7 @@ if ( /***/ }), -/* 665 */ +/* 664 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76422,7 +76340,7 @@ module.exports = path => { /***/ }), -/* 666 */ +/* 665 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76475,7 +76393,7 @@ module.exports = { /***/ }), -/* 667 */ +/* 666 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76497,7 +76415,7 @@ module.exports = path_ => { /***/ }), -/* 668 */ +/* 667 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76525,7 +76443,7 @@ module.exports = (childPath, parentPath) => { /***/ }), -/* 669 */ +/* 668 */ /***/ (function(module, exports, __webpack_require__) { const assert = __webpack_require__(30) @@ -76533,7 +76451,7 @@ const path = __webpack_require__(16) const fs = __webpack_require__(23) let glob = undefined try { - glob = __webpack_require__(590) + glob = __webpack_require__(589) } catch (_err) { // treat glob as optional. } @@ -76899,12 +76817,12 @@ rimraf.sync = rimrafSync /***/ }), -/* 670 */ +/* 669 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const AggregateError = __webpack_require__(671); +const AggregateError = __webpack_require__(670); module.exports = async ( iterable, @@ -76987,13 +76905,13 @@ module.exports = async ( /***/ }), -/* 671 */ +/* 670 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const indentString = __webpack_require__(672); -const cleanStack = __webpack_require__(673); +const indentString = __webpack_require__(671); +const cleanStack = __webpack_require__(672); const cleanInternalStack = stack => stack.replace(/\s+at .*aggregate-error\/index.js:\d+:\d+\)?/g, ''); @@ -77041,7 +76959,7 @@ module.exports = AggregateError; /***/ }), -/* 672 */ +/* 671 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77083,7 +77001,7 @@ module.exports = (string, count = 1, options) => { /***/ }), -/* 673 */ +/* 672 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77130,15 +77048,15 @@ module.exports = (stack, options) => { /***/ }), -/* 674 */ +/* 673 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const chalk = __webpack_require__(675); -const cliCursor = __webpack_require__(679); -const cliSpinners = __webpack_require__(683); -const logSymbols = __webpack_require__(564); +const chalk = __webpack_require__(674); +const cliCursor = __webpack_require__(678); +const cliSpinners = __webpack_require__(682); +const logSymbols = __webpack_require__(563); class Ora { constructor(options) { @@ -77285,16 +77203,16 @@ module.exports.promise = (action, options) => { /***/ }), -/* 675 */ +/* 674 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const escapeStringRegexp = __webpack_require__(3); -const ansiStyles = __webpack_require__(676); -const stdoutColor = __webpack_require__(677).stdout; +const ansiStyles = __webpack_require__(675); +const stdoutColor = __webpack_require__(676).stdout; -const template = __webpack_require__(678); +const template = __webpack_require__(677); const isSimpleWindowsTerm = process.platform === 'win32' && !(process.env.TERM || '').toLowerCase().startsWith('xterm'); @@ -77520,7 +77438,7 @@ module.exports.default = module.exports; // For TypeScript /***/ }), -/* 676 */ +/* 675 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77693,7 +77611,7 @@ Object.defineProperty(module, 'exports', { /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(5)(module))) /***/ }), -/* 677 */ +/* 676 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77835,7 +77753,7 @@ module.exports = { /***/ }), -/* 678 */ +/* 677 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77970,12 +77888,12 @@ module.exports = (chalk, tmp) => { /***/ }), -/* 679 */ +/* 678 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const restoreCursor = __webpack_require__(680); +const restoreCursor = __webpack_require__(679); let hidden = false; @@ -78016,12 +77934,12 @@ exports.toggle = (force, stream) => { /***/ }), -/* 680 */ +/* 679 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const onetime = __webpack_require__(681); +const onetime = __webpack_require__(680); const signalExit = __webpack_require__(377); module.exports = onetime(() => { @@ -78032,12 +77950,12 @@ module.exports = onetime(() => { /***/ }), -/* 681 */ +/* 680 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const mimicFn = __webpack_require__(682); +const mimicFn = __webpack_require__(681); module.exports = (fn, opts) => { // TODO: Remove this in v3 @@ -78078,7 +77996,7 @@ module.exports = (fn, opts) => { /***/ }), -/* 682 */ +/* 681 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78094,22 +78012,22 @@ module.exports = (to, from) => { /***/ }), -/* 683 */ +/* 682 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -module.exports = __webpack_require__(684); +module.exports = __webpack_require__(683); /***/ }), -/* 684 */ +/* 683 */ /***/ (function(module) { module.exports = JSON.parse("{\"dots\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠹\",\"⠸\",\"⠼\",\"⠴\",\"⠦\",\"⠧\",\"⠇\",\"⠏\"]},\"dots2\":{\"interval\":80,\"frames\":[\"⣾\",\"⣽\",\"⣻\",\"⢿\",\"⡿\",\"⣟\",\"⣯\",\"⣷\"]},\"dots3\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠚\",\"⠞\",\"⠖\",\"⠦\",\"⠴\",\"⠲\",\"⠳\",\"⠓\"]},\"dots4\":{\"interval\":80,\"frames\":[\"⠄\",\"⠆\",\"⠇\",\"⠋\",\"⠙\",\"⠸\",\"⠰\",\"⠠\",\"⠰\",\"⠸\",\"⠙\",\"⠋\",\"⠇\",\"⠆\"]},\"dots5\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\"]},\"dots6\":{\"interval\":80,\"frames\":[\"⠁\",\"⠉\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠤\",\"⠄\",\"⠄\",\"⠤\",\"⠴\",\"⠲\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠚\",\"⠙\",\"⠉\",\"⠁\"]},\"dots7\":{\"interval\":80,\"frames\":[\"⠈\",\"⠉\",\"⠋\",\"⠓\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠖\",\"⠦\",\"⠤\",\"⠠\",\"⠠\",\"⠤\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\",\"⠉\",\"⠈\"]},\"dots8\":{\"interval\":80,\"frames\":[\"⠁\",\"⠁\",\"⠉\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠤\",\"⠄\",\"⠄\",\"⠤\",\"⠠\",\"⠠\",\"⠤\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\",\"⠉\",\"⠈\",\"⠈\"]},\"dots9\":{\"interval\":80,\"frames\":[\"⢹\",\"⢺\",\"⢼\",\"⣸\",\"⣇\",\"⡧\",\"⡗\",\"⡏\"]},\"dots10\":{\"interval\":80,\"frames\":[\"⢄\",\"⢂\",\"⢁\",\"⡁\",\"⡈\",\"⡐\",\"⡠\"]},\"dots11\":{\"interval\":100,\"frames\":[\"⠁\",\"⠂\",\"⠄\",\"⡀\",\"⢀\",\"⠠\",\"⠐\",\"⠈\"]},\"dots12\":{\"interval\":80,\"frames\":[\"⢀⠀\",\"⡀⠀\",\"⠄⠀\",\"⢂⠀\",\"⡂⠀\",\"⠅⠀\",\"⢃⠀\",\"⡃⠀\",\"⠍⠀\",\"⢋⠀\",\"⡋⠀\",\"⠍⠁\",\"⢋⠁\",\"⡋⠁\",\"⠍⠉\",\"⠋⠉\",\"⠋⠉\",\"⠉⠙\",\"⠉⠙\",\"⠉⠩\",\"⠈⢙\",\"⠈⡙\",\"⢈⠩\",\"⡀⢙\",\"⠄⡙\",\"⢂⠩\",\"⡂⢘\",\"⠅⡘\",\"⢃⠨\",\"⡃⢐\",\"⠍⡐\",\"⢋⠠\",\"⡋⢀\",\"⠍⡁\",\"⢋⠁\",\"⡋⠁\",\"⠍⠉\",\"⠋⠉\",\"⠋⠉\",\"⠉⠙\",\"⠉⠙\",\"⠉⠩\",\"⠈⢙\",\"⠈⡙\",\"⠈⠩\",\"⠀⢙\",\"⠀⡙\",\"⠀⠩\",\"⠀⢘\",\"⠀⡘\",\"⠀⠨\",\"⠀⢐\",\"⠀⡐\",\"⠀⠠\",\"⠀⢀\",\"⠀⡀\"]},\"line\":{\"interval\":130,\"frames\":[\"-\",\"\\\\\",\"|\",\"/\"]},\"line2\":{\"interval\":100,\"frames\":[\"⠂\",\"-\",\"–\",\"—\",\"–\",\"-\"]},\"pipe\":{\"interval\":100,\"frames\":[\"┤\",\"┘\",\"┴\",\"└\",\"├\",\"┌\",\"┬\",\"┐\"]},\"simpleDots\":{\"interval\":400,\"frames\":[\". \",\".. \",\"...\",\" \"]},\"simpleDotsScrolling\":{\"interval\":200,\"frames\":[\". \",\".. \",\"...\",\" ..\",\" .\",\" \"]},\"star\":{\"interval\":70,\"frames\":[\"✶\",\"✸\",\"✹\",\"✺\",\"✹\",\"✷\"]},\"star2\":{\"interval\":80,\"frames\":[\"+\",\"x\",\"*\"]},\"flip\":{\"interval\":70,\"frames\":[\"_\",\"_\",\"_\",\"-\",\"`\",\"`\",\"'\",\"´\",\"-\",\"_\",\"_\",\"_\"]},\"hamburger\":{\"interval\":100,\"frames\":[\"☱\",\"☲\",\"☴\"]},\"growVertical\":{\"interval\":120,\"frames\":[\"▁\",\"▃\",\"▄\",\"▅\",\"▆\",\"▇\",\"▆\",\"▅\",\"▄\",\"▃\"]},\"growHorizontal\":{\"interval\":120,\"frames\":[\"▏\",\"▎\",\"▍\",\"▌\",\"▋\",\"▊\",\"▉\",\"▊\",\"▋\",\"▌\",\"▍\",\"▎\"]},\"balloon\":{\"interval\":140,\"frames\":[\" \",\".\",\"o\",\"O\",\"@\",\"*\",\" \"]},\"balloon2\":{\"interval\":120,\"frames\":[\".\",\"o\",\"O\",\"°\",\"O\",\"o\",\".\"]},\"noise\":{\"interval\":100,\"frames\":[\"▓\",\"▒\",\"░\"]},\"bounce\":{\"interval\":120,\"frames\":[\"⠁\",\"⠂\",\"⠄\",\"⠂\"]},\"boxBounce\":{\"interval\":120,\"frames\":[\"▖\",\"▘\",\"▝\",\"▗\"]},\"boxBounce2\":{\"interval\":100,\"frames\":[\"▌\",\"▀\",\"▐\",\"▄\"]},\"triangle\":{\"interval\":50,\"frames\":[\"◢\",\"◣\",\"◤\",\"◥\"]},\"arc\":{\"interval\":100,\"frames\":[\"◜\",\"◠\",\"◝\",\"◞\",\"◡\",\"◟\"]},\"circle\":{\"interval\":120,\"frames\":[\"◡\",\"⊙\",\"◠\"]},\"squareCorners\":{\"interval\":180,\"frames\":[\"◰\",\"◳\",\"◲\",\"◱\"]},\"circleQuarters\":{\"interval\":120,\"frames\":[\"◴\",\"◷\",\"◶\",\"◵\"]},\"circleHalves\":{\"interval\":50,\"frames\":[\"◐\",\"◓\",\"◑\",\"◒\"]},\"squish\":{\"interval\":100,\"frames\":[\"╫\",\"╪\"]},\"toggle\":{\"interval\":250,\"frames\":[\"⊶\",\"⊷\"]},\"toggle2\":{\"interval\":80,\"frames\":[\"▫\",\"▪\"]},\"toggle3\":{\"interval\":120,\"frames\":[\"□\",\"■\"]},\"toggle4\":{\"interval\":100,\"frames\":[\"■\",\"□\",\"▪\",\"▫\"]},\"toggle5\":{\"interval\":100,\"frames\":[\"▮\",\"▯\"]},\"toggle6\":{\"interval\":300,\"frames\":[\"ဝ\",\"၀\"]},\"toggle7\":{\"interval\":80,\"frames\":[\"⦾\",\"⦿\"]},\"toggle8\":{\"interval\":100,\"frames\":[\"◍\",\"◌\"]},\"toggle9\":{\"interval\":100,\"frames\":[\"◉\",\"◎\"]},\"toggle10\":{\"interval\":100,\"frames\":[\"㊂\",\"㊀\",\"㊁\"]},\"toggle11\":{\"interval\":50,\"frames\":[\"⧇\",\"⧆\"]},\"toggle12\":{\"interval\":120,\"frames\":[\"☗\",\"☖\"]},\"toggle13\":{\"interval\":80,\"frames\":[\"=\",\"*\",\"-\"]},\"arrow\":{\"interval\":100,\"frames\":[\"←\",\"↖\",\"↑\",\"↗\",\"→\",\"↘\",\"↓\",\"↙\"]},\"arrow2\":{\"interval\":80,\"frames\":[\"⬆️ \",\"↗️ \",\"➡️ \",\"↘️ \",\"⬇️ \",\"↙️ \",\"⬅️ \",\"↖️ \"]},\"arrow3\":{\"interval\":120,\"frames\":[\"▹▹▹▹▹\",\"▸▹▹▹▹\",\"▹▸▹▹▹\",\"▹▹▸▹▹\",\"▹▹▹▸▹\",\"▹▹▹▹▸\"]},\"bouncingBar\":{\"interval\":80,\"frames\":[\"[ ]\",\"[= ]\",\"[== ]\",\"[=== ]\",\"[ ===]\",\"[ ==]\",\"[ =]\",\"[ ]\",\"[ =]\",\"[ ==]\",\"[ ===]\",\"[====]\",\"[=== ]\",\"[== ]\",\"[= ]\"]},\"bouncingBall\":{\"interval\":80,\"frames\":[\"( ● )\",\"( ● )\",\"( ● )\",\"( ● )\",\"( ●)\",\"( ● )\",\"( ● )\",\"( ● )\",\"( ● )\",\"(● )\"]},\"smiley\":{\"interval\":200,\"frames\":[\"😄 \",\"😝 \"]},\"monkey\":{\"interval\":300,\"frames\":[\"🙈 \",\"🙈 \",\"🙉 \",\"🙊 \"]},\"hearts\":{\"interval\":100,\"frames\":[\"💛 \",\"💙 \",\"💜 \",\"💚 \",\"❤️ \"]},\"clock\":{\"interval\":100,\"frames\":[\"🕐 \",\"🕑 \",\"🕒 \",\"🕓 \",\"🕔 \",\"🕕 \",\"🕖 \",\"🕗 \",\"🕘 \",\"🕙 \",\"🕚 \"]},\"earth\":{\"interval\":180,\"frames\":[\"🌍 \",\"🌎 \",\"🌏 \"]},\"moon\":{\"interval\":80,\"frames\":[\"🌑 \",\"🌒 \",\"🌓 \",\"🌔 \",\"🌕 \",\"🌖 \",\"🌗 \",\"🌘 \"]},\"runner\":{\"interval\":140,\"frames\":[\"🚶 \",\"🏃 \"]},\"pong\":{\"interval\":80,\"frames\":[\"▐⠂ ▌\",\"▐⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂▌\",\"▐ ⠠▌\",\"▐ ⡀▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐⠠ ▌\"]},\"shark\":{\"interval\":120,\"frames\":[\"▐|\\\\____________▌\",\"▐_|\\\\___________▌\",\"▐__|\\\\__________▌\",\"▐___|\\\\_________▌\",\"▐____|\\\\________▌\",\"▐_____|\\\\_______▌\",\"▐______|\\\\______▌\",\"▐_______|\\\\_____▌\",\"▐________|\\\\____▌\",\"▐_________|\\\\___▌\",\"▐__________|\\\\__▌\",\"▐___________|\\\\_▌\",\"▐____________|\\\\▌\",\"▐____________/|▌\",\"▐___________/|_▌\",\"▐__________/|__▌\",\"▐_________/|___▌\",\"▐________/|____▌\",\"▐_______/|_____▌\",\"▐______/|______▌\",\"▐_____/|_______▌\",\"▐____/|________▌\",\"▐___/|_________▌\",\"▐__/|__________▌\",\"▐_/|___________▌\",\"▐/|____________▌\"]},\"dqpb\":{\"interval\":100,\"frames\":[\"d\",\"q\",\"p\",\"b\"]},\"weather\":{\"interval\":100,\"frames\":[\"☀️ \",\"☀️ \",\"☀️ \",\"🌤 \",\"⛅️ \",\"🌥 \",\"☁️ \",\"🌧 \",\"🌨 \",\"🌧 \",\"🌨 \",\"🌧 \",\"🌨 \",\"⛈ \",\"🌨 \",\"🌧 \",\"🌨 \",\"☁️ \",\"🌥 \",\"⛅️ \",\"🌤 \",\"☀️ \",\"☀️ \"]},\"christmas\":{\"interval\":400,\"frames\":[\"🌲\",\"🎄\"]}}"); /***/ }), -/* 685 */ +/* 684 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -78169,7 +78087,7 @@ const RunCommand = { }; /***/ }), -/* 686 */ +/* 685 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -78180,7 +78098,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(34); /* harmony import */ var _utils_parallelize__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(499); /* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(500); -/* harmony import */ var _utils_watch__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(687); +/* harmony import */ var _utils_watch__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(686); /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -78264,7 +78182,7 @@ const WatchCommand = { }; /***/ }), -/* 687 */ +/* 686 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -78338,7 +78256,7 @@ function waitUntilWatchIsReady(stream, opts = {}) { } /***/ }), -/* 688 */ +/* 687 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -78346,15 +78264,15 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "runCommand", function() { return runCommand; }); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(chalk__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var indent_string__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(689); +/* harmony import */ var indent_string__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(688); /* harmony import */ var indent_string__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(indent_string__WEBPACK_IMPORTED_MODULE_1__); -/* harmony import */ var wrap_ansi__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(690); +/* harmony import */ var wrap_ansi__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(689); /* harmony import */ var wrap_ansi__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(wrap_ansi__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var _utils_errors__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(514); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(34); /* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(500); -/* harmony import */ var _utils_projects_tree__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(697); -/* harmony import */ var _utils_kibana__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(698); +/* harmony import */ var _utils_projects_tree__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(696); +/* harmony import */ var _utils_kibana__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(697); function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } @@ -78442,7 +78360,7 @@ function toArray(value) { } /***/ }), -/* 689 */ +/* 688 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78476,13 +78394,13 @@ module.exports = (str, count, opts) => { /***/ }), -/* 690 */ +/* 689 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const stringWidth = __webpack_require__(691); -const stripAnsi = __webpack_require__(695); +const stringWidth = __webpack_require__(690); +const stripAnsi = __webpack_require__(694); const ESCAPES = new Set([ '\u001B', @@ -78676,13 +78594,13 @@ module.exports = (str, cols, opts) => { /***/ }), -/* 691 */ +/* 690 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const stripAnsi = __webpack_require__(692); -const isFullwidthCodePoint = __webpack_require__(694); +const stripAnsi = __webpack_require__(691); +const isFullwidthCodePoint = __webpack_require__(693); module.exports = str => { if (typeof str !== 'string' || str.length === 0) { @@ -78719,18 +78637,18 @@ module.exports = str => { /***/ }), -/* 692 */ +/* 691 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const ansiRegex = __webpack_require__(693); +const ansiRegex = __webpack_require__(692); module.exports = input => typeof input === 'string' ? input.replace(ansiRegex(), '') : input; /***/ }), -/* 693 */ +/* 692 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78747,7 +78665,7 @@ module.exports = () => { /***/ }), -/* 694 */ +/* 693 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78800,18 +78718,18 @@ module.exports = x => { /***/ }), -/* 695 */ +/* 694 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const ansiRegex = __webpack_require__(696); +const ansiRegex = __webpack_require__(695); module.exports = input => typeof input === 'string' ? input.replace(ansiRegex(), '') : input; /***/ }), -/* 696 */ +/* 695 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78828,7 +78746,7 @@ module.exports = () => { /***/ }), -/* 697 */ +/* 696 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -78981,7 +78899,7 @@ function addProjectToTree(tree, pathParts, project) { } /***/ }), -/* 698 */ +/* 697 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -78989,12 +78907,12 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Kibana", function() { return Kibana; }); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(16); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var multimatch__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(699); +/* harmony import */ var multimatch__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(698); /* harmony import */ var multimatch__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(multimatch__WEBPACK_IMPORTED_MODULE_1__); -/* harmony import */ var is_path_inside__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(703); +/* harmony import */ var is_path_inside__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(702); /* harmony import */ var is_path_inside__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(is_path_inside__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var _projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(500); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(578); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(577); function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } @@ -79135,15 +79053,15 @@ class Kibana { } /***/ }), -/* 699 */ +/* 698 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const minimatch = __webpack_require__(504); -const arrayUnion = __webpack_require__(700); -const arrayDiffer = __webpack_require__(701); -const arrify = __webpack_require__(702); +const arrayUnion = __webpack_require__(699); +const arrayDiffer = __webpack_require__(700); +const arrify = __webpack_require__(701); module.exports = (list, patterns, options = {}) => { list = arrify(list); @@ -79167,7 +79085,7 @@ module.exports = (list, patterns, options = {}) => { /***/ }), -/* 700 */ +/* 699 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79179,7 +79097,7 @@ module.exports = (...arguments_) => { /***/ }), -/* 701 */ +/* 700 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79194,7 +79112,7 @@ module.exports = arrayDiffer; /***/ }), -/* 702 */ +/* 701 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79224,7 +79142,7 @@ module.exports = arrify; /***/ }), -/* 703 */ +/* 702 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79252,15 +79170,15 @@ module.exports = (childPath, parentPath) => { /***/ }), -/* 704 */ +/* 703 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); -/* harmony import */ var _build_production_projects__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(705); +/* harmony import */ var _build_production_projects__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(704); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildProductionProjects", function() { return _build_production_projects__WEBPACK_IMPORTED_MODULE_0__["buildProductionProjects"]; }); -/* harmony import */ var _prepare_project_dependencies__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(923); +/* harmony import */ var _prepare_project_dependencies__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(927); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "prepareExternalProjectDependencies", function() { return _prepare_project_dependencies__WEBPACK_IMPORTED_MODULE_1__["prepareExternalProjectDependencies"]; }); /* @@ -79285,19 +79203,19 @@ __webpack_require__.r(__webpack_exports__); /***/ }), -/* 705 */ +/* 704 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "buildProductionProjects", function() { return buildProductionProjects; }); -/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(706); +/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(705); /* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(cpy__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(586); +/* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(585); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(del__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(16); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(578); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(577); /* harmony import */ var _utils_fs__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(20); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(34); /* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(516); @@ -79433,7 +79351,7 @@ async function copyToBuild(project, kibanaRoot, buildRoot) { } /***/ }), -/* 706 */ +/* 705 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79441,13 +79359,13 @@ async function copyToBuild(project, kibanaRoot, buildRoot) { const EventEmitter = __webpack_require__(379); const path = __webpack_require__(16); const os = __webpack_require__(11); -const pAll = __webpack_require__(707); -const arrify = __webpack_require__(709); -const globby = __webpack_require__(710); -const isGlob = __webpack_require__(604); -const cpFile = __webpack_require__(908); -const junk = __webpack_require__(920); -const CpyError = __webpack_require__(921); +const pAll = __webpack_require__(706); +const arrify = __webpack_require__(708); +const globby = __webpack_require__(709); +const isGlob = __webpack_require__(603); +const cpFile = __webpack_require__(912); +const junk = __webpack_require__(924); +const CpyError = __webpack_require__(925); const defaultOptions = { ignoreJunk: true @@ -79566,12 +79484,12 @@ module.exports = (source, destination, { /***/ }), -/* 707 */ +/* 706 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pMap = __webpack_require__(708); +const pMap = __webpack_require__(707); module.exports = (iterable, options) => pMap(iterable, element => element(), options); // TODO: Remove this for the next major release @@ -79579,7 +79497,7 @@ module.exports.default = module.exports; /***/ }), -/* 708 */ +/* 707 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79658,7 +79576,7 @@ module.exports.default = pMap; /***/ }), -/* 709 */ +/* 708 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79688,17 +79606,17 @@ module.exports = arrify; /***/ }), -/* 710 */ +/* 709 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(23); -const arrayUnion = __webpack_require__(711); -const glob = __webpack_require__(713); -const fastGlob = __webpack_require__(718); -const dirGlob = __webpack_require__(901); -const gitignore = __webpack_require__(904); +const arrayUnion = __webpack_require__(710); +const glob = __webpack_require__(712); +const fastGlob = __webpack_require__(717); +const dirGlob = __webpack_require__(905); +const gitignore = __webpack_require__(908); const DEFAULT_FILTER = () => false; @@ -79843,12 +79761,12 @@ module.exports.gitignore = gitignore; /***/ }), -/* 711 */ +/* 710 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var arrayUniq = __webpack_require__(712); +var arrayUniq = __webpack_require__(711); module.exports = function () { return arrayUniq([].concat.apply([], arguments)); @@ -79856,7 +79774,7 @@ module.exports = function () { /***/ }), -/* 712 */ +/* 711 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79925,7 +79843,7 @@ if ('Set' in global) { /***/ }), -/* 713 */ +/* 712 */ /***/ (function(module, exports, __webpack_require__) { // Approach: @@ -79974,13 +79892,13 @@ var fs = __webpack_require__(23) var rp = __webpack_require__(502) var minimatch = __webpack_require__(504) var Minimatch = minimatch.Minimatch -var inherits = __webpack_require__(714) +var inherits = __webpack_require__(713) var EE = __webpack_require__(379).EventEmitter var path = __webpack_require__(16) var assert = __webpack_require__(30) var isAbsolute = __webpack_require__(510) -var globSync = __webpack_require__(716) -var common = __webpack_require__(717) +var globSync = __webpack_require__(715) +var common = __webpack_require__(716) var alphasort = common.alphasort var alphasorti = common.alphasorti var setopts = common.setopts @@ -80721,7 +80639,7 @@ Glob.prototype._stat2 = function (f, abs, er, stat, cb) { /***/ }), -/* 714 */ +/* 713 */ /***/ (function(module, exports, __webpack_require__) { try { @@ -80731,12 +80649,12 @@ try { module.exports = util.inherits; } catch (e) { /* istanbul ignore next */ - module.exports = __webpack_require__(715); + module.exports = __webpack_require__(714); } /***/ }), -/* 715 */ +/* 714 */ /***/ (function(module, exports) { if (typeof Object.create === 'function') { @@ -80769,7 +80687,7 @@ if (typeof Object.create === 'function') { /***/ }), -/* 716 */ +/* 715 */ /***/ (function(module, exports, __webpack_require__) { module.exports = globSync @@ -80779,12 +80697,12 @@ var fs = __webpack_require__(23) var rp = __webpack_require__(502) var minimatch = __webpack_require__(504) var Minimatch = minimatch.Minimatch -var Glob = __webpack_require__(713).Glob +var Glob = __webpack_require__(712).Glob var util = __webpack_require__(29) var path = __webpack_require__(16) var assert = __webpack_require__(30) var isAbsolute = __webpack_require__(510) -var common = __webpack_require__(717) +var common = __webpack_require__(716) var alphasort = common.alphasort var alphasorti = common.alphasorti var setopts = common.setopts @@ -81261,7 +81179,7 @@ GlobSync.prototype._makeAbs = function (f) { /***/ }), -/* 717 */ +/* 716 */ /***/ (function(module, exports, __webpack_require__) { exports.alphasort = alphasort @@ -81507,10 +81425,10 @@ function childrenIgnored (self, path) { /***/ }), -/* 718 */ +/* 717 */ /***/ (function(module, exports, __webpack_require__) { -const pkg = __webpack_require__(719); +const pkg = __webpack_require__(718); module.exports = pkg.async; module.exports.default = pkg.async; @@ -81523,19 +81441,19 @@ module.exports.generateTasks = pkg.generateTasks; /***/ }), -/* 719 */ +/* 718 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var optionsManager = __webpack_require__(720); -var taskManager = __webpack_require__(721); -var reader_async_1 = __webpack_require__(872); -var reader_stream_1 = __webpack_require__(896); -var reader_sync_1 = __webpack_require__(897); -var arrayUtils = __webpack_require__(899); -var streamUtils = __webpack_require__(900); +var optionsManager = __webpack_require__(719); +var taskManager = __webpack_require__(720); +var reader_async_1 = __webpack_require__(876); +var reader_stream_1 = __webpack_require__(900); +var reader_sync_1 = __webpack_require__(901); +var arrayUtils = __webpack_require__(903); +var streamUtils = __webpack_require__(904); /** * Synchronous API. */ @@ -81601,7 +81519,7 @@ function isString(source) { /***/ }), -/* 720 */ +/* 719 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81639,13 +81557,13 @@ exports.prepare = prepare; /***/ }), -/* 721 */ +/* 720 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var patternUtils = __webpack_require__(722); +var patternUtils = __webpack_require__(721); /** * Generate tasks based on parent directory of each pattern. */ @@ -81736,16 +81654,16 @@ exports.convertPatternGroupToTask = convertPatternGroupToTask; /***/ }), -/* 722 */ +/* 721 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var path = __webpack_require__(16); -var globParent = __webpack_require__(723); -var isGlob = __webpack_require__(726); -var micromatch = __webpack_require__(727); +var globParent = __webpack_require__(722); +var isGlob = __webpack_require__(725); +var micromatch = __webpack_require__(726); var GLOBSTAR = '**'; /** * Return true for static pattern. @@ -81891,15 +81809,15 @@ exports.matchAny = matchAny; /***/ }), -/* 723 */ +/* 722 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var path = __webpack_require__(16); -var isglob = __webpack_require__(724); -var pathDirname = __webpack_require__(725); +var isglob = __webpack_require__(723); +var pathDirname = __webpack_require__(724); var isWin32 = __webpack_require__(11).platform() === 'win32'; module.exports = function globParent(str) { @@ -81922,7 +81840,7 @@ module.exports = function globParent(str) { /***/ }), -/* 724 */ +/* 723 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -81932,7 +81850,7 @@ module.exports = function globParent(str) { * Licensed under the MIT License. */ -var isExtglob = __webpack_require__(605); +var isExtglob = __webpack_require__(604); module.exports = function isGlob(str) { if (typeof str !== 'string' || str === '') { @@ -81953,7 +81871,7 @@ module.exports = function isGlob(str) { /***/ }), -/* 725 */ +/* 724 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82103,7 +82021,7 @@ module.exports.win32 = win32; /***/ }), -/* 726 */ +/* 725 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -82113,7 +82031,7 @@ module.exports.win32 = win32; * Released under the MIT License. */ -var isExtglob = __webpack_require__(605); +var isExtglob = __webpack_require__(604); var chars = { '{': '}', '(': ')', '[': ']'}; module.exports = function isGlob(str, options) { @@ -82155,7 +82073,7 @@ module.exports = function isGlob(str, options) { /***/ }), -/* 727 */ +/* 726 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82166,18 +82084,18 @@ module.exports = function isGlob(str, options) { */ var util = __webpack_require__(29); -var braces = __webpack_require__(728); -var toRegex = __webpack_require__(830); -var extend = __webpack_require__(838); +var braces = __webpack_require__(727); +var toRegex = __webpack_require__(829); +var extend = __webpack_require__(837); /** * Local dependencies */ -var compilers = __webpack_require__(841); -var parsers = __webpack_require__(868); -var cache = __webpack_require__(869); -var utils = __webpack_require__(870); +var compilers = __webpack_require__(840); +var parsers = __webpack_require__(872); +var cache = __webpack_require__(873); +var utils = __webpack_require__(874); var MAX_LENGTH = 1024 * 64; /** @@ -83039,7 +82957,7 @@ module.exports = micromatch; /***/ }), -/* 728 */ +/* 727 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83049,18 +82967,18 @@ module.exports = micromatch; * Module dependencies */ -var toRegex = __webpack_require__(729); -var unique = __webpack_require__(741); -var extend = __webpack_require__(738); +var toRegex = __webpack_require__(728); +var unique = __webpack_require__(740); +var extend = __webpack_require__(737); /** * Local dependencies */ -var compilers = __webpack_require__(742); -var parsers = __webpack_require__(757); -var Braces = __webpack_require__(767); -var utils = __webpack_require__(743); +var compilers = __webpack_require__(741); +var parsers = __webpack_require__(756); +var Braces = __webpack_require__(766); +var utils = __webpack_require__(742); var MAX_LENGTH = 1024 * 64; var cache = {}; @@ -83364,15 +83282,15 @@ module.exports = braces; /***/ }), -/* 729 */ +/* 728 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var define = __webpack_require__(730); -var extend = __webpack_require__(738); -var not = __webpack_require__(740); +var define = __webpack_require__(729); +var extend = __webpack_require__(737); +var not = __webpack_require__(739); var MAX_LENGTH = 1024 * 64; /** @@ -83519,7 +83437,7 @@ module.exports.makeRe = makeRe; /***/ }), -/* 730 */ +/* 729 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83532,7 +83450,7 @@ module.exports.makeRe = makeRe; -var isDescriptor = __webpack_require__(731); +var isDescriptor = __webpack_require__(730); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -83557,7 +83475,7 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 731 */ +/* 730 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83570,9 +83488,9 @@ module.exports = function defineProperty(obj, prop, val) { -var typeOf = __webpack_require__(732); -var isAccessor = __webpack_require__(733); -var isData = __webpack_require__(736); +var typeOf = __webpack_require__(731); +var isAccessor = __webpack_require__(732); +var isData = __webpack_require__(735); module.exports = function isDescriptor(obj, key) { if (typeOf(obj) !== 'object') { @@ -83586,7 +83504,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 732 */ +/* 731 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -83739,7 +83657,7 @@ function isBuffer(val) { /***/ }), -/* 733 */ +/* 732 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83752,7 +83670,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(734); +var typeOf = __webpack_require__(733); // accessor descriptor properties var accessor = { @@ -83815,10 +83733,10 @@ module.exports = isAccessorDescriptor; /***/ }), -/* 734 */ +/* 733 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(735); +var isBuffer = __webpack_require__(734); var toString = Object.prototype.toString; /** @@ -83937,7 +83855,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 735 */ +/* 734 */ /***/ (function(module, exports) { /*! @@ -83964,7 +83882,7 @@ function isSlowBuffer (obj) { /***/ }), -/* 736 */ +/* 735 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83977,7 +83895,7 @@ function isSlowBuffer (obj) { -var typeOf = __webpack_require__(737); +var typeOf = __webpack_require__(736); // data descriptor properties var data = { @@ -84026,10 +83944,10 @@ module.exports = isDataDescriptor; /***/ }), -/* 737 */ +/* 736 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(735); +var isBuffer = __webpack_require__(734); var toString = Object.prototype.toString; /** @@ -84148,13 +84066,13 @@ module.exports = function kindOf(val) { /***/ }), -/* 738 */ +/* 737 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(739); +var isObject = __webpack_require__(738); module.exports = function extend(o/*, objects*/) { if (!isObject(o)) { o = {}; } @@ -84188,7 +84106,7 @@ function hasOwn(obj, key) { /***/ }), -/* 739 */ +/* 738 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84208,13 +84126,13 @@ module.exports = function isExtendable(val) { /***/ }), -/* 740 */ +/* 739 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extend = __webpack_require__(738); +var extend = __webpack_require__(737); /** * The main export is a function that takes a `pattern` string and an `options` object. @@ -84281,7 +84199,7 @@ module.exports = toRegex; /***/ }), -/* 741 */ +/* 740 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84331,13 +84249,13 @@ module.exports.immutable = function uniqueImmutable(arr) { /***/ }), -/* 742 */ +/* 741 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(743); +var utils = __webpack_require__(742); module.exports = function(braces, options) { braces.compiler @@ -84620,25 +84538,25 @@ function hasQueue(node) { /***/ }), -/* 743 */ +/* 742 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var splitString = __webpack_require__(744); +var splitString = __webpack_require__(743); var utils = module.exports; /** * Module dependencies */ -utils.extend = __webpack_require__(738); -utils.flatten = __webpack_require__(750); -utils.isObject = __webpack_require__(748); -utils.fillRange = __webpack_require__(751); -utils.repeat = __webpack_require__(756); -utils.unique = __webpack_require__(741); +utils.extend = __webpack_require__(737); +utils.flatten = __webpack_require__(749); +utils.isObject = __webpack_require__(747); +utils.fillRange = __webpack_require__(750); +utils.repeat = __webpack_require__(755); +utils.unique = __webpack_require__(740); utils.define = function(obj, key, val) { Object.defineProperty(obj, key, { @@ -84970,7 +84888,7 @@ utils.escapeRegex = function(str) { /***/ }), -/* 744 */ +/* 743 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84983,7 +84901,7 @@ utils.escapeRegex = function(str) { -var extend = __webpack_require__(745); +var extend = __webpack_require__(744); module.exports = function(str, options, fn) { if (typeof str !== 'string') { @@ -85148,14 +85066,14 @@ function keepEscaping(opts, str, idx) { /***/ }), -/* 745 */ +/* 744 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(746); -var assignSymbols = __webpack_require__(749); +var isExtendable = __webpack_require__(745); +var assignSymbols = __webpack_require__(748); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -85215,7 +85133,7 @@ function isEnum(obj, key) { /***/ }), -/* 746 */ +/* 745 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85228,7 +85146,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(747); +var isPlainObject = __webpack_require__(746); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -85236,7 +85154,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 747 */ +/* 746 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85249,7 +85167,7 @@ module.exports = function isExtendable(val) { -var isObject = __webpack_require__(748); +var isObject = __webpack_require__(747); function isObjectObject(o) { return isObject(o) === true @@ -85280,7 +85198,7 @@ module.exports = function isPlainObject(o) { /***/ }), -/* 748 */ +/* 747 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85299,7 +85217,7 @@ module.exports = function isObject(val) { /***/ }), -/* 749 */ +/* 748 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85346,7 +85264,7 @@ module.exports = function(receiver, objects) { /***/ }), -/* 750 */ +/* 749 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85375,7 +85293,7 @@ function flat(arr, res) { /***/ }), -/* 751 */ +/* 750 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85389,10 +85307,10 @@ function flat(arr, res) { var util = __webpack_require__(29); -var isNumber = __webpack_require__(752); -var extend = __webpack_require__(738); -var repeat = __webpack_require__(754); -var toRegex = __webpack_require__(755); +var isNumber = __webpack_require__(751); +var extend = __webpack_require__(737); +var repeat = __webpack_require__(753); +var toRegex = __webpack_require__(754); /** * Return a range of numbers or letters. @@ -85590,7 +85508,7 @@ module.exports = fillRange; /***/ }), -/* 752 */ +/* 751 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85603,7 +85521,7 @@ module.exports = fillRange; -var typeOf = __webpack_require__(753); +var typeOf = __webpack_require__(752); module.exports = function isNumber(num) { var type = typeOf(num); @@ -85619,10 +85537,10 @@ module.exports = function isNumber(num) { /***/ }), -/* 753 */ +/* 752 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(735); +var isBuffer = __webpack_require__(734); var toString = Object.prototype.toString; /** @@ -85741,7 +85659,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 754 */ +/* 753 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85818,7 +85736,7 @@ function repeat(str, num) { /***/ }), -/* 755 */ +/* 754 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85831,8 +85749,8 @@ function repeat(str, num) { -var repeat = __webpack_require__(754); -var isNumber = __webpack_require__(752); +var repeat = __webpack_require__(753); +var isNumber = __webpack_require__(751); var cache = {}; function toRegexRange(min, max, options) { @@ -86119,7 +86037,7 @@ module.exports = toRegexRange; /***/ }), -/* 756 */ +/* 755 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86144,14 +86062,14 @@ module.exports = function repeat(ele, num) { /***/ }), -/* 757 */ +/* 756 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Node = __webpack_require__(758); -var utils = __webpack_require__(743); +var Node = __webpack_require__(757); +var utils = __webpack_require__(742); /** * Braces parsers @@ -86511,15 +86429,15 @@ function concatNodes(pos, node, parent, options) { /***/ }), -/* 758 */ +/* 757 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(748); -var define = __webpack_require__(759); -var utils = __webpack_require__(766); +var isObject = __webpack_require__(747); +var define = __webpack_require__(758); +var utils = __webpack_require__(765); var ownNames; /** @@ -87010,7 +86928,7 @@ exports = module.exports = Node; /***/ }), -/* 759 */ +/* 758 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87023,7 +86941,7 @@ exports = module.exports = Node; -var isDescriptor = __webpack_require__(760); +var isDescriptor = __webpack_require__(759); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -87048,7 +86966,7 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 760 */ +/* 759 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87061,9 +86979,9 @@ module.exports = function defineProperty(obj, prop, val) { -var typeOf = __webpack_require__(761); -var isAccessor = __webpack_require__(762); -var isData = __webpack_require__(764); +var typeOf = __webpack_require__(760); +var isAccessor = __webpack_require__(761); +var isData = __webpack_require__(763); module.exports = function isDescriptor(obj, key) { if (typeOf(obj) !== 'object') { @@ -87077,7 +86995,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 761 */ +/* 760 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -87212,7 +87130,7 @@ function isBuffer(val) { /***/ }), -/* 762 */ +/* 761 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87225,7 +87143,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(763); +var typeOf = __webpack_require__(762); // accessor descriptor properties var accessor = { @@ -87288,7 +87206,7 @@ module.exports = isAccessorDescriptor; /***/ }), -/* 763 */ +/* 762 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -87423,7 +87341,7 @@ function isBuffer(val) { /***/ }), -/* 764 */ +/* 763 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87436,7 +87354,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(765); +var typeOf = __webpack_require__(764); module.exports = function isDataDescriptor(obj, prop) { // data descriptor properties @@ -87479,7 +87397,7 @@ module.exports = function isDataDescriptor(obj, prop) { /***/ }), -/* 765 */ +/* 764 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -87614,13 +87532,13 @@ function isBuffer(val) { /***/ }), -/* 766 */ +/* 765 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var typeOf = __webpack_require__(753); +var typeOf = __webpack_require__(752); var utils = module.exports; /** @@ -88640,17 +88558,17 @@ function assert(val, message) { /***/ }), -/* 767 */ +/* 766 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extend = __webpack_require__(738); -var Snapdragon = __webpack_require__(768); -var compilers = __webpack_require__(742); -var parsers = __webpack_require__(757); -var utils = __webpack_require__(743); +var extend = __webpack_require__(737); +var Snapdragon = __webpack_require__(767); +var compilers = __webpack_require__(741); +var parsers = __webpack_require__(756); +var utils = __webpack_require__(742); /** * Customize Snapdragon parser and renderer @@ -88751,17 +88669,17 @@ module.exports = Braces; /***/ }), -/* 768 */ +/* 767 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Base = __webpack_require__(769); -var define = __webpack_require__(730); -var Compiler = __webpack_require__(798); -var Parser = __webpack_require__(827); -var utils = __webpack_require__(807); +var Base = __webpack_require__(768); +var define = __webpack_require__(729); +var Compiler = __webpack_require__(797); +var Parser = __webpack_require__(826); +var utils = __webpack_require__(806); var regexCache = {}; var cache = {}; @@ -88932,20 +88850,20 @@ module.exports.Parser = Parser; /***/ }), -/* 769 */ +/* 768 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(29); -var define = __webpack_require__(770); -var CacheBase = __webpack_require__(771); -var Emitter = __webpack_require__(772); -var isObject = __webpack_require__(748); -var merge = __webpack_require__(789); -var pascal = __webpack_require__(792); -var cu = __webpack_require__(793); +var define = __webpack_require__(769); +var CacheBase = __webpack_require__(770); +var Emitter = __webpack_require__(771); +var isObject = __webpack_require__(747); +var merge = __webpack_require__(788); +var pascal = __webpack_require__(791); +var cu = __webpack_require__(792); /** * Optionally define a custom `cache` namespace to use. @@ -89374,7 +89292,7 @@ module.exports.namespace = namespace; /***/ }), -/* 770 */ +/* 769 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -89387,7 +89305,7 @@ module.exports.namespace = namespace; -var isDescriptor = __webpack_require__(760); +var isDescriptor = __webpack_require__(759); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -89412,21 +89330,21 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 771 */ +/* 770 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(748); -var Emitter = __webpack_require__(772); -var visit = __webpack_require__(773); -var toPath = __webpack_require__(776); -var union = __webpack_require__(777); -var del = __webpack_require__(781); -var get = __webpack_require__(779); -var has = __webpack_require__(786); -var set = __webpack_require__(780); +var isObject = __webpack_require__(747); +var Emitter = __webpack_require__(771); +var visit = __webpack_require__(772); +var toPath = __webpack_require__(775); +var union = __webpack_require__(776); +var del = __webpack_require__(780); +var get = __webpack_require__(778); +var has = __webpack_require__(785); +var set = __webpack_require__(779); /** * Create a `Cache` constructor that when instantiated will @@ -89680,7 +89598,7 @@ module.exports.namespace = namespace; /***/ }), -/* 772 */ +/* 771 */ /***/ (function(module, exports, __webpack_require__) { @@ -89849,7 +89767,7 @@ Emitter.prototype.hasListeners = function(event){ /***/ }), -/* 773 */ +/* 772 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -89862,8 +89780,8 @@ Emitter.prototype.hasListeners = function(event){ -var visit = __webpack_require__(774); -var mapVisit = __webpack_require__(775); +var visit = __webpack_require__(773); +var mapVisit = __webpack_require__(774); module.exports = function(collection, method, val) { var result; @@ -89886,7 +89804,7 @@ module.exports = function(collection, method, val) { /***/ }), -/* 774 */ +/* 773 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -89899,7 +89817,7 @@ module.exports = function(collection, method, val) { -var isObject = __webpack_require__(748); +var isObject = __webpack_require__(747); module.exports = function visit(thisArg, method, target, val) { if (!isObject(thisArg) && typeof thisArg !== 'function') { @@ -89926,14 +89844,14 @@ module.exports = function visit(thisArg, method, target, val) { /***/ }), -/* 775 */ +/* 774 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(29); -var visit = __webpack_require__(774); +var visit = __webpack_require__(773); /** * Map `visit` over an array of objects. @@ -89970,7 +89888,7 @@ function isObject(val) { /***/ }), -/* 776 */ +/* 775 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -89983,7 +89901,7 @@ function isObject(val) { -var typeOf = __webpack_require__(753); +var typeOf = __webpack_require__(752); module.exports = function toPath(args) { if (typeOf(args) !== 'arguments') { @@ -90010,16 +89928,16 @@ function filter(arr) { /***/ }), -/* 777 */ +/* 776 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(739); -var union = __webpack_require__(778); -var get = __webpack_require__(779); -var set = __webpack_require__(780); +var isObject = __webpack_require__(738); +var union = __webpack_require__(777); +var get = __webpack_require__(778); +var set = __webpack_require__(779); module.exports = function unionValue(obj, prop, value) { if (!isObject(obj)) { @@ -90047,7 +89965,7 @@ function arrayify(val) { /***/ }), -/* 778 */ +/* 777 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90083,7 +90001,7 @@ module.exports = function union(init) { /***/ }), -/* 779 */ +/* 778 */ /***/ (function(module, exports) { /*! @@ -90139,7 +90057,7 @@ function toString(val) { /***/ }), -/* 780 */ +/* 779 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90152,10 +90070,10 @@ function toString(val) { -var split = __webpack_require__(744); -var extend = __webpack_require__(738); -var isPlainObject = __webpack_require__(747); -var isObject = __webpack_require__(739); +var split = __webpack_require__(743); +var extend = __webpack_require__(737); +var isPlainObject = __webpack_require__(746); +var isObject = __webpack_require__(738); module.exports = function(obj, prop, val) { if (!isObject(obj)) { @@ -90201,7 +90119,7 @@ function isValidKey(key) { /***/ }), -/* 781 */ +/* 780 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90214,8 +90132,8 @@ function isValidKey(key) { -var isObject = __webpack_require__(748); -var has = __webpack_require__(782); +var isObject = __webpack_require__(747); +var has = __webpack_require__(781); module.exports = function unset(obj, prop) { if (!isObject(obj)) { @@ -90240,7 +90158,7 @@ module.exports = function unset(obj, prop) { /***/ }), -/* 782 */ +/* 781 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90253,9 +90171,9 @@ module.exports = function unset(obj, prop) { -var isObject = __webpack_require__(783); -var hasValues = __webpack_require__(785); -var get = __webpack_require__(779); +var isObject = __webpack_require__(782); +var hasValues = __webpack_require__(784); +var get = __webpack_require__(778); module.exports = function(obj, prop, noZero) { if (isObject(obj)) { @@ -90266,7 +90184,7 @@ module.exports = function(obj, prop, noZero) { /***/ }), -/* 783 */ +/* 782 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90279,7 +90197,7 @@ module.exports = function(obj, prop, noZero) { -var isArray = __webpack_require__(784); +var isArray = __webpack_require__(783); module.exports = function isObject(val) { return val != null && typeof val === 'object' && isArray(val) === false; @@ -90287,7 +90205,7 @@ module.exports = function isObject(val) { /***/ }), -/* 784 */ +/* 783 */ /***/ (function(module, exports) { var toString = {}.toString; @@ -90298,7 +90216,7 @@ module.exports = Array.isArray || function (arr) { /***/ }), -/* 785 */ +/* 784 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90341,7 +90259,7 @@ module.exports = function hasValue(o, noZero) { /***/ }), -/* 786 */ +/* 785 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90354,9 +90272,9 @@ module.exports = function hasValue(o, noZero) { -var isObject = __webpack_require__(748); -var hasValues = __webpack_require__(787); -var get = __webpack_require__(779); +var isObject = __webpack_require__(747); +var hasValues = __webpack_require__(786); +var get = __webpack_require__(778); module.exports = function(val, prop) { return hasValues(isObject(val) && prop ? get(val, prop) : val); @@ -90364,7 +90282,7 @@ module.exports = function(val, prop) { /***/ }), -/* 787 */ +/* 786 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90377,8 +90295,8 @@ module.exports = function(val, prop) { -var typeOf = __webpack_require__(788); -var isNumber = __webpack_require__(752); +var typeOf = __webpack_require__(787); +var isNumber = __webpack_require__(751); module.exports = function hasValue(val) { // is-number checks for NaN and other edge cases @@ -90431,10 +90349,10 @@ module.exports = function hasValue(val) { /***/ }), -/* 788 */ +/* 787 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(735); +var isBuffer = __webpack_require__(734); var toString = Object.prototype.toString; /** @@ -90556,14 +90474,14 @@ module.exports = function kindOf(val) { /***/ }), -/* 789 */ +/* 788 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(790); -var forIn = __webpack_require__(791); +var isExtendable = __webpack_require__(789); +var forIn = __webpack_require__(790); function mixinDeep(target, objects) { var len = arguments.length, i = 0; @@ -90627,7 +90545,7 @@ module.exports = mixinDeep; /***/ }), -/* 790 */ +/* 789 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90640,7 +90558,7 @@ module.exports = mixinDeep; -var isPlainObject = __webpack_require__(747); +var isPlainObject = __webpack_require__(746); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -90648,7 +90566,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 791 */ +/* 790 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90671,7 +90589,7 @@ module.exports = function forIn(obj, fn, thisArg) { /***/ }), -/* 792 */ +/* 791 */ /***/ (function(module, exports) { /*! @@ -90698,14 +90616,14 @@ module.exports = pascalcase; /***/ }), -/* 793 */ +/* 792 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(29); -var utils = __webpack_require__(794); +var utils = __webpack_require__(793); /** * Expose class utils @@ -91070,7 +90988,7 @@ cu.bubble = function(Parent, events) { /***/ }), -/* 794 */ +/* 793 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -91084,10 +91002,10 @@ var utils = {}; * Lazily required module dependencies */ -utils.union = __webpack_require__(778); -utils.define = __webpack_require__(730); -utils.isObj = __webpack_require__(748); -utils.staticExtend = __webpack_require__(795); +utils.union = __webpack_require__(777); +utils.define = __webpack_require__(729); +utils.isObj = __webpack_require__(747); +utils.staticExtend = __webpack_require__(794); /** @@ -91098,7 +91016,7 @@ module.exports = utils; /***/ }), -/* 795 */ +/* 794 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -91111,8 +91029,8 @@ module.exports = utils; -var copy = __webpack_require__(796); -var define = __webpack_require__(730); +var copy = __webpack_require__(795); +var define = __webpack_require__(729); var util = __webpack_require__(29); /** @@ -91195,15 +91113,15 @@ module.exports = extend; /***/ }), -/* 796 */ +/* 795 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var typeOf = __webpack_require__(753); -var copyDescriptor = __webpack_require__(797); -var define = __webpack_require__(730); +var typeOf = __webpack_require__(752); +var copyDescriptor = __webpack_require__(796); +var define = __webpack_require__(729); /** * Copy static properties, prototype properties, and descriptors from one object to another. @@ -91376,7 +91294,7 @@ module.exports.has = has; /***/ }), -/* 797 */ +/* 796 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -91464,16 +91382,16 @@ function isObject(val) { /***/ }), -/* 798 */ +/* 797 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var use = __webpack_require__(799); -var define = __webpack_require__(730); -var debug = __webpack_require__(801)('snapdragon:compiler'); -var utils = __webpack_require__(807); +var use = __webpack_require__(798); +var define = __webpack_require__(729); +var debug = __webpack_require__(800)('snapdragon:compiler'); +var utils = __webpack_require__(806); /** * Create a new `Compiler` with the given `options`. @@ -91627,7 +91545,7 @@ Compiler.prototype = { // source map support if (opts.sourcemap) { - var sourcemaps = __webpack_require__(826); + var sourcemaps = __webpack_require__(825); sourcemaps(this); this.mapVisit(this.ast.nodes); this.applySourceMaps(); @@ -91648,7 +91566,7 @@ module.exports = Compiler; /***/ }), -/* 799 */ +/* 798 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -91661,7 +91579,7 @@ module.exports = Compiler; -var utils = __webpack_require__(800); +var utils = __webpack_require__(799); module.exports = function base(app, opts) { if (!utils.isObject(app) && typeof app !== 'function') { @@ -91776,7 +91694,7 @@ module.exports = function base(app, opts) { /***/ }), -/* 800 */ +/* 799 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -91790,8 +91708,8 @@ var utils = {}; * Lazily required module dependencies */ -utils.define = __webpack_require__(730); -utils.isObject = __webpack_require__(748); +utils.define = __webpack_require__(729); +utils.isObject = __webpack_require__(747); utils.isString = function(val) { @@ -91806,7 +91724,7 @@ module.exports = utils; /***/ }), -/* 801 */ +/* 800 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -91815,14 +91733,14 @@ module.exports = utils; */ if (typeof process !== 'undefined' && process.type === 'renderer') { - module.exports = __webpack_require__(802); + module.exports = __webpack_require__(801); } else { - module.exports = __webpack_require__(805); + module.exports = __webpack_require__(804); } /***/ }), -/* 802 */ +/* 801 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -91831,7 +91749,7 @@ if (typeof process !== 'undefined' && process.type === 'renderer') { * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(803); +exports = module.exports = __webpack_require__(802); exports.log = log; exports.formatArgs = formatArgs; exports.save = save; @@ -92013,7 +91931,7 @@ function localstorage() { /***/ }), -/* 803 */ +/* 802 */ /***/ (function(module, exports, __webpack_require__) { @@ -92029,7 +91947,7 @@ exports.coerce = coerce; exports.disable = disable; exports.enable = enable; exports.enabled = enabled; -exports.humanize = __webpack_require__(804); +exports.humanize = __webpack_require__(803); /** * The currently active debug mode names, and names to skip. @@ -92221,7 +92139,7 @@ function coerce(val) { /***/ }), -/* 804 */ +/* 803 */ /***/ (function(module, exports) { /** @@ -92379,7 +92297,7 @@ function plural(ms, n, name) { /***/ }), -/* 805 */ +/* 804 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -92395,7 +92313,7 @@ var util = __webpack_require__(29); * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(803); +exports = module.exports = __webpack_require__(802); exports.init = init; exports.log = log; exports.formatArgs = formatArgs; @@ -92574,7 +92492,7 @@ function createWritableStdioStream (fd) { case 'PIPE': case 'TCP': - var net = __webpack_require__(806); + var net = __webpack_require__(805); stream = new net.Socket({ fd: fd, readable: false, @@ -92633,13 +92551,13 @@ exports.enable(load()); /***/ }), -/* 806 */ +/* 805 */ /***/ (function(module, exports) { module.exports = require("net"); /***/ }), -/* 807 */ +/* 806 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -92649,9 +92567,9 @@ module.exports = require("net"); * Module dependencies */ -exports.extend = __webpack_require__(738); -exports.SourceMap = __webpack_require__(808); -exports.sourceMapResolve = __webpack_require__(819); +exports.extend = __webpack_require__(737); +exports.SourceMap = __webpack_require__(807); +exports.sourceMapResolve = __webpack_require__(818); /** * Convert backslash in the given string to forward slashes @@ -92694,7 +92612,7 @@ exports.last = function(arr, n) { /***/ }), -/* 808 */ +/* 807 */ /***/ (function(module, exports, __webpack_require__) { /* @@ -92702,13 +92620,13 @@ exports.last = function(arr, n) { * Licensed under the New BSD license. See LICENSE.txt or: * http://opensource.org/licenses/BSD-3-Clause */ -exports.SourceMapGenerator = __webpack_require__(809).SourceMapGenerator; -exports.SourceMapConsumer = __webpack_require__(815).SourceMapConsumer; -exports.SourceNode = __webpack_require__(818).SourceNode; +exports.SourceMapGenerator = __webpack_require__(808).SourceMapGenerator; +exports.SourceMapConsumer = __webpack_require__(814).SourceMapConsumer; +exports.SourceNode = __webpack_require__(817).SourceNode; /***/ }), -/* 809 */ +/* 808 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -92718,10 +92636,10 @@ exports.SourceNode = __webpack_require__(818).SourceNode; * http://opensource.org/licenses/BSD-3-Clause */ -var base64VLQ = __webpack_require__(810); -var util = __webpack_require__(812); -var ArraySet = __webpack_require__(813).ArraySet; -var MappingList = __webpack_require__(814).MappingList; +var base64VLQ = __webpack_require__(809); +var util = __webpack_require__(811); +var ArraySet = __webpack_require__(812).ArraySet; +var MappingList = __webpack_require__(813).MappingList; /** * An instance of the SourceMapGenerator represents a source map which is @@ -93130,7 +93048,7 @@ exports.SourceMapGenerator = SourceMapGenerator; /***/ }), -/* 810 */ +/* 809 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -93170,7 +93088,7 @@ exports.SourceMapGenerator = SourceMapGenerator; * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -var base64 = __webpack_require__(811); +var base64 = __webpack_require__(810); // A single base 64 digit can contain 6 bits of data. For the base 64 variable // length quantities we use in the source map spec, the first bit is the sign, @@ -93276,7 +93194,7 @@ exports.decode = function base64VLQ_decode(aStr, aIndex, aOutParam) { /***/ }), -/* 811 */ +/* 810 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -93349,7 +93267,7 @@ exports.decode = function (charCode) { /***/ }), -/* 812 */ +/* 811 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -93772,7 +93690,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate /***/ }), -/* 813 */ +/* 812 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -93782,7 +93700,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(812); +var util = __webpack_require__(811); var has = Object.prototype.hasOwnProperty; var hasNativeMap = typeof Map !== "undefined"; @@ -93899,7 +93817,7 @@ exports.ArraySet = ArraySet; /***/ }), -/* 814 */ +/* 813 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -93909,7 +93827,7 @@ exports.ArraySet = ArraySet; * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(812); +var util = __webpack_require__(811); /** * Determine whether mappingB is after mappingA with respect to generated @@ -93984,7 +93902,7 @@ exports.MappingList = MappingList; /***/ }), -/* 815 */ +/* 814 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -93994,11 +93912,11 @@ exports.MappingList = MappingList; * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(812); -var binarySearch = __webpack_require__(816); -var ArraySet = __webpack_require__(813).ArraySet; -var base64VLQ = __webpack_require__(810); -var quickSort = __webpack_require__(817).quickSort; +var util = __webpack_require__(811); +var binarySearch = __webpack_require__(815); +var ArraySet = __webpack_require__(812).ArraySet; +var base64VLQ = __webpack_require__(809); +var quickSort = __webpack_require__(816).quickSort; function SourceMapConsumer(aSourceMap) { var sourceMap = aSourceMap; @@ -95072,7 +94990,7 @@ exports.IndexedSourceMapConsumer = IndexedSourceMapConsumer; /***/ }), -/* 816 */ +/* 815 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -95189,7 +95107,7 @@ exports.search = function search(aNeedle, aHaystack, aCompare, aBias) { /***/ }), -/* 817 */ +/* 816 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -95309,7 +95227,7 @@ exports.quickSort = function (ary, comparator) { /***/ }), -/* 818 */ +/* 817 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -95319,8 +95237,8 @@ exports.quickSort = function (ary, comparator) { * http://opensource.org/licenses/BSD-3-Clause */ -var SourceMapGenerator = __webpack_require__(809).SourceMapGenerator; -var util = __webpack_require__(812); +var SourceMapGenerator = __webpack_require__(808).SourceMapGenerator; +var util = __webpack_require__(811); // Matches a Windows-style `\r\n` newline or a `\n` newline used by all other // operating systems these days (capturing the result). @@ -95728,17 +95646,17 @@ exports.SourceNode = SourceNode; /***/ }), -/* 819 */ +/* 818 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014, 2015, 2016, 2017 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var sourceMappingURL = __webpack_require__(820) -var resolveUrl = __webpack_require__(821) -var decodeUriComponent = __webpack_require__(822) -var urix = __webpack_require__(824) -var atob = __webpack_require__(825) +var sourceMappingURL = __webpack_require__(819) +var resolveUrl = __webpack_require__(820) +var decodeUriComponent = __webpack_require__(821) +var urix = __webpack_require__(823) +var atob = __webpack_require__(824) @@ -96036,7 +95954,7 @@ module.exports = { /***/ }), -/* 820 */ +/* 819 */ /***/ (function(module, exports, __webpack_require__) { var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_RESULT__;// Copyright 2014 Simon Lydell @@ -96099,7 +96017,7 @@ void (function(root, factory) { /***/ }), -/* 821 */ +/* 820 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -96117,13 +96035,13 @@ module.exports = resolveUrl /***/ }), -/* 822 */ +/* 821 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2017 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var decodeUriComponent = __webpack_require__(823) +var decodeUriComponent = __webpack_require__(822) function customDecodeUriComponent(string) { // `decodeUriComponent` turns `+` into ` `, but that's not wanted. @@ -96134,7 +96052,7 @@ module.exports = customDecodeUriComponent /***/ }), -/* 823 */ +/* 822 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -96235,7 +96153,7 @@ module.exports = function (encodedURI) { /***/ }), -/* 824 */ +/* 823 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -96258,7 +96176,7 @@ module.exports = urix /***/ }), -/* 825 */ +/* 824 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -96272,7 +96190,7 @@ module.exports = atob.atob = atob; /***/ }), -/* 826 */ +/* 825 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -96280,8 +96198,8 @@ module.exports = atob.atob = atob; var fs = __webpack_require__(23); var path = __webpack_require__(16); -var define = __webpack_require__(730); -var utils = __webpack_require__(807); +var define = __webpack_require__(729); +var utils = __webpack_require__(806); /** * Expose `mixin()`. @@ -96424,19 +96342,19 @@ exports.comment = function(node) { /***/ }), -/* 827 */ +/* 826 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var use = __webpack_require__(799); +var use = __webpack_require__(798); var util = __webpack_require__(29); -var Cache = __webpack_require__(828); -var define = __webpack_require__(730); -var debug = __webpack_require__(801)('snapdragon:parser'); -var Position = __webpack_require__(829); -var utils = __webpack_require__(807); +var Cache = __webpack_require__(827); +var define = __webpack_require__(729); +var debug = __webpack_require__(800)('snapdragon:parser'); +var Position = __webpack_require__(828); +var utils = __webpack_require__(806); /** * Create a new `Parser` with the given `input` and `options`. @@ -96964,7 +96882,7 @@ module.exports = Parser; /***/ }), -/* 828 */ +/* 827 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -97071,13 +96989,13 @@ MapCache.prototype.del = function mapDelete(key) { /***/ }), -/* 829 */ +/* 828 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var define = __webpack_require__(730); +var define = __webpack_require__(729); /** * Store position for a node @@ -97092,16 +97010,16 @@ module.exports = function Position(start, parser) { /***/ }), -/* 830 */ +/* 829 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var safe = __webpack_require__(831); -var define = __webpack_require__(837); -var extend = __webpack_require__(838); -var not = __webpack_require__(840); +var safe = __webpack_require__(830); +var define = __webpack_require__(836); +var extend = __webpack_require__(837); +var not = __webpack_require__(839); var MAX_LENGTH = 1024 * 64; /** @@ -97254,10 +97172,10 @@ module.exports.makeRe = makeRe; /***/ }), -/* 831 */ +/* 830 */ /***/ (function(module, exports, __webpack_require__) { -var parse = __webpack_require__(832); +var parse = __webpack_require__(831); var types = parse.types; module.exports = function (re, opts) { @@ -97303,13 +97221,13 @@ function isRegExp (x) { /***/ }), -/* 832 */ +/* 831 */ /***/ (function(module, exports, __webpack_require__) { -var util = __webpack_require__(833); -var types = __webpack_require__(834); -var sets = __webpack_require__(835); -var positions = __webpack_require__(836); +var util = __webpack_require__(832); +var types = __webpack_require__(833); +var sets = __webpack_require__(834); +var positions = __webpack_require__(835); module.exports = function(regexpStr) { @@ -97591,11 +97509,11 @@ module.exports.types = types; /***/ }), -/* 833 */ +/* 832 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(834); -var sets = __webpack_require__(835); +var types = __webpack_require__(833); +var sets = __webpack_require__(834); // All of these are private and only used by randexp. @@ -97708,7 +97626,7 @@ exports.error = function(regexp, msg) { /***/ }), -/* 834 */ +/* 833 */ /***/ (function(module, exports) { module.exports = { @@ -97724,10 +97642,10 @@ module.exports = { /***/ }), -/* 835 */ +/* 834 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(834); +var types = __webpack_require__(833); var INTS = function() { return [{ type: types.RANGE , from: 48, to: 57 }]; @@ -97812,10 +97730,10 @@ exports.anyChar = function() { /***/ }), -/* 836 */ +/* 835 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(834); +var types = __webpack_require__(833); exports.wordBoundary = function() { return { type: types.POSITION, value: 'b' }; @@ -97835,7 +97753,7 @@ exports.end = function() { /***/ }), -/* 837 */ +/* 836 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -97848,8 +97766,8 @@ exports.end = function() { -var isobject = __webpack_require__(748); -var isDescriptor = __webpack_require__(760); +var isobject = __webpack_require__(747); +var isDescriptor = __webpack_require__(759); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -97880,14 +97798,14 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 838 */ +/* 837 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(839); -var assignSymbols = __webpack_require__(749); +var isExtendable = __webpack_require__(838); +var assignSymbols = __webpack_require__(748); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -97947,7 +97865,7 @@ function isEnum(obj, key) { /***/ }), -/* 839 */ +/* 838 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -97960,7 +97878,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(747); +var isPlainObject = __webpack_require__(746); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -97968,14 +97886,14 @@ module.exports = function isExtendable(val) { /***/ }), -/* 840 */ +/* 839 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extend = __webpack_require__(838); -var safe = __webpack_require__(831); +var extend = __webpack_require__(837); +var safe = __webpack_require__(830); /** * The main export is a function that takes a `pattern` string and an `options` object. @@ -98047,14 +97965,14 @@ module.exports = toRegex; /***/ }), -/* 841 */ +/* 840 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var nanomatch = __webpack_require__(842); -var extglob = __webpack_require__(857); +var nanomatch = __webpack_require__(841); +var extglob = __webpack_require__(856); module.exports = function(snapdragon) { var compilers = snapdragon.compiler.compilers; @@ -98131,7 +98049,7 @@ function escapeExtglobs(compiler) { /***/ }), -/* 842 */ +/* 841 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -98142,17 +98060,17 @@ function escapeExtglobs(compiler) { */ var util = __webpack_require__(29); -var toRegex = __webpack_require__(729); -var extend = __webpack_require__(843); +var toRegex = __webpack_require__(728); +var extend = __webpack_require__(842); /** * Local dependencies */ -var compilers = __webpack_require__(845); -var parsers = __webpack_require__(846); -var cache = __webpack_require__(849); -var utils = __webpack_require__(851); +var compilers = __webpack_require__(844); +var parsers = __webpack_require__(845); +var cache = __webpack_require__(848); +var utils = __webpack_require__(850); var MAX_LENGTH = 1024 * 64; /** @@ -98976,14 +98894,14 @@ module.exports = nanomatch; /***/ }), -/* 843 */ +/* 842 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(844); -var assignSymbols = __webpack_require__(749); +var isExtendable = __webpack_require__(843); +var assignSymbols = __webpack_require__(748); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -99043,7 +98961,7 @@ function isEnum(obj, key) { /***/ }), -/* 844 */ +/* 843 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -99056,7 +98974,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(747); +var isPlainObject = __webpack_require__(746); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -99064,7 +98982,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 845 */ +/* 844 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -99410,15 +99328,15 @@ module.exports = function(nanomatch, options) { /***/ }), -/* 846 */ +/* 845 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var regexNot = __webpack_require__(740); -var toRegex = __webpack_require__(729); -var isOdd = __webpack_require__(847); +var regexNot = __webpack_require__(739); +var toRegex = __webpack_require__(728); +var isOdd = __webpack_require__(846); /** * Characters to use in negation regex (we want to "not" match @@ -99804,7 +99722,7 @@ module.exports.not = NOT_REGEX; /***/ }), -/* 847 */ +/* 846 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -99817,7 +99735,7 @@ module.exports.not = NOT_REGEX; -var isNumber = __webpack_require__(848); +var isNumber = __webpack_require__(847); module.exports = function isOdd(i) { if (!isNumber(i)) { @@ -99831,7 +99749,7 @@ module.exports = function isOdd(i) { /***/ }), -/* 848 */ +/* 847 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -99859,14 +99777,14 @@ module.exports = function isNumber(num) { /***/ }), -/* 849 */ +/* 848 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = new (__webpack_require__(850))(); +module.exports = new (__webpack_require__(849))(); /***/ }), -/* 850 */ +/* 849 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -99879,7 +99797,7 @@ module.exports = new (__webpack_require__(850))(); -var MapCache = __webpack_require__(828); +var MapCache = __webpack_require__(827); /** * Create a new `FragmentCache` with an optional object to use for `caches`. @@ -100001,7 +99919,7 @@ exports = module.exports = FragmentCache; /***/ }), -/* 851 */ +/* 850 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -100014,14 +99932,14 @@ var path = __webpack_require__(16); * Module dependencies */ -var isWindows = __webpack_require__(852)(); -var Snapdragon = __webpack_require__(768); -utils.define = __webpack_require__(853); -utils.diff = __webpack_require__(854); -utils.extend = __webpack_require__(843); -utils.pick = __webpack_require__(855); -utils.typeOf = __webpack_require__(856); -utils.unique = __webpack_require__(741); +var isWindows = __webpack_require__(851)(); +var Snapdragon = __webpack_require__(767); +utils.define = __webpack_require__(852); +utils.diff = __webpack_require__(853); +utils.extend = __webpack_require__(842); +utils.pick = __webpack_require__(854); +utils.typeOf = __webpack_require__(855); +utils.unique = __webpack_require__(740); /** * Returns true if the given value is effectively an empty string @@ -100387,7 +100305,7 @@ utils.unixify = function(options) { /***/ }), -/* 852 */ +/* 851 */ /***/ (function(module, exports, __webpack_require__) { var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_DEFINE_RESULT__;/*! @@ -100415,7 +100333,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_ /***/ }), -/* 853 */ +/* 852 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -100428,8 +100346,8 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_ -var isobject = __webpack_require__(748); -var isDescriptor = __webpack_require__(760); +var isobject = __webpack_require__(747); +var isDescriptor = __webpack_require__(759); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -100460,7 +100378,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 854 */ +/* 853 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -100514,7 +100432,7 @@ function diffArray(one, two) { /***/ }), -/* 855 */ +/* 854 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -100527,7 +100445,7 @@ function diffArray(one, two) { -var isObject = __webpack_require__(748); +var isObject = __webpack_require__(747); module.exports = function pick(obj, keys) { if (!isObject(obj) && typeof obj !== 'function') { @@ -100556,7 +100474,7 @@ module.exports = function pick(obj, keys) { /***/ }), -/* 856 */ +/* 855 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -100691,7 +100609,7 @@ function isBuffer(val) { /***/ }), -/* 857 */ +/* 856 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -100701,18 +100619,18 @@ function isBuffer(val) { * Module dependencies */ -var extend = __webpack_require__(738); -var unique = __webpack_require__(741); -var toRegex = __webpack_require__(729); +var extend = __webpack_require__(737); +var unique = __webpack_require__(740); +var toRegex = __webpack_require__(728); /** * Local dependencies */ -var compilers = __webpack_require__(858); -var parsers = __webpack_require__(864); -var Extglob = __webpack_require__(867); -var utils = __webpack_require__(866); +var compilers = __webpack_require__(857); +var parsers = __webpack_require__(868); +var Extglob = __webpack_require__(871); +var utils = __webpack_require__(870); var MAX_LENGTH = 1024 * 64; /** @@ -101029,13 +100947,13 @@ module.exports = extglob; /***/ }), -/* 858 */ +/* 857 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var brackets = __webpack_require__(859); +var brackets = __webpack_require__(858); /** * Extglob compilers @@ -101205,7 +101123,7 @@ module.exports = function(extglob) { /***/ }), -/* 859 */ +/* 858 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -101215,17 +101133,17 @@ module.exports = function(extglob) { * Local dependencies */ -var compilers = __webpack_require__(860); -var parsers = __webpack_require__(862); +var compilers = __webpack_require__(859); +var parsers = __webpack_require__(861); /** * Module dependencies */ -var debug = __webpack_require__(801)('expand-brackets'); -var extend = __webpack_require__(738); -var Snapdragon = __webpack_require__(768); -var toRegex = __webpack_require__(729); +var debug = __webpack_require__(863)('expand-brackets'); +var extend = __webpack_require__(737); +var Snapdragon = __webpack_require__(767); +var toRegex = __webpack_require__(728); /** * Parses the given POSIX character class `pattern` and returns a @@ -101423,13 +101341,13 @@ module.exports = brackets; /***/ }), -/* 860 */ +/* 859 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var posix = __webpack_require__(861); +var posix = __webpack_require__(860); module.exports = function(brackets) { brackets.compiler @@ -101517,7 +101435,7 @@ module.exports = function(brackets) { /***/ }), -/* 861 */ +/* 860 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -101546,14 +101464,14 @@ module.exports = { /***/ }), -/* 862 */ +/* 861 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(863); -var define = __webpack_require__(730); +var utils = __webpack_require__(862); +var define = __webpack_require__(729); /** * Text regex @@ -101772,14 +101690,14 @@ module.exports.TEXT_REGEX = TEXT_REGEX; /***/ }), -/* 863 */ +/* 862 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var toRegex = __webpack_require__(729); -var regexNot = __webpack_require__(740); +var toRegex = __webpack_require__(728); +var regexNot = __webpack_require__(739); var cached; /** @@ -101813,195 +101731,1022 @@ exports.createRegex = function(pattern, include) { /***/ }), -/* 864 */ +/* 863 */ /***/ (function(module, exports, __webpack_require__) { -"use strict"; +/** + * Detect Electron renderer process, which is node, but we should + * treat as a browser. + */ + +if (typeof process !== 'undefined' && process.type === 'renderer') { + module.exports = __webpack_require__(864); +} else { + module.exports = __webpack_require__(867); +} -var brackets = __webpack_require__(859); -var define = __webpack_require__(865); -var utils = __webpack_require__(866); +/***/ }), +/* 864 */ +/***/ (function(module, exports, __webpack_require__) { /** - * Characters to use in text regex (we want to "not" match - * characters that are matched by other parsers) + * This is the web browser implementation of `debug()`. + * + * Expose `debug()` as the module. */ -var TEXT_REGEX = '([!@*?+]?\\(|\\)|[*?.+\\\\]|\\[:?(?=.*\\])|:?\\])+'; -var not = utils.createRegex(TEXT_REGEX); +exports = module.exports = __webpack_require__(865); +exports.log = log; +exports.formatArgs = formatArgs; +exports.save = save; +exports.load = load; +exports.useColors = useColors; +exports.storage = 'undefined' != typeof chrome + && 'undefined' != typeof chrome.storage + ? chrome.storage.local + : localstorage(); /** - * Extglob parsers + * Colors. */ -function parsers(extglob) { - extglob.state = extglob.state || {}; - - /** - * Use `expand-brackets` parsers - */ +exports.colors = [ + 'lightseagreen', + 'forestgreen', + 'goldenrod', + 'dodgerblue', + 'darkorchid', + 'crimson' +]; - extglob.use(brackets.parsers); - extglob.parser.sets.paren = extglob.parser.sets.paren || []; - extglob.parser +/** + * Currently only WebKit-based Web Inspectors, Firefox >= v31, + * and the Firebug extension (any Firefox version) are known + * to support "%c" CSS customizations. + * + * TODO: add a `localStorage` variable to explicitly enable/disable colors + */ - /** - * Extglob open: "*(" - */ +function useColors() { + // NB: In an Electron preload script, document will be defined but not fully + // initialized. Since we know we're in Chrome, we'll just detect this case + // explicitly + if (typeof window !== 'undefined' && window.process && window.process.type === 'renderer') { + return true; + } - .capture('paren.open', function() { - var parsed = this.parsed; - var pos = this.position(); - var m = this.match(/^([!@*?+])?\(/); - if (!m) return; + // is webkit? http://stackoverflow.com/a/16459606/376773 + // document is undefined in react-native: https://github.com/facebook/react-native/pull/1632 + return (typeof document !== 'undefined' && document.documentElement && document.documentElement.style && document.documentElement.style.WebkitAppearance) || + // is firebug? http://stackoverflow.com/a/398120/376773 + (typeof window !== 'undefined' && window.console && (window.console.firebug || (window.console.exception && window.console.table))) || + // is firefox >= v31? + // https://developer.mozilla.org/en-US/docs/Tools/Web_Console#Styling_messages + (typeof navigator !== 'undefined' && navigator.userAgent && navigator.userAgent.toLowerCase().match(/firefox\/(\d+)/) && parseInt(RegExp.$1, 10) >= 31) || + // double check webkit in userAgent just in case we are in a worker + (typeof navigator !== 'undefined' && navigator.userAgent && navigator.userAgent.toLowerCase().match(/applewebkit\/(\d+)/)); +} - var prev = this.prev(); - var prefix = m[1]; - var val = m[0]; +/** + * Map %j to `JSON.stringify()`, since no Web Inspectors do that by default. + */ - var open = pos({ - type: 'paren.open', - parsed: parsed, - val: val - }); +exports.formatters.j = function(v) { + try { + return JSON.stringify(v); + } catch (err) { + return '[UnexpectedJSONParseError]: ' + err.message; + } +}; - var node = pos({ - type: 'paren', - prefix: prefix, - nodes: [open] - }); - // if nested negation extglobs, just cancel them out to simplify - if (prefix === '!' && prev.type === 'paren' && prev.prefix === '!') { - prev.prefix = '@'; - node.prefix = '@'; - } +/** + * Colorize log arguments if enabled. + * + * @api public + */ - define(node, 'rest', this.input); - define(node, 'parsed', parsed); - define(node, 'parent', prev); - define(open, 'parent', node); +function formatArgs(args) { + var useColors = this.useColors; - this.push('paren', node); - prev.nodes.push(node); - }) + args[0] = (useColors ? '%c' : '') + + this.namespace + + (useColors ? ' %c' : ' ') + + args[0] + + (useColors ? '%c ' : ' ') + + '+' + exports.humanize(this.diff); - /** - * Extglob close: ")" - */ + if (!useColors) return; - .capture('paren.close', function() { - var parsed = this.parsed; - var pos = this.position(); - var m = this.match(/^\)/); - if (!m) return; + var c = 'color: ' + this.color; + args.splice(1, 0, c, 'color: inherit') - var parent = this.pop('paren'); - var node = pos({ - type: 'paren.close', - rest: this.input, - parsed: parsed, - val: m[0] - }); + // the final "%c" is somewhat tricky, because there could be other + // arguments passed either before or after the %c, so we need to + // figure out the correct index to insert the CSS into + var index = 0; + var lastC = 0; + args[0].replace(/%[a-zA-Z%]/g, function(match) { + if ('%%' === match) return; + index++; + if ('%c' === match) { + // we only are interested in the *last* %c + // (the user may have provided their own) + lastC = index; + } + }); - if (!this.isType(parent, 'paren')) { - if (this.options.strict) { - throw new Error('missing opening paren: "("'); - } - node.escaped = true; - return node; - } + args.splice(lastC, 0, c); +} - node.prefix = parent.prefix; - parent.nodes.push(node); - define(node, 'parent', parent); - }) +/** + * Invokes `console.log()` when available. + * No-op when `console.log` is not a "function". + * + * @api public + */ - /** - * Escape: "\\." - */ +function log() { + // this hackery is required for IE8/9, where + // the `console.log` function doesn't have 'apply' + return 'object' === typeof console + && console.log + && Function.prototype.apply.call(console.log, console, arguments); +} - .capture('escape', function() { - var pos = this.position(); - var m = this.match(/^\\(.)/); - if (!m) return; +/** + * Save `namespaces`. + * + * @param {String} namespaces + * @api private + */ - return pos({ - type: 'escape', - val: m[0], - ch: m[1] - }); - }) +function save(namespaces) { + try { + if (null == namespaces) { + exports.storage.removeItem('debug'); + } else { + exports.storage.debug = namespaces; + } + } catch(e) {} +} - /** - * Question marks: "?" - */ +/** + * Load `namespaces`. + * + * @return {String} returns the previously persisted debug modes + * @api private + */ - .capture('qmark', function() { - var parsed = this.parsed; - var pos = this.position(); - var m = this.match(/^\?+(?!\()/); - if (!m) return; - extglob.state.metachar = true; - return pos({ - type: 'qmark', - rest: this.input, - parsed: parsed, - val: m[0] - }); - }) +function load() { + var r; + try { + r = exports.storage.debug; + } catch(e) {} - /** - * Character parsers - */ + // If debug isn't set in LS, and we're in Electron, try to load $DEBUG + if (!r && typeof process !== 'undefined' && 'env' in process) { + r = process.env.DEBUG; + } - .capture('star', /^\*(?!\()/) - .capture('plus', /^\+(?!\()/) - .capture('dot', /^\./) - .capture('text', not); -}; + return r; +} /** - * Expose text regex string + * Enable namespaces listed in `localStorage.debug` initially. */ -module.exports.TEXT_REGEX = TEXT_REGEX; +exports.enable(load()); /** - * Extglob parsers + * Localstorage attempts to return the localstorage. + * + * This is necessary because safari throws + * when a user disables cookies/localstorage + * and you attempt to access it. + * + * @return {LocalStorage} + * @api private */ -module.exports = parsers; +function localstorage() { + try { + return window.localStorage; + } catch (e) {} +} /***/ }), /* 865 */ /***/ (function(module, exports, __webpack_require__) { -"use strict"; -/*! - * define-property + +/** + * This is the common logic for both the Node.js and web browser + * implementations of `debug()`. * - * Copyright (c) 2015, 2017, Jon Schlinkert. - * Released under the MIT License. + * Expose `debug()` as the module. */ +exports = module.exports = createDebug.debug = createDebug['default'] = createDebug; +exports.coerce = coerce; +exports.disable = disable; +exports.enable = enable; +exports.enabled = enabled; +exports.humanize = __webpack_require__(866); +/** + * The currently active debug mode names, and names to skip. + */ -var isDescriptor = __webpack_require__(760); +exports.names = []; +exports.skips = []; -module.exports = function defineProperty(obj, prop, val) { - if (typeof obj !== 'object' && typeof obj !== 'function') { - throw new TypeError('expected an object or function.'); - } +/** + * Map of special "%n" handling functions, for the debug "format" argument. + * + * Valid key names are a single, lower or upper-case letter, i.e. "n" and "N". + */ - if (typeof prop !== 'string') { - throw new TypeError('expected `prop` to be a string.'); - } +exports.formatters = {}; - if (isDescriptor(val) && ('set' in val || 'get' in val)) { - return Object.defineProperty(obj, prop, val); +/** + * Previous log timestamp. + */ + +var prevTime; + +/** + * Select a color. + * @param {String} namespace + * @return {Number} + * @api private + */ + +function selectColor(namespace) { + var hash = 0, i; + + for (i in namespace) { + hash = ((hash << 5) - hash) + namespace.charCodeAt(i); + hash |= 0; // Convert to 32bit integer + } + + return exports.colors[Math.abs(hash) % exports.colors.length]; +} + +/** + * Create a debugger with the given `namespace`. + * + * @param {String} namespace + * @return {Function} + * @api public + */ + +function createDebug(namespace) { + + function debug() { + // disabled? + if (!debug.enabled) return; + + var self = debug; + + // set `diff` timestamp + var curr = +new Date(); + var ms = curr - (prevTime || curr); + self.diff = ms; + self.prev = prevTime; + self.curr = curr; + prevTime = curr; + + // turn the `arguments` into a proper Array + var args = new Array(arguments.length); + for (var i = 0; i < args.length; i++) { + args[i] = arguments[i]; + } + + args[0] = exports.coerce(args[0]); + + if ('string' !== typeof args[0]) { + // anything else let's inspect with %O + args.unshift('%O'); + } + + // apply any `formatters` transformations + var index = 0; + args[0] = args[0].replace(/%([a-zA-Z%])/g, function(match, format) { + // if we encounter an escaped % then don't increase the array index + if (match === '%%') return match; + index++; + var formatter = exports.formatters[format]; + if ('function' === typeof formatter) { + var val = args[index]; + match = formatter.call(self, val); + + // now we need to remove `args[index]` since it's inlined in the `format` + args.splice(index, 1); + index--; + } + return match; + }); + + // apply env-specific formatting (colors, etc.) + exports.formatArgs.call(self, args); + + var logFn = debug.log || exports.log || console.log.bind(console); + logFn.apply(self, args); + } + + debug.namespace = namespace; + debug.enabled = exports.enabled(namespace); + debug.useColors = exports.useColors(); + debug.color = selectColor(namespace); + + // env-specific initialization logic for debug instances + if ('function' === typeof exports.init) { + exports.init(debug); + } + + return debug; +} + +/** + * Enables a debug mode by namespaces. This can include modes + * separated by a colon and wildcards. + * + * @param {String} namespaces + * @api public + */ + +function enable(namespaces) { + exports.save(namespaces); + + exports.names = []; + exports.skips = []; + + var split = (typeof namespaces === 'string' ? namespaces : '').split(/[\s,]+/); + var len = split.length; + + for (var i = 0; i < len; i++) { + if (!split[i]) continue; // ignore empty strings + namespaces = split[i].replace(/\*/g, '.*?'); + if (namespaces[0] === '-') { + exports.skips.push(new RegExp('^' + namespaces.substr(1) + '$')); + } else { + exports.names.push(new RegExp('^' + namespaces + '$')); + } + } +} + +/** + * Disable debug output. + * + * @api public + */ + +function disable() { + exports.enable(''); +} + +/** + * Returns true if the given mode name is enabled, false otherwise. + * + * @param {String} name + * @return {Boolean} + * @api public + */ + +function enabled(name) { + var i, len; + for (i = 0, len = exports.skips.length; i < len; i++) { + if (exports.skips[i].test(name)) { + return false; + } + } + for (i = 0, len = exports.names.length; i < len; i++) { + if (exports.names[i].test(name)) { + return true; + } + } + return false; +} + +/** + * Coerce `val`. + * + * @param {Mixed} val + * @return {Mixed} + * @api private + */ + +function coerce(val) { + if (val instanceof Error) return val.stack || val.message; + return val; +} + + +/***/ }), +/* 866 */ +/***/ (function(module, exports) { + +/** + * Helpers. + */ + +var s = 1000; +var m = s * 60; +var h = m * 60; +var d = h * 24; +var y = d * 365.25; + +/** + * Parse or format the given `val`. + * + * Options: + * + * - `long` verbose formatting [false] + * + * @param {String|Number} val + * @param {Object} [options] + * @throws {Error} throw an error if val is not a non-empty string or a number + * @return {String|Number} + * @api public + */ + +module.exports = function(val, options) { + options = options || {}; + var type = typeof val; + if (type === 'string' && val.length > 0) { + return parse(val); + } else if (type === 'number' && isNaN(val) === false) { + return options.long ? fmtLong(val) : fmtShort(val); + } + throw new Error( + 'val is not a non-empty string or a valid number. val=' + + JSON.stringify(val) + ); +}; + +/** + * Parse the given `str` and return milliseconds. + * + * @param {String} str + * @return {Number} + * @api private + */ + +function parse(str) { + str = String(str); + if (str.length > 100) { + return; + } + var match = /^((?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|years?|yrs?|y)?$/i.exec( + str + ); + if (!match) { + return; + } + var n = parseFloat(match[1]); + var type = (match[2] || 'ms').toLowerCase(); + switch (type) { + case 'years': + case 'year': + case 'yrs': + case 'yr': + case 'y': + return n * y; + case 'days': + case 'day': + case 'd': + return n * d; + case 'hours': + case 'hour': + case 'hrs': + case 'hr': + case 'h': + return n * h; + case 'minutes': + case 'minute': + case 'mins': + case 'min': + case 'm': + return n * m; + case 'seconds': + case 'second': + case 'secs': + case 'sec': + case 's': + return n * s; + case 'milliseconds': + case 'millisecond': + case 'msecs': + case 'msec': + case 'ms': + return n; + default: + return undefined; + } +} + +/** + * Short format for `ms`. + * + * @param {Number} ms + * @return {String} + * @api private + */ + +function fmtShort(ms) { + if (ms >= d) { + return Math.round(ms / d) + 'd'; + } + if (ms >= h) { + return Math.round(ms / h) + 'h'; + } + if (ms >= m) { + return Math.round(ms / m) + 'm'; + } + if (ms >= s) { + return Math.round(ms / s) + 's'; + } + return ms + 'ms'; +} + +/** + * Long format for `ms`. + * + * @param {Number} ms + * @return {String} + * @api private + */ + +function fmtLong(ms) { + return plural(ms, d, 'day') || + plural(ms, h, 'hour') || + plural(ms, m, 'minute') || + plural(ms, s, 'second') || + ms + ' ms'; +} + +/** + * Pluralization helper. + */ + +function plural(ms, n, name) { + if (ms < n) { + return; + } + if (ms < n * 1.5) { + return Math.floor(ms / n) + ' ' + name; + } + return Math.ceil(ms / n) + ' ' + name + 's'; +} + + +/***/ }), +/* 867 */ +/***/ (function(module, exports, __webpack_require__) { + +/** + * Module dependencies. + */ + +var tty = __webpack_require__(478); +var util = __webpack_require__(29); + +/** + * This is the Node.js implementation of `debug()`. + * + * Expose `debug()` as the module. + */ + +exports = module.exports = __webpack_require__(865); +exports.init = init; +exports.log = log; +exports.formatArgs = formatArgs; +exports.save = save; +exports.load = load; +exports.useColors = useColors; + +/** + * Colors. + */ + +exports.colors = [6, 2, 3, 4, 5, 1]; + +/** + * Build up the default `inspectOpts` object from the environment variables. + * + * $ DEBUG_COLORS=no DEBUG_DEPTH=10 DEBUG_SHOW_HIDDEN=enabled node script.js + */ + +exports.inspectOpts = Object.keys(process.env).filter(function (key) { + return /^debug_/i.test(key); +}).reduce(function (obj, key) { + // camel-case + var prop = key + .substring(6) + .toLowerCase() + .replace(/_([a-z])/g, function (_, k) { return k.toUpperCase() }); + + // coerce string value into JS value + var val = process.env[key]; + if (/^(yes|on|true|enabled)$/i.test(val)) val = true; + else if (/^(no|off|false|disabled)$/i.test(val)) val = false; + else if (val === 'null') val = null; + else val = Number(val); + + obj[prop] = val; + return obj; +}, {}); + +/** + * The file descriptor to write the `debug()` calls to. + * Set the `DEBUG_FD` env variable to override with another value. i.e.: + * + * $ DEBUG_FD=3 node script.js 3>debug.log + */ + +var fd = parseInt(process.env.DEBUG_FD, 10) || 2; + +if (1 !== fd && 2 !== fd) { + util.deprecate(function(){}, 'except for stderr(2) and stdout(1), any other usage of DEBUG_FD is deprecated. Override debug.log if you want to use a different log function (https://git.io/debug_fd)')() +} + +var stream = 1 === fd ? process.stdout : + 2 === fd ? process.stderr : + createWritableStdioStream(fd); + +/** + * Is stdout a TTY? Colored output is enabled when `true`. + */ + +function useColors() { + return 'colors' in exports.inspectOpts + ? Boolean(exports.inspectOpts.colors) + : tty.isatty(fd); +} + +/** + * Map %o to `util.inspect()`, all on a single line. + */ + +exports.formatters.o = function(v) { + this.inspectOpts.colors = this.useColors; + return util.inspect(v, this.inspectOpts) + .split('\n').map(function(str) { + return str.trim() + }).join(' '); +}; + +/** + * Map %o to `util.inspect()`, allowing multiple lines if needed. + */ + +exports.formatters.O = function(v) { + this.inspectOpts.colors = this.useColors; + return util.inspect(v, this.inspectOpts); +}; + +/** + * Adds ANSI color escape codes if enabled. + * + * @api public + */ + +function formatArgs(args) { + var name = this.namespace; + var useColors = this.useColors; + + if (useColors) { + var c = this.color; + var prefix = ' \u001b[3' + c + ';1m' + name + ' ' + '\u001b[0m'; + + args[0] = prefix + args[0].split('\n').join('\n' + prefix); + args.push('\u001b[3' + c + 'm+' + exports.humanize(this.diff) + '\u001b[0m'); + } else { + args[0] = new Date().toUTCString() + + ' ' + name + ' ' + args[0]; + } +} + +/** + * Invokes `util.format()` with the specified arguments and writes to `stream`. + */ + +function log() { + return stream.write(util.format.apply(util, arguments) + '\n'); +} + +/** + * Save `namespaces`. + * + * @param {String} namespaces + * @api private + */ + +function save(namespaces) { + if (null == namespaces) { + // If you set a process.env field to null or undefined, it gets cast to the + // string 'null' or 'undefined'. Just delete instead. + delete process.env.DEBUG; + } else { + process.env.DEBUG = namespaces; + } +} + +/** + * Load `namespaces`. + * + * @return {String} returns the previously persisted debug modes + * @api private + */ + +function load() { + return process.env.DEBUG; +} + +/** + * Copied from `node/src/node.js`. + * + * XXX: It's lame that node doesn't expose this API out-of-the-box. It also + * relies on the undocumented `tty_wrap.guessHandleType()` which is also lame. + */ + +function createWritableStdioStream (fd) { + var stream; + var tty_wrap = process.binding('tty_wrap'); + + // Note stream._type is used for test-module-load-list.js + + switch (tty_wrap.guessHandleType(fd)) { + case 'TTY': + stream = new tty.WriteStream(fd); + stream._type = 'tty'; + + // Hack to have stream not keep the event loop alive. + // See https://github.com/joyent/node/issues/1726 + if (stream._handle && stream._handle.unref) { + stream._handle.unref(); + } + break; + + case 'FILE': + var fs = __webpack_require__(23); + stream = new fs.SyncWriteStream(fd, { autoClose: false }); + stream._type = 'fs'; + break; + + case 'PIPE': + case 'TCP': + var net = __webpack_require__(805); + stream = new net.Socket({ + fd: fd, + readable: false, + writable: true + }); + + // FIXME Should probably have an option in net.Socket to create a + // stream from an existing fd which is writable only. But for now + // we'll just add this hack and set the `readable` member to false. + // Test: ./node test/fixtures/echo.js < /etc/passwd + stream.readable = false; + stream.read = null; + stream._type = 'pipe'; + + // FIXME Hack to have stream not keep the event loop alive. + // See https://github.com/joyent/node/issues/1726 + if (stream._handle && stream._handle.unref) { + stream._handle.unref(); + } + break; + + default: + // Probably an error on in uv_guess_handle() + throw new Error('Implement me. Unknown stream file type!'); + } + + // For supporting legacy API we put the FD here. + stream.fd = fd; + + stream._isStdio = true; + + return stream; +} + +/** + * Init logic for `debug` instances. + * + * Create a new `inspectOpts` object in case `useColors` is set + * differently for a particular `debug` instance. + */ + +function init (debug) { + debug.inspectOpts = {}; + + var keys = Object.keys(exports.inspectOpts); + for (var i = 0; i < keys.length; i++) { + debug.inspectOpts[keys[i]] = exports.inspectOpts[keys[i]]; + } +} + +/** + * Enable namespaces listed in `process.env.DEBUG` initially. + */ + +exports.enable(load()); + + +/***/ }), +/* 868 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +var brackets = __webpack_require__(858); +var define = __webpack_require__(869); +var utils = __webpack_require__(870); + +/** + * Characters to use in text regex (we want to "not" match + * characters that are matched by other parsers) + */ + +var TEXT_REGEX = '([!@*?+]?\\(|\\)|[*?.+\\\\]|\\[:?(?=.*\\])|:?\\])+'; +var not = utils.createRegex(TEXT_REGEX); + +/** + * Extglob parsers + */ + +function parsers(extglob) { + extglob.state = extglob.state || {}; + + /** + * Use `expand-brackets` parsers + */ + + extglob.use(brackets.parsers); + extglob.parser.sets.paren = extglob.parser.sets.paren || []; + extglob.parser + + /** + * Extglob open: "*(" + */ + + .capture('paren.open', function() { + var parsed = this.parsed; + var pos = this.position(); + var m = this.match(/^([!@*?+])?\(/); + if (!m) return; + + var prev = this.prev(); + var prefix = m[1]; + var val = m[0]; + + var open = pos({ + type: 'paren.open', + parsed: parsed, + val: val + }); + + var node = pos({ + type: 'paren', + prefix: prefix, + nodes: [open] + }); + + // if nested negation extglobs, just cancel them out to simplify + if (prefix === '!' && prev.type === 'paren' && prev.prefix === '!') { + prev.prefix = '@'; + node.prefix = '@'; + } + + define(node, 'rest', this.input); + define(node, 'parsed', parsed); + define(node, 'parent', prev); + define(open, 'parent', node); + + this.push('paren', node); + prev.nodes.push(node); + }) + + /** + * Extglob close: ")" + */ + + .capture('paren.close', function() { + var parsed = this.parsed; + var pos = this.position(); + var m = this.match(/^\)/); + if (!m) return; + + var parent = this.pop('paren'); + var node = pos({ + type: 'paren.close', + rest: this.input, + parsed: parsed, + val: m[0] + }); + + if (!this.isType(parent, 'paren')) { + if (this.options.strict) { + throw new Error('missing opening paren: "("'); + } + node.escaped = true; + return node; + } + + node.prefix = parent.prefix; + parent.nodes.push(node); + define(node, 'parent', parent); + }) + + /** + * Escape: "\\." + */ + + .capture('escape', function() { + var pos = this.position(); + var m = this.match(/^\\(.)/); + if (!m) return; + + return pos({ + type: 'escape', + val: m[0], + ch: m[1] + }); + }) + + /** + * Question marks: "?" + */ + + .capture('qmark', function() { + var parsed = this.parsed; + var pos = this.position(); + var m = this.match(/^\?+(?!\()/); + if (!m) return; + extglob.state.metachar = true; + return pos({ + type: 'qmark', + rest: this.input, + parsed: parsed, + val: m[0] + }); + }) + + /** + * Character parsers + */ + + .capture('star', /^\*(?!\()/) + .capture('plus', /^\+(?!\()/) + .capture('dot', /^\./) + .capture('text', not); +}; + +/** + * Expose text regex string + */ + +module.exports.TEXT_REGEX = TEXT_REGEX; + +/** + * Extglob parsers + */ + +module.exports = parsers; + + +/***/ }), +/* 869 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; +/*! + * define-property + * + * Copyright (c) 2015, 2017, Jon Schlinkert. + * Released under the MIT License. + */ + + + +var isDescriptor = __webpack_require__(759); + +module.exports = function defineProperty(obj, prop, val) { + if (typeof obj !== 'object' && typeof obj !== 'function') { + throw new TypeError('expected an object or function.'); + } + + if (typeof prop !== 'string') { + throw new TypeError('expected `prop` to be a string.'); + } + + if (isDescriptor(val) && ('set' in val || 'get' in val)) { + return Object.defineProperty(obj, prop, val); } return Object.defineProperty(obj, prop, { @@ -102014,14 +102759,14 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 866 */ +/* 870 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var regex = __webpack_require__(740); -var Cache = __webpack_require__(850); +var regex = __webpack_require__(739); +var Cache = __webpack_require__(849); /** * Utils @@ -102090,7 +102835,7 @@ utils.createRegex = function(str) { /***/ }), -/* 867 */ +/* 871 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -102100,16 +102845,16 @@ utils.createRegex = function(str) { * Module dependencies */ -var Snapdragon = __webpack_require__(768); -var define = __webpack_require__(865); -var extend = __webpack_require__(738); +var Snapdragon = __webpack_require__(767); +var define = __webpack_require__(869); +var extend = __webpack_require__(737); /** * Local dependencies */ -var compilers = __webpack_require__(858); -var parsers = __webpack_require__(864); +var compilers = __webpack_require__(857); +var parsers = __webpack_require__(868); /** * Customize Snapdragon parser and renderer @@ -102175,16 +102920,16 @@ module.exports = Extglob; /***/ }), -/* 868 */ +/* 872 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extglob = __webpack_require__(857); -var nanomatch = __webpack_require__(842); -var regexNot = __webpack_require__(740); -var toRegex = __webpack_require__(830); +var extglob = __webpack_require__(856); +var nanomatch = __webpack_require__(841); +var regexNot = __webpack_require__(739); +var toRegex = __webpack_require__(829); var not; /** @@ -102265,14 +103010,14 @@ function textRegex(pattern) { /***/ }), -/* 869 */ +/* 873 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = new (__webpack_require__(850))(); +module.exports = new (__webpack_require__(849))(); /***/ }), -/* 870 */ +/* 874 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -102285,13 +103030,13 @@ var path = __webpack_require__(16); * Module dependencies */ -var Snapdragon = __webpack_require__(768); -utils.define = __webpack_require__(837); -utils.diff = __webpack_require__(854); -utils.extend = __webpack_require__(838); -utils.pick = __webpack_require__(855); -utils.typeOf = __webpack_require__(871); -utils.unique = __webpack_require__(741); +var Snapdragon = __webpack_require__(767); +utils.define = __webpack_require__(836); +utils.diff = __webpack_require__(853); +utils.extend = __webpack_require__(837); +utils.pick = __webpack_require__(854); +utils.typeOf = __webpack_require__(875); +utils.unique = __webpack_require__(740); /** * Returns true if the platform is windows, or `path.sep` is `\\`. @@ -102588,7 +103333,7 @@ utils.unixify = function(options) { /***/ }), -/* 871 */ +/* 875 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -102723,7 +103468,7 @@ function isBuffer(val) { /***/ }), -/* 872 */ +/* 876 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -102742,9 +103487,9 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var readdir = __webpack_require__(873); -var reader_1 = __webpack_require__(886); -var fs_stream_1 = __webpack_require__(890); +var readdir = __webpack_require__(877); +var reader_1 = __webpack_require__(890); +var fs_stream_1 = __webpack_require__(894); var ReaderAsync = /** @class */ (function (_super) { __extends(ReaderAsync, _super); function ReaderAsync() { @@ -102805,15 +103550,15 @@ exports.default = ReaderAsync; /***/ }), -/* 873 */ +/* 877 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const readdirSync = __webpack_require__(874); -const readdirAsync = __webpack_require__(882); -const readdirStream = __webpack_require__(885); +const readdirSync = __webpack_require__(878); +const readdirAsync = __webpack_require__(886); +const readdirStream = __webpack_require__(889); module.exports = exports = readdirAsyncPath; exports.readdir = exports.readdirAsync = exports.async = readdirAsyncPath; @@ -102897,7 +103642,7 @@ function readdirStreamStat (dir, options) { /***/ }), -/* 874 */ +/* 878 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -102905,11 +103650,11 @@ function readdirStreamStat (dir, options) { module.exports = readdirSync; -const DirectoryReader = __webpack_require__(875); +const DirectoryReader = __webpack_require__(879); let syncFacade = { - fs: __webpack_require__(880), - forEach: __webpack_require__(881), + fs: __webpack_require__(884), + forEach: __webpack_require__(885), sync: true }; @@ -102938,7 +103683,7 @@ function readdirSync (dir, options, internalOptions) { /***/ }), -/* 875 */ +/* 879 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -102947,9 +103692,9 @@ function readdirSync (dir, options, internalOptions) { const Readable = __webpack_require__(27).Readable; const EventEmitter = __webpack_require__(379).EventEmitter; const path = __webpack_require__(16); -const normalizeOptions = __webpack_require__(876); -const stat = __webpack_require__(878); -const call = __webpack_require__(879); +const normalizeOptions = __webpack_require__(880); +const stat = __webpack_require__(882); +const call = __webpack_require__(883); /** * Asynchronously reads the contents of a directory and streams the results @@ -103325,14 +104070,14 @@ module.exports = DirectoryReader; /***/ }), -/* 876 */ +/* 880 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(16); -const globToRegExp = __webpack_require__(877); +const globToRegExp = __webpack_require__(881); module.exports = normalizeOptions; @@ -103509,7 +104254,7 @@ function normalizeOptions (options, internalOptions) { /***/ }), -/* 877 */ +/* 881 */ /***/ (function(module, exports) { module.exports = function (glob, opts) { @@ -103646,13 +104391,13 @@ module.exports = function (glob, opts) { /***/ }), -/* 878 */ +/* 882 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const call = __webpack_require__(879); +const call = __webpack_require__(883); module.exports = stat; @@ -103727,7 +104472,7 @@ function symlinkStat (fs, path, lstats, callback) { /***/ }), -/* 879 */ +/* 883 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -103788,14 +104533,14 @@ function callOnce (fn) { /***/ }), -/* 880 */ +/* 884 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(23); -const call = __webpack_require__(879); +const call = __webpack_require__(883); /** * A facade around {@link fs.readdirSync} that allows it to be called @@ -103859,7 +104604,7 @@ exports.lstat = function (path, callback) { /***/ }), -/* 881 */ +/* 885 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -103888,7 +104633,7 @@ function syncForEach (array, iterator, done) { /***/ }), -/* 882 */ +/* 886 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -103896,12 +104641,12 @@ function syncForEach (array, iterator, done) { module.exports = readdirAsync; -const maybe = __webpack_require__(883); -const DirectoryReader = __webpack_require__(875); +const maybe = __webpack_require__(887); +const DirectoryReader = __webpack_require__(879); let asyncFacade = { fs: __webpack_require__(23), - forEach: __webpack_require__(884), + forEach: __webpack_require__(888), async: true }; @@ -103943,7 +104688,7 @@ function readdirAsync (dir, options, callback, internalOptions) { /***/ }), -/* 883 */ +/* 887 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -103970,7 +104715,7 @@ module.exports = function maybe (cb, promise) { /***/ }), -/* 884 */ +/* 888 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -104006,7 +104751,7 @@ function asyncForEach (array, iterator, done) { /***/ }), -/* 885 */ +/* 889 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -104014,11 +104759,11 @@ function asyncForEach (array, iterator, done) { module.exports = readdirStream; -const DirectoryReader = __webpack_require__(875); +const DirectoryReader = __webpack_require__(879); let streamFacade = { fs: __webpack_require__(23), - forEach: __webpack_require__(884), + forEach: __webpack_require__(888), async: true }; @@ -104038,16 +104783,16 @@ function readdirStream (dir, options, internalOptions) { /***/ }), -/* 886 */ +/* 890 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var path = __webpack_require__(16); -var deep_1 = __webpack_require__(887); -var entry_1 = __webpack_require__(889); -var pathUtil = __webpack_require__(888); +var deep_1 = __webpack_require__(891); +var entry_1 = __webpack_require__(893); +var pathUtil = __webpack_require__(892); var Reader = /** @class */ (function () { function Reader(options) { this.options = options; @@ -104113,14 +104858,14 @@ exports.default = Reader; /***/ }), -/* 887 */ +/* 891 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var pathUtils = __webpack_require__(888); -var patternUtils = __webpack_require__(722); +var pathUtils = __webpack_require__(892); +var patternUtils = __webpack_require__(721); var DeepFilter = /** @class */ (function () { function DeepFilter(options, micromatchOptions) { this.options = options; @@ -104203,7 +104948,7 @@ exports.default = DeepFilter; /***/ }), -/* 888 */ +/* 892 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -104234,14 +104979,14 @@ exports.makeAbsolute = makeAbsolute; /***/ }), -/* 889 */ +/* 893 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var pathUtils = __webpack_require__(888); -var patternUtils = __webpack_require__(722); +var pathUtils = __webpack_require__(892); +var patternUtils = __webpack_require__(721); var EntryFilter = /** @class */ (function () { function EntryFilter(options, micromatchOptions) { this.options = options; @@ -104326,7 +105071,7 @@ exports.default = EntryFilter; /***/ }), -/* 890 */ +/* 894 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -104346,8 +105091,8 @@ var __extends = (this && this.__extends) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); var stream = __webpack_require__(27); -var fsStat = __webpack_require__(891); -var fs_1 = __webpack_require__(895); +var fsStat = __webpack_require__(895); +var fs_1 = __webpack_require__(899); var FileSystemStream = /** @class */ (function (_super) { __extends(FileSystemStream, _super); function FileSystemStream() { @@ -104397,14 +105142,14 @@ exports.default = FileSystemStream; /***/ }), -/* 891 */ +/* 895 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const optionsManager = __webpack_require__(892); -const statProvider = __webpack_require__(894); +const optionsManager = __webpack_require__(896); +const statProvider = __webpack_require__(898); /** * Asynchronous API. */ @@ -104435,13 +105180,13 @@ exports.statSync = statSync; /***/ }), -/* 892 */ +/* 896 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fsAdapter = __webpack_require__(893); +const fsAdapter = __webpack_require__(897); function prepare(opts) { const options = Object.assign({ fs: fsAdapter.getFileSystemAdapter(opts ? opts.fs : undefined), @@ -104454,7 +105199,7 @@ exports.prepare = prepare; /***/ }), -/* 893 */ +/* 897 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -104477,7 +105222,7 @@ exports.getFileSystemAdapter = getFileSystemAdapter; /***/ }), -/* 894 */ +/* 898 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -104529,7 +105274,7 @@ exports.isFollowedSymlink = isFollowedSymlink; /***/ }), -/* 895 */ +/* 899 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -104560,7 +105305,7 @@ exports.default = FileSystem; /***/ }), -/* 896 */ +/* 900 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -104580,9 +105325,9 @@ var __extends = (this && this.__extends) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); var stream = __webpack_require__(27); -var readdir = __webpack_require__(873); -var reader_1 = __webpack_require__(886); -var fs_stream_1 = __webpack_require__(890); +var readdir = __webpack_require__(877); +var reader_1 = __webpack_require__(890); +var fs_stream_1 = __webpack_require__(894); var TransformStream = /** @class */ (function (_super) { __extends(TransformStream, _super); function TransformStream(reader) { @@ -104650,7 +105395,7 @@ exports.default = ReaderStream; /***/ }), -/* 897 */ +/* 901 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -104669,9 +105414,9 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var readdir = __webpack_require__(873); -var reader_1 = __webpack_require__(886); -var fs_sync_1 = __webpack_require__(898); +var readdir = __webpack_require__(877); +var reader_1 = __webpack_require__(890); +var fs_sync_1 = __webpack_require__(902); var ReaderSync = /** @class */ (function (_super) { __extends(ReaderSync, _super); function ReaderSync() { @@ -104731,7 +105476,7 @@ exports.default = ReaderSync; /***/ }), -/* 898 */ +/* 902 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -104750,8 +105495,8 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var fsStat = __webpack_require__(891); -var fs_1 = __webpack_require__(895); +var fsStat = __webpack_require__(895); +var fs_1 = __webpack_require__(899); var FileSystemSync = /** @class */ (function (_super) { __extends(FileSystemSync, _super); function FileSystemSync() { @@ -104797,7 +105542,7 @@ exports.default = FileSystemSync; /***/ }), -/* 899 */ +/* 903 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -104813,13 +105558,13 @@ exports.flatten = flatten; /***/ }), -/* 900 */ +/* 904 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var merge2 = __webpack_require__(589); +var merge2 = __webpack_require__(588); /** * Merge multiple streams and propagate their errors into one stream in parallel. */ @@ -104834,13 +105579,13 @@ exports.merge = merge; /***/ }), -/* 901 */ +/* 905 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(16); -const pathType = __webpack_require__(902); +const pathType = __webpack_require__(906); const getExtensions = extensions => extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0]; @@ -104906,13 +105651,13 @@ module.exports.sync = (input, opts) => { /***/ }), -/* 902 */ +/* 906 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(23); -const pify = __webpack_require__(903); +const pify = __webpack_require__(907); function type(fn, fn2, fp) { if (typeof fp !== 'string') { @@ -104955,7 +105700,7 @@ exports.symlinkSync = typeSync.bind(null, 'lstatSync', 'isSymbolicLink'); /***/ }), -/* 903 */ +/* 907 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -105046,17 +105791,17 @@ module.exports = (obj, opts) => { /***/ }), -/* 904 */ +/* 908 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(23); const path = __webpack_require__(16); -const fastGlob = __webpack_require__(718); -const gitIgnore = __webpack_require__(905); -const pify = __webpack_require__(906); -const slash = __webpack_require__(907); +const fastGlob = __webpack_require__(717); +const gitIgnore = __webpack_require__(909); +const pify = __webpack_require__(910); +const slash = __webpack_require__(911); const DEFAULT_IGNORE = [ '**/node_modules/**', @@ -105154,7 +105899,7 @@ module.exports.sync = options => { /***/ }), -/* 905 */ +/* 909 */ /***/ (function(module, exports) { // A simple implementation of make-array @@ -105623,7 +106368,7 @@ module.exports = options => new IgnoreBase(options) /***/ }), -/* 906 */ +/* 910 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -105698,7 +106443,7 @@ module.exports = (input, options) => { /***/ }), -/* 907 */ +/* 911 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -105716,17 +106461,17 @@ module.exports = input => { /***/ }), -/* 908 */ +/* 912 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(16); const {constants: fsConstants} = __webpack_require__(23); -const pEvent = __webpack_require__(909); -const CpFileError = __webpack_require__(912); -const fs = __webpack_require__(916); -const ProgressEmitter = __webpack_require__(919); +const pEvent = __webpack_require__(913); +const CpFileError = __webpack_require__(916); +const fs = __webpack_require__(920); +const ProgressEmitter = __webpack_require__(923); const cpFileAsync = async (source, destination, options, progressEmitter) => { let readError; @@ -105840,12 +106585,12 @@ module.exports.sync = (source, destination, options) => { /***/ }), -/* 909 */ +/* 913 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pTimeout = __webpack_require__(910); +const pTimeout = __webpack_require__(914); const symbolAsyncIterator = Symbol.asyncIterator || '@@asyncIterator'; @@ -106136,12 +106881,12 @@ module.exports.iterator = (emitter, event, options) => { /***/ }), -/* 910 */ +/* 914 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pFinally = __webpack_require__(911); +const pFinally = __webpack_require__(915); class TimeoutError extends Error { constructor(message) { @@ -106187,7 +106932,7 @@ module.exports.TimeoutError = TimeoutError; /***/ }), -/* 911 */ +/* 915 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -106209,12 +106954,12 @@ module.exports = (promise, onFinally) => { /***/ }), -/* 912 */ +/* 916 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const NestedError = __webpack_require__(913); +const NestedError = __webpack_require__(917); class CpFileError extends NestedError { constructor(message, nested) { @@ -106228,10 +106973,10 @@ module.exports = CpFileError; /***/ }), -/* 913 */ +/* 917 */ /***/ (function(module, exports, __webpack_require__) { -var inherits = __webpack_require__(914); +var inherits = __webpack_require__(918); var NestedError = function (message, nested) { this.nested = nested; @@ -106282,7 +107027,7 @@ module.exports = NestedError; /***/ }), -/* 914 */ +/* 918 */ /***/ (function(module, exports, __webpack_require__) { try { @@ -106290,12 +107035,12 @@ try { if (typeof util.inherits !== 'function') throw ''; module.exports = util.inherits; } catch (e) { - module.exports = __webpack_require__(915); + module.exports = __webpack_require__(919); } /***/ }), -/* 915 */ +/* 919 */ /***/ (function(module, exports) { if (typeof Object.create === 'function') { @@ -106324,16 +107069,16 @@ if (typeof Object.create === 'function') { /***/ }), -/* 916 */ +/* 920 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const {promisify} = __webpack_require__(29); const fs = __webpack_require__(22); -const makeDir = __webpack_require__(917); -const pEvent = __webpack_require__(909); -const CpFileError = __webpack_require__(912); +const makeDir = __webpack_require__(921); +const pEvent = __webpack_require__(913); +const CpFileError = __webpack_require__(916); const stat = promisify(fs.stat); const lstat = promisify(fs.lstat); @@ -106430,7 +107175,7 @@ exports.copyFileSync = (source, destination, flags) => { /***/ }), -/* 917 */ +/* 921 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -106438,7 +107183,7 @@ exports.copyFileSync = (source, destination, flags) => { const fs = __webpack_require__(23); const path = __webpack_require__(16); const {promisify} = __webpack_require__(29); -const semver = __webpack_require__(918); +const semver = __webpack_require__(922); const defaults = { mode: 0o777 & (~process.umask()), @@ -106587,7 +107332,7 @@ module.exports.sync = (input, options) => { /***/ }), -/* 918 */ +/* 922 */ /***/ (function(module, exports) { exports = module.exports = SemVer @@ -108189,7 +108934,7 @@ function coerce (version, options) { /***/ }), -/* 919 */ +/* 923 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -108230,7 +108975,7 @@ module.exports = ProgressEmitter; /***/ }), -/* 920 */ +/* 924 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -108276,12 +109021,12 @@ exports.default = module.exports; /***/ }), -/* 921 */ +/* 925 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const NestedError = __webpack_require__(922); +const NestedError = __webpack_require__(926); class CpyError extends NestedError { constructor(message, nested) { @@ -108295,7 +109040,7 @@ module.exports = CpyError; /***/ }), -/* 922 */ +/* 926 */ /***/ (function(module, exports, __webpack_require__) { var inherits = __webpack_require__(29).inherits; @@ -108351,7 +109096,7 @@ module.exports = NestedError; /***/ }), -/* 923 */ +/* 927 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index e2823f23d0431..c8614b1df9d5d 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -9,7 +9,7 @@ "kbn:watch": "node scripts/build --watch" }, "dependencies": { - "@elastic/charts": "^18.1.1", + "@elastic/charts": "18.3.0", "@elastic/eui": "21.0.1", "@kbn/i18n": "1.0.0", "abortcontroller-polyfill": "^1.4.0", diff --git a/rfcs/text/0002_encrypted_attributes.md b/rfcs/text/0002_encrypted_attributes.md index aa7307edb66bd..c6553c177d995 100644 --- a/rfcs/text/0002_encrypted_attributes.md +++ b/rfcs/text/0002_encrypted_attributes.md @@ -166,7 +166,7 @@ take a look at the source code of this library to know how encryption is perform parameters are used, but in short it's AES Encryption with AES-256-GCM that uses random initialization vector and salt. As with encryption key for Kibana's session cookie, master encryption key used by `encrypted_saved_objects` plugin can be -defined as a configuration value (`xpack.encrypted_saved_objects.encryptionKey`) via `kibana.yml`, but it's **highly +defined as a configuration value (`xpack.encryptedSavedObjects.encryptionKey`) via `kibana.yml`, but it's **highly recommended** to define this key in the [Kibana Keystore](https://www.elastic.co/guide/en/kibana/current/secure-settings.html) instead. The master key should be cryptographically safe and be equal or greater than 32 bytes. diff --git a/src/core/CONVENTIONS.md b/src/core/CONVENTIONS.md index 0f592d108c561..a82cc27839a1d 100644 --- a/src/core/CONVENTIONS.md +++ b/src/core/CONVENTIONS.md @@ -1,6 +1,6 @@ -# Kibana Conventions - -- [Kibana Conventions](#kibana-conventions) +- [Organisational Conventions](#organisational-conventions) + - [Definition of done](#definition-of-done) +- [Technical Conventions](#technical-conventions) - [Plugin Structure](#plugin-structure) - [The PluginInitializer](#the-plugininitializer) - [The Plugin class](#the-plugin-class) @@ -8,8 +8,34 @@ - [Services](#services) - [Usage Collection](#usage-collection) - [Saved Objects Types](#saved-objects-types) - -## Plugin Structure + - [Naming conventions](#naming-conventions) + +## Organisational Conventions +### Definition of done +Definition of done for a feature: +- has a dedicated Github issue describing problem space +- an umbrella task closed/updated with follow-ups +- all code review comments are resolved +- has been verified manually by at least one reviewer +- can be used by first & third party plugins +- there is no contradiction between client and server API +- works for OSS version + - works with and without a `server.basePath` configured + - cannot crash the Kibana server when it fails +- works for the commercial version with a license + - for a logged-in user + - for anonymous user + - compatible with Spaces +- has unit & integration tests for public contracts +- has functional tests for user scenarios +- uses standard tooling: + - code - `TypeScript` + - UI - `React` + - tests - `jest` & `FTR` +- has documentation for the public contract, provides a usage example + +## Technical Conventions +### Plugin Structure All Kibana plugins built at Elastic should follow the same structure. @@ -60,7 +86,7 @@ my_plugin/ - More should be fleshed out here... - Usage collectors for Telemetry should be defined in a separate `server/collectors/` directory. -### The PluginInitializer +#### The PluginInitializer ```ts // my_plugin/public/index.ts @@ -75,7 +101,7 @@ export { } ``` -### The Plugin class +#### The Plugin class ```ts // my_plugin/public/plugin.ts @@ -130,7 +156,7 @@ Difference between `setup` and `start`: The bulk of your plugin logic will most likely live inside _handlers_ registered during `setup`. -### Applications +#### Applications It's important that UI code is not included in the main bundle for your plugin. Our webpack configuration supports dynamic async imports to split out imports into a separate bundle. Every app's rendering logic and UI code should @@ -175,7 +201,7 @@ export class MyPlugin implements Plugin { } ``` -### Services +#### Services Service structure should mirror the plugin lifecycle to make reasoning about how the service is executed more clear. @@ -226,7 +252,7 @@ export class Plugin { } ``` -### Usage Collection +#### Usage Collection For creating and registering a Usage Collector. Collectors should be defined in a separate directory `server/collectors/`. You can read more about usage collectors on `src/plugins/usage_collection/README.md`. @@ -263,7 +289,7 @@ export function registerMyPluginUsageCollector(usageCollection?: UsageCollection } ``` -### Saved Objects Types +#### Saved Objects Types Saved object type definitions should be defined in their own `server/saved_objects` directory. diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md index 5d7b467052029..80f12dd78214d 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -24,6 +24,7 @@ - [7. Switch to new platform services](#7-switch-to-new-platform-services) - [8. Migrate to the new plugin system](#8-migrate-to-the-new-plugin-system) - [Bonus: Tips for complex migration scenarios](#bonus-tips-for-complex-migration-scenarios) + - [Keep Kibana fast](#keep-kibana-fast) - [Frequently asked questions](#frequently-asked-questions) - [Is migrating a plugin an all-or-nothing thing?](#is-migrating-a-plugin-an-all-or-nothing-thing) - [Do plugins need to be converted to TypeScript?](#do-plugins-need-to-be-converted-to-typescript) @@ -933,6 +934,66 @@ For a few plugins, some of these steps (such as angular removal) could be a mont One convention that is useful for this is creating a dedicated `public/np_ready` directory to house the code that is ready to migrate, and gradually move more and more code into it until the rest of your plugin is essentially empty. At that point, you'll be able to copy your `index.ts`, `plugin.ts`, and the contents of `./np_ready` over into your plugin in the new platform, leaving your legacy shim behind. This carries the added benefit of providing a way for us to introduce helpful tooling in the future, such as [custom eslint rules](https://github.com/elastic/kibana/pull/40537), which could be run against that specific directory to ensure your code is ready to migrate. +## Keep Kibana fast +**tl;dr**: Load as much code lazily as possible. +Everyone loves snappy applications with responsive UI and hates spinners. Users deserve the best user experiences regardless of whether they run Kibana locally or in the cloud, regardless of their hardware & environment. +There are 2 main aspects of the perceived speed of an application: loading time and responsiveness to user actions. +New platform loads and bootstraps **all** the plugins whenever a user lands on any page. It means that adding every new application affects overall **loading performance** in the new platform, as plugin code is loaded **eagerly** to initialize the plugin and provide plugin API to dependent plugins. +However, it's usually not necessary that the whole plugin code should be loaded and initialized at once. The plugin could keep on loading code covering API functionality on Kibana bootstrap but load UI related code lazily on-demand, when an application page or management section is mounted. +Always prefer to require UI root components lazily when possible (such as in mount handlers). Even if their size may seem negligible, they are likely using some heavy-weight libraries that will also be removed from the initial plugin bundle, therefore, reducing its size by a significant amount. + +```typescript +import { Plugin, CoreSetup, AppMountParameters } from 'src/core/public'; +export class MyPlugin implements Plugin { + setup(core: CoreSetup, plugins: SetupDeps){ + core.application.register({ + id: 'app', + title: 'My app', + async mount(params: AppMountParameters) { + const { mountApp } = await import('./app/mount_app'); + return mountApp(await core.getStartServices(), params); + }, + }); + plugins.management.sections.getSection('another').registerApp({ + id: 'app', + title: 'My app', + order: 1, + async mount(params) { + const { mountManagementSection } = await import('./app/mount_management_section'); + return mountManagementSection(coreSetup, params); + }, + }) + return { + doSomething(){} + } + } +} +``` + +#### How to understand how big the bundle size of my plugin is? +New platform plugins are distributed as a pre-built with `@kbn/optimizer` package artifacts. It allows us to get rid of the shipping of `optimizer` in the distributable version of Kibana. +Every NP plugin artifact contains all plugin dependencies required to run the plugin, except some stateful dependencies shared across plugin bundles via `@kbn/ui-shared-deps`. +It means that NP plugin artifacts tend to have a bigger size than the legacy platform version. +To understand the current size of your plugin artifact, run `@kbn/optimizer` as +```bash +node scripts/build_kibana_platform_plugins.js --dist --no-examples +``` +and check the output in the `target` sub-folder of your plugin folder +```bash +ls -lh plugins/my_plugin/target/public/ +# output +# an async chunk loaded on demand +... 262K 0.plugin.js +# eagerly loaded chunk +... 50K my_plugin.plugin.js +``` +you might see at least one js bundle - `my_plugin.plugin.js`. This is the only artifact loaded by the platform during bootstrap in the browser. The rule of thumb is to keep its size as small as possible. +Other lazily loaded parts of your plugin present in the same folder as separate chunks under `{number}.plugin.js` names. +If you want to investigate what your plugin bundle consists of you need to run `@kbn/optimizer` with `--profile` flag to get generated [webpack stats file](https://webpack.js.org/api/stats/). +Many OSS tools are allowing you to analyze generated stats file +- [an official tool](http://webpack.github.io/analyse/#modules) from webpack authors +- [webpack-visualizer](https://chrisbateman.github.io/webpack-visualizer/) + ## Frequently asked questions ### Is migrating a plugin an all-or-nothing thing? @@ -1191,26 +1252,27 @@ import { npStart: { plugins } } from 'ui/new_platform'; In server code, `core` can be accessed from either `server.newPlatform` or `kbnServer.newPlatform`. There are not currently very many services available on the server-side: -| Legacy Platform | New Platform | Notes | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| `server.config()` | [`initializerContext.config.create()`](/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md) | Must also define schema. See _[how to configure plugin](#configure-plugin)_ | -| `server.route` | [`core.http.createRouter`](/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.createrouter.md) | [Examples](./MIGRATION_EXAMPLES.md#route-registration) | -| `server.renderApp()` / `server.renderAppWithDefaultConfig()` | [`context.rendering.render()`](/docs/development/core/server/kibana-plugin-core-server.iscopedrenderingclient.render.md) | [Examples](./MIGRATION_EXAMPLES.md#render-html-content) | -| `request.getBasePath()` | [`core.http.basePath.get`](/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.basepath.md) | | +| Legacy Platform | New Platform | Notes | +| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `server.config()` | [`initializerContext.config.create()`](/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md) | Must also define schema. See _[how to configure plugin](#configure-plugin)_ | +| `server.route` | [`core.http.createRouter`](/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.createrouter.md) | [Examples](./MIGRATION_EXAMPLES.md#route-registration) | +| `server.renderApp()` | [`response.renderCoreApp()`](docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.rendercoreapp.md) | [Examples](./MIGRATION_EXAMPLES.md#render-html-content) | +| `server.renderAppWithDefaultConfig()` | [`response.renderAnonymousCoreApp()`](docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.renderanonymouscoreapp.md) | [Examples](./MIGRATION_EXAMPLES.md#render-html-content) | +| `request.getBasePath()` | [`core.http.basePath.get`](/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.basepath.md) | | | `server.plugins.elasticsearch.getCluster('data')` | [`context.core.elasticsearch.dataClient`](/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md) | | | `server.plugins.elasticsearch.getCluster('admin')` | [`context.core.elasticsearch.adminClient`](/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md) | | -| `server.plugins.elasticsearch.createCluster(...)` | [`core.elasticsearch.legacy.createClient`](/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.legacy.md) | | -| `server.savedObjects.setScopedSavedObjectsClientFactory` | [`core.savedObjects.setClientFactoryProvider`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.setclientfactoryprovider.md) | | -| `server.savedObjects.addScopedSavedObjectsClientWrapperFactory` | [`core.savedObjects.addClientWrapper`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.addclientwrapper.md) | | +| `server.plugins.elasticsearch.createCluster(...)` | [`core.elasticsearch.legacy.createClient`](/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.legacy.md) | | +| `server.savedObjects.setScopedSavedObjectsClientFactory` | [`core.savedObjects.setClientFactoryProvider`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.setclientfactoryprovider.md) | | +| `server.savedObjects.addScopedSavedObjectsClientWrapperFactory` | [`core.savedObjects.addClientWrapper`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.addclientwrapper.md) | | | `server.savedObjects.getSavedObjectsRepository` | [`core.savedObjects.createInternalRepository`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicestart.createinternalrepository.md) [`core.savedObjects.createScopedRepository`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicestart.createscopedrepository.md) | | -| `server.savedObjects.getScopedSavedObjectsClient` | [`core.savedObjects.getScopedClient`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicestart.getscopedclient.md) | | -| `request.getSavedObjectsClient` | [`context.core.savedObjects.client`](/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md) | | +| `server.savedObjects.getScopedSavedObjectsClient` | [`core.savedObjects.getScopedClient`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicestart.getscopedclient.md) | | +| `request.getSavedObjectsClient` | [`context.core.savedObjects.client`](/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md) | | | `request.getUiSettingsService` | [`context.core.uiSettings.client`](/docs/development/core/server/kibana-plugin-core-server.iuisettingsclient.md) | | -| `kibana.Plugin.deprecations` | [Handle plugin configuration deprecations](#handle-plugin-config-deprecations) and [`PluginConfigDescriptor.deprecations`](docs/development/core/server/kibana-plugin-core-server.pluginconfigdescriptor.md) | Deprecations from New Platform are not applied to legacy configuration | -| `kibana.Plugin.savedObjectSchemas` | [`core.savedObjects.registerType`](docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | [Examples](./MIGRATION_EXAMPLES.md#saved-objects-types) | -| `kibana.Plugin.mappings` | [`core.savedObjects.registerType`](docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | [Examples](./MIGRATION_EXAMPLES.md#saved-objects-types) | -| `kibana.Plugin.migrations` | [`core.savedObjects.registerType`](docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | [Examples](./MIGRATION_EXAMPLES.md#saved-objects-types) | -| `kibana.Plugin.savedObjectsManagement` | [`core.savedObjects.registerType`](docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | [Examples](./MIGRATION_EXAMPLES.md#saved-objects-types) | +| `kibana.Plugin.deprecations` | [Handle plugin configuration deprecations](#handle-plugin-config-deprecations) and [`PluginConfigDescriptor.deprecations`](docs/development/core/server/kibana-plugin-core-server.pluginconfigdescriptor.md) | Deprecations from New Platform are not applied to legacy configuration | +| `kibana.Plugin.savedObjectSchemas` | [`core.savedObjects.registerType`](docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | [Examples](./MIGRATION_EXAMPLES.md#saved-objects-types) | +| `kibana.Plugin.mappings` | [`core.savedObjects.registerType`](docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | [Examples](./MIGRATION_EXAMPLES.md#saved-objects-types) | +| `kibana.Plugin.migrations` | [`core.savedObjects.registerType`](docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | [Examples](./MIGRATION_EXAMPLES.md#saved-objects-types) | +| `kibana.Plugin.savedObjectsManagement` | [`core.savedObjects.registerType`](docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | [Examples](./MIGRATION_EXAMPLES.md#saved-objects-types) | _See also: [Server's CoreSetup API Docs](/docs/development/core/server/kibana-plugin-core-server.coresetup.md)_ @@ -1433,8 +1495,9 @@ The above example looks in the new platform as ``` The [request handler context](/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md) exposed the next scoped **core** services: -| Legacy Platform | New Platform | -| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------| + +| Legacy Platform | New Platform | +| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `request.getSavedObjectsClient` | [`context.savedObjects.client`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md) | | `server.plugins.elasticsearch.getCluster('admin')` | [`context.elasticsearch.adminClient`](/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md) | | `server.plugins.elasticsearch.getCluster('data')` | [`context.elasticsearch.dataClient`](/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md) | diff --git a/src/core/MIGRATION_EXAMPLES.md b/src/core/MIGRATION_EXAMPLES.md index 37d0b9297ed3c..8c5fe4875aaea 100644 --- a/src/core/MIGRATION_EXAMPLES.md +++ b/src/core/MIGRATION_EXAMPLES.md @@ -700,21 +700,15 @@ application.register({ ## Render HTML Content You can return a blank HTML page bootstrapped with the core application bundle from an HTTP route handler -via the `rendering` context. You may wish to do this if you are rendering a chromeless application with a +via the `httpResources` service. You may wish to do this if you are rendering a chromeless application with a custom application route or have other custom rendering needs. -```ts -router.get( +```typescript +httpResources.register( { path: '/chromeless', validate: false }, (context, request, response) => { - const { http, rendering } = context.core; - - return response.ok({ - body: await rendering.render(), // generates an HTML document - headers: { - 'content-security-policy': http.csp.header, - }, - }); + //... some logic + return response.renderCoreApp(); } ); ``` @@ -724,18 +718,12 @@ comprises all UI Settings that are *user provided*, then injected into the page. You may wish to exclude fetching this data if not authorized or to slim the page size. -```ts -router.get( - { path: '/', validate: false }, +```typescript +httpResources.register( + { path: '/', validate: false, options: { authRequired: false } }, (context, request, response) => { - const { http, rendering } = context.core; - - return response.ok({ - body: await rendering.render({ includeUserSettings: false }), - headers: { - 'content-security-policy': http.csp.header, - }, - }); + //... some logic + return response.renderAnonymousCoreApp(); } ); ``` diff --git a/src/core/public/application/scoped_history.test.ts b/src/core/public/application/scoped_history.test.ts index c01eb50830516..a56cffef1e2f2 100644 --- a/src/core/public/application/scoped_history.test.ts +++ b/src/core/public/application/scoped_history.test.ts @@ -268,11 +268,32 @@ describe('ScopedHistory', () => { const gh = createMemoryHistory(); gh.push('/app/wow'); const h = new ScopedHistory(gh, '/app/wow'); - expect(h.createHref({ pathname: '' })).toEqual(`/`); + expect(h.createHref({ pathname: '' })).toEqual(`/app/wow`); + expect(h.createHref({})).toEqual(`/app/wow`); expect(h.createHref({ pathname: '/new-page', search: '?alpha=true' })).toEqual( - `/new-page?alpha=true` + `/app/wow/new-page?alpha=true` ); }); + + it('behave correctly with slash-ending basePath', () => { + const gh = createMemoryHistory(); + gh.push('/app/wow/'); + const h = new ScopedHistory(gh, '/app/wow/'); + expect(h.createHref({ pathname: '' })).toEqual(`/app/wow/`); + expect(h.createHref({ pathname: '/new-page', search: '?alpha=true' })).toEqual( + `/app/wow/new-page?alpha=true` + ); + }); + + it('skips the scoped history path when `prependBasePath` is false', () => { + const gh = createMemoryHistory(); + gh.push('/app/wow'); + const h = new ScopedHistory(gh, '/app/wow'); + expect(h.createHref({ pathname: '' }, { prependBasePath: false })).toEqual(`/`); + expect( + h.createHref({ pathname: '/new-page', search: '?alpha=true' }, { prependBasePath: false }) + ).toEqual(`/new-page?alpha=true`); + }); }); describe('action', () => { diff --git a/src/core/public/application/scoped_history.ts b/src/core/public/application/scoped_history.ts index c5febc7604feb..9fa8f0b7f8148 100644 --- a/src/core/public/application/scoped_history.ts +++ b/src/core/public/application/scoped_history.ts @@ -219,11 +219,26 @@ export class ScopedHistory /** * Creates an href (string) to the location. + * If `prependBasePath` is true (default), it will prepend the location's path with the scoped history basePath. * * @param location + * @param prependBasePath */ - public createHref = (location: LocationDescriptorObject): Href => { + public createHref = ( + location: LocationDescriptorObject, + { prependBasePath = true }: { prependBasePath?: boolean } = {} + ): Href => { this.verifyActive(); + if (prependBasePath) { + location = this.prependBasePath(location); + if (location.pathname === undefined) { + // we always want to create an url relative to the basePath + // so if pathname is not present, we use the history's basePath as default + // we are doing that here because `prependBasePath` should not + // alter pathname for other method calls + location.pathname = this.basePath; + } + } return this.parentHistory.createHref(location); }; @@ -254,8 +269,7 @@ export class ScopedHistory * Prepends the base path to string. */ private prependBasePathToString(path: string): string { - path = path.startsWith('/') ? path.slice(1) : path; - return path.length ? `${this.basePath}/${path}` : this.basePath; + return path.length ? `${this.basePath}/${path}`.replace(/\/{2,}/g, '/') : this.basePath; } /** diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index 0c4930592b233..959ffaa7e7e08 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -48,6 +48,7 @@ export { overlayServiceMock } from './overlays/overlay_service.mock'; export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; export { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; export { scopedHistoryMock } from './application/scoped_history.mock'; +export { applicationServiceMock } from './application/application_service.mock'; function createCoreSetupMock({ basePath = '', @@ -62,9 +63,8 @@ function createCoreSetupMock({ application: applicationServiceMock.createSetupContract(), context: contextServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), - getStartServices: jest.fn, object, any]>, []>( - () => - Promise.resolve([createCoreStartMock({ basePath }), pluginStartDeps, pluginStartContract]) + getStartServices: jest.fn, any, any]>, []>(() => + Promise.resolve([createCoreStartMock({ basePath }), pluginStartDeps, pluginStartContract]) ), http: httpServiceMock.createSetupContract({ basePath }), notifications: notificationServiceMock.createSetupContract(), diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 9f7f649f1e2a5..6d95d1bc7069c 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -956,6 +956,7 @@ export interface SavedObject { }; id: string; migrationVersion?: SavedObjectsMigrationVersion; + namespaces?: string[]; references: SavedObjectReference[]; type: string; updated_at?: string; @@ -1195,7 +1196,9 @@ export class ScopedHistory implements History | undefined) => UnregisterCallback; - createHref: (location: LocationDescriptorObject) => string; + createHref: (location: LocationDescriptorObject, { prependBasePath }?: { + prependBasePath?: boolean | undefined; + }) => string; createSubHistory: (basePath: string) => ScopedHistory; go: (n: number) => void; goBack: () => void; diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index 684f6e15caff9..18725f04a05b5 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -33,7 +33,12 @@ import { CoreService } from '../../types'; import { merge } from '../../utils'; import { CoreContext } from '../core_context'; import { Logger } from '../logging'; -import { ClusterClient, ScopeableRequest } from './cluster_client'; +import { + ClusterClient, + ScopeableRequest, + IClusterClient, + ICustomClusterClient, +} from './cluster_client'; import { ElasticsearchClientConfig } from './elasticsearch_client_config'; import { ElasticsearchConfig, ElasticsearchConfigType } from './elasticsearch_config'; import { InternalHttpServiceSetup, GetAuthHeaders } from '../http/'; @@ -58,12 +63,14 @@ export class ElasticsearchService implements CoreService { private readonly log: Logger; private readonly config$: Observable; - private subscription: Subscription | undefined; + private subscription?: Subscription; private stop$ = new Subject(); private kibanaVersion: string; - createClient: InternalElasticsearchServiceSetup['createClient'] | undefined; - dataClient: InternalElasticsearchServiceSetup['dataClient'] | undefined; - adminClient: InternalElasticsearchServiceSetup['adminClient'] | undefined; + private createClient?: ( + type: string, + clientConfig?: Partial + ) => ICustomClusterClient; + private adminClient?: IClusterClient; constructor(private readonly coreContext: CoreContext) { this.kibanaVersion = coreContext.env.packageInfo.version; diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index a75eb04fa0120..ca9dfde2e71dc 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -38,6 +38,7 @@ export { LifecycleResponseFactory, RedirectResponseOptions, RequestHandler, + RequestHandlerWrapper, ResponseError, ResponseErrorAttributes, ResponseHeaders, diff --git a/src/core/server/http/lifecycle/on_pre_response.ts b/src/core/server/http/lifecycle/on_pre_response.ts index 50d3d7b47bf8d..050881472bc80 100644 --- a/src/core/server/http/lifecycle/on_pre_response.ts +++ b/src/core/server/http/lifecycle/on_pre_response.ts @@ -148,7 +148,7 @@ function findHeadersIntersection( log: Logger ) { Object.keys(headers).forEach(headerName => { - if (responseHeaders[headerName] !== undefined) { + if (Reflect.has(responseHeaders, headerName)) { log.warn(`onPreResponseHandler rewrote a response header [${headerName}].`); } }); diff --git a/src/core/server/http/router/error_wrapper.ts b/src/core/server/http/router/error_wrapper.ts index 8f895753c38c3..af99812eff4b3 100644 --- a/src/core/server/http/router/error_wrapper.ts +++ b/src/core/server/http/router/error_wrapper.ts @@ -18,20 +18,10 @@ */ import Boom from 'boom'; -import { KibanaRequest } from './request'; -import { KibanaResponseFactory } from './response'; -import { RequestHandler } from './router'; -import { RequestHandlerContext } from '../../../server'; -import { RouteMethod } from './route'; +import { RequestHandlerWrapper } from './router'; -export const wrapErrors = ( - handler: RequestHandler -): RequestHandler => { - return async ( - context: RequestHandlerContext, - request: KibanaRequest, - response: KibanaResponseFactory - ) => { +export const wrapErrors: RequestHandlerWrapper = handler => { + return async (context, request, response) => { try { return await handler(context, request, response); } catch (e) { diff --git a/src/core/server/http/router/headers.ts b/src/core/server/http/router/headers.ts index 19eaee5081996..b79cc0d325f1e 100644 --- a/src/core/server/http/router/headers.ts +++ b/src/core/server/http/router/headers.ts @@ -56,9 +56,9 @@ export type Headers = { [header in KnownHeaders]?: string | string[] | undefined * Http response headers to set. * @public */ -export type ResponseHeaders = { [header in KnownHeaders]?: string | string[] } & { - [header: string]: string | string[]; -}; +export type ResponseHeaders = + | Record + | Record; const normalizeHeaderField = (field: string) => field.trim().toLowerCase(); diff --git a/src/core/server/http/router/index.ts b/src/core/server/http/router/index.ts index d254f391ca5e4..83ceff4a25d86 100644 --- a/src/core/server/http/router/index.ts +++ b/src/core/server/http/router/index.ts @@ -18,7 +18,7 @@ */ export { Headers, filterHeaders, ResponseHeaders, KnownHeaders } from './headers'; -export { Router, RequestHandler, IRouter, RouteRegistrar } from './router'; +export { Router, RequestHandler, RequestHandlerWrapper, IRouter, RouteRegistrar } from './router'; export { KibanaRequest, KibanaRequestEvents, diff --git a/src/core/server/http/router/router.ts b/src/core/server/http/router/router.ts index bb56ee3727d1a..b4e7fc2a989b6 100644 --- a/src/core/server/http/router/router.ts +++ b/src/core/server/http/router/router.ts @@ -98,7 +98,7 @@ export interface IRouter { * Wrap a router handler to catch and converts legacy boom errors to proper custom errors. * @param handler {@link RequestHandler} - a route handler to wrap */ - handleLegacyErrors: (handler: RequestHandler) => RequestHandler; + handleLegacyErrors: RequestHandlerWrapper; /** * Returns all routes registered with this router. @@ -237,9 +237,7 @@ export class Router implements IRouter { return [...this.routes]; } - public handleLegacyErrors(handler: RequestHandler): RequestHandler { - return wrapErrors(handler); - } + public handleLegacyErrors = wrapErrors; private async handle({ routeSchemas, @@ -316,9 +314,33 @@ export type RequestHandler< P = unknown, Q = unknown, B = unknown, - Method extends RouteMethod = any + Method extends RouteMethod = any, + ResponseFactory extends KibanaResponseFactory = KibanaResponseFactory > = ( context: RequestHandlerContext, request: KibanaRequest, - response: KibanaResponseFactory + response: ResponseFactory ) => IKibanaResponse | Promise>; + +/** + * Type-safe wrapper for {@link RequestHandler} function. + * @example + * ```typescript + * export const wrapper: RequestHandlerWrapper = handler => { + * return async (context, request, response) => { + * // do some logic + * ... + * }; + * } + * ``` + * @public + */ +export type RequestHandlerWrapper = < + P, + Q, + B, + Method extends RouteMethod = any, + ResponseFactory extends KibanaResponseFactory = KibanaResponseFactory +>( + handler: RequestHandler +) => RequestHandler; diff --git a/src/core/server/http_resources/http_resources_service.mock.ts b/src/core/server/http_resources/http_resources_service.mock.ts new file mode 100644 index 0000000000000..4536b0898cad9 --- /dev/null +++ b/src/core/server/http_resources/http_resources_service.mock.ts @@ -0,0 +1,50 @@ +/* + * 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 { httpServerMock } from '../http/http_server.mocks'; +import { HttpResources, HttpResourcesServiceToolkit } from './types'; + +const createHttpResourcesMock = (): jest.Mocked => ({ + register: jest.fn(), +}); + +function createInternalHttpResourcesSetup() { + return { + createRegistrar: createHttpResourcesMock, + }; +} + +function createHttpResourcesResponseFactory() { + const mocked: jest.Mocked = { + renderCoreApp: jest.fn(), + renderAnonymousCoreApp: jest.fn(), + renderHtml: jest.fn(), + renderJs: jest.fn(), + }; + + return { + ...httpServerMock.createResponseFactory(), + ...mocked, + }; +} + +export const httpResourcesMock = { + createRegistrar: createHttpResourcesMock, + createSetupContract: createInternalHttpResourcesSetup, + createResponseFactory: createHttpResourcesResponseFactory, +}; diff --git a/src/core/server/http_resources/http_resources_service.test.ts b/src/core/server/http_resources/http_resources_service.test.ts new file mode 100644 index 0000000000000..e6f129ba12d78 --- /dev/null +++ b/src/core/server/http_resources/http_resources_service.test.ts @@ -0,0 +1,258 @@ +/* + * 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 { IRouter, RouteConfig } from '../http'; + +import { coreMock } from '../mocks'; +import { mockCoreContext } from '../core_context.mock'; +import { httpServiceMock } from '../http/http_service.mock'; +import { httpServerMock } from '../http/http_server.mocks'; +import { renderingMock } from '../rendering/rendering_service.mock'; +import { HttpResourcesService, SetupDeps } from './http_resources_service'; +import { httpResourcesMock } from './http_resources_service.mock'; + +const coreContext = mockCoreContext.create(); + +describe('HttpResources service', () => { + let service: HttpResourcesService; + let setupDeps: SetupDeps; + let router: jest.Mocked; + const kibanaRequest = httpServerMock.createKibanaRequest(); + const context = { core: coreMock.createRequestHandlerContext() }; + describe('#createRegistrar', () => { + beforeEach(() => { + setupDeps = { + http: httpServiceMock.createSetupContract(), + rendering: renderingMock.createSetupContract(), + }; + service = new HttpResourcesService(coreContext); + router = httpServiceMock.createRouter(); + }); + + describe('register', () => { + describe('renderCoreApp', () => { + it('formats successful response', async () => { + const routeConfig: RouteConfig = { path: '/', validate: false }; + const { createRegistrar } = await service.setup(setupDeps); + const { register } = createRegistrar(router); + register(routeConfig, async (ctx, req, res) => { + return res.renderCoreApp(); + }); + const [[, routeHandler]] = router.get.mock.calls; + + const responseFactory = httpResourcesMock.createResponseFactory(); + await routeHandler(context, kibanaRequest, responseFactory); + expect(setupDeps.rendering.render).toHaveBeenCalledWith( + kibanaRequest, + context.core.uiSettings.client, + { + includeUserSettings: true, + } + ); + }); + + it('can attach headers, except the CSP header', async () => { + const routeConfig: RouteConfig = { path: '/', validate: false }; + const { createRegistrar } = await service.setup(setupDeps); + const { register } = createRegistrar(router); + register(routeConfig, async (ctx, req, res) => { + return res.renderCoreApp({ + headers: { + 'content-security-policy': "script-src 'unsafe-eval'", + 'x-kibana': '42', + }, + }); + }); + + const [[, routeHandler]] = router.get.mock.calls; + + const responseFactory = httpResourcesMock.createResponseFactory(); + await routeHandler(context, kibanaRequest, responseFactory); + + expect(responseFactory.ok).toHaveBeenCalledWith({ + body: '', + headers: { + 'x-kibana': '42', + 'content-security-policy': + "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'", + }, + }); + }); + }); + describe('renderAnonymousCoreApp', () => { + it('formats successful response', async () => { + const routeConfig: RouteConfig = { path: '/', validate: false }; + const { createRegistrar } = await service.setup(setupDeps); + const { register } = createRegistrar(router); + register(routeConfig, async (ctx, req, res) => { + return res.renderAnonymousCoreApp(); + }); + const [[, routeHandler]] = router.get.mock.calls; + + const responseFactory = httpResourcesMock.createResponseFactory(); + await routeHandler(context, kibanaRequest, responseFactory); + expect(setupDeps.rendering.render).toHaveBeenCalledWith( + kibanaRequest, + context.core.uiSettings.client, + { + includeUserSettings: false, + } + ); + }); + + it('can attach headers, except the CSP header', async () => { + const routeConfig: RouteConfig = { path: '/', validate: false }; + const { createRegistrar } = await service.setup(setupDeps); + const { register } = createRegistrar(router); + register(routeConfig, async (ctx, req, res) => { + return res.renderAnonymousCoreApp({ + headers: { + 'content-security-policy': "script-src 'unsafe-eval'", + 'x-kibana': '42', + }, + }); + }); + + const [[, routeHandler]] = router.get.mock.calls; + + const responseFactory = httpResourcesMock.createResponseFactory(); + await routeHandler(context, kibanaRequest, responseFactory); + + expect(responseFactory.ok).toHaveBeenCalledWith({ + body: '', + headers: { + 'x-kibana': '42', + 'content-security-policy': + "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'", + }, + }); + }); + }); + describe('renderHtml', () => { + it('formats successful response', async () => { + const htmlBody = ''; + const routeConfig: RouteConfig = { path: '/', validate: false }; + const { createRegistrar } = await service.setup(setupDeps); + const { register } = createRegistrar(router); + register(routeConfig, async (ctx, req, res) => { + return res.renderHtml({ body: htmlBody }); + }); + const [[, routeHandler]] = router.get.mock.calls; + + const responseFactory = httpResourcesMock.createResponseFactory(); + await routeHandler(context, kibanaRequest, responseFactory); + expect(responseFactory.ok).toHaveBeenCalledWith({ + body: htmlBody, + headers: { + 'content-type': 'text/html', + 'content-security-policy': + "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'", + }, + }); + }); + + it('can attach headers, except the CSP & "content-type" headers', async () => { + const htmlBody = ''; + const routeConfig: RouteConfig = { path: '/', validate: false }; + const { createRegistrar } = await service.setup(setupDeps); + const { register } = createRegistrar(router); + register(routeConfig, async (ctx, req, res) => { + return res.renderHtml({ + body: htmlBody, + headers: { + 'content-type': 'text/html5', + 'content-security-policy': "script-src 'unsafe-eval'", + 'x-kibana': '42', + }, + }); + }); + + const [[, routeHandler]] = router.get.mock.calls; + + const responseFactory = httpResourcesMock.createResponseFactory(); + await routeHandler(context, kibanaRequest, responseFactory); + + expect(responseFactory.ok).toHaveBeenCalledWith({ + body: htmlBody, + headers: { + 'content-type': 'text/html', + 'x-kibana': '42', + 'content-security-policy': + "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'", + }, + }); + }); + }); + describe('renderJs', () => { + it('formats successful response', async () => { + const jsBody = 'alert(1);'; + const routeConfig: RouteConfig = { path: '/', validate: false }; + const { createRegistrar } = await service.setup(setupDeps); + const { register } = createRegistrar(router); + register(routeConfig, async (ctx, req, res) => { + return res.renderJs({ body: jsBody }); + }); + const [[, routeHandler]] = router.get.mock.calls; + + const responseFactory = httpResourcesMock.createResponseFactory(); + await routeHandler(context, kibanaRequest, responseFactory); + expect(responseFactory.ok).toHaveBeenCalledWith({ + body: jsBody, + headers: { + 'content-type': 'text/javascript', + 'content-security-policy': + "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'", + }, + }); + }); + + it('can attach headers, except the CSP & "content-type" headers', async () => { + const jsBody = 'alert(1);'; + const routeConfig: RouteConfig = { path: '/', validate: false }; + const { createRegistrar } = await service.setup(setupDeps); + const { register } = createRegistrar(router); + register(routeConfig, async (ctx, req, res) => { + return res.renderJs({ + body: jsBody, + headers: { + 'content-type': 'text/html', + 'content-security-policy': "script-src 'unsafe-eval'", + 'x-kibana': '42', + }, + }); + }); + + const [[, routeHandler]] = router.get.mock.calls; + + const responseFactory = httpResourcesMock.createResponseFactory(); + await routeHandler(context, kibanaRequest, responseFactory); + + expect(responseFactory.ok).toHaveBeenCalledWith({ + body: jsBody, + headers: { + 'content-type': 'text/javascript', + 'x-kibana': '42', + 'content-security-policy': + "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'", + }, + }); + }); + }); + }); + }); +}); diff --git a/src/core/server/http_resources/http_resources_service.ts b/src/core/server/http_resources/http_resources_service.ts new file mode 100644 index 0000000000000..bc79ad68f4099 --- /dev/null +++ b/src/core/server/http_resources/http_resources_service.ts @@ -0,0 +1,130 @@ +/* + * 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 { RequestHandlerContext } from 'src/core/server'; + +import { CoreContext } from '../core_context'; +import { + IRouter, + RouteConfig, + InternalHttpServiceSetup, + KibanaRequest, + KibanaResponseFactory, +} from '../http'; + +import { Logger } from '../logging'; +import { InternalRenderingServiceSetup } from '../rendering'; +import { CoreService } from '../../types'; + +import { + InternalHttpResourcesSetup, + HttpResources, + HttpResourcesResponseOptions, + HttpResourcesRenderOptions, + HttpResourcesRequestHandler, + HttpResourcesServiceToolkit, +} from './types'; + +export interface SetupDeps { + http: InternalHttpServiceSetup; + rendering: InternalRenderingServiceSetup; +} + +export class HttpResourcesService implements CoreService { + private readonly logger: Logger; + constructor(core: CoreContext) { + this.logger = core.logger.get('http-resources'); + } + + setup(deps: SetupDeps) { + this.logger.debug('setting up HttpResourcesService'); + return { + createRegistrar: this.createRegistrar.bind(this, deps), + }; + } + + start() {} + stop() {} + + private createRegistrar(deps: SetupDeps, router: IRouter): HttpResources { + return { + register: ( + route: RouteConfig, + handler: HttpResourcesRequestHandler + ) => { + return router.get(route, (context, request, response) => { + return handler(context, request, { + ...response, + ...this.createResponseToolkit(deps, context, request, response), + }); + }); + }, + }; + } + + private createResponseToolkit( + deps: SetupDeps, + context: RequestHandlerContext, + request: KibanaRequest, + response: KibanaResponseFactory + ): HttpResourcesServiceToolkit { + const cspHeader = deps.http.csp.header; + return { + async renderCoreApp(options: HttpResourcesRenderOptions = {}) { + const body = await deps.rendering.render(request, context.core.uiSettings.client, { + includeUserSettings: true, + }); + + return response.ok({ + body, + headers: { ...options.headers, 'content-security-policy': cspHeader }, + }); + }, + async renderAnonymousCoreApp(options: HttpResourcesRenderOptions = {}) { + const body = await deps.rendering.render(request, context.core.uiSettings.client, { + includeUserSettings: false, + }); + + return response.ok({ + body, + headers: { ...options.headers, 'content-security-policy': cspHeader }, + }); + }, + renderHtml(options: HttpResourcesResponseOptions) { + return response.ok({ + body: options.body, + headers: { + ...options.headers, + 'content-type': 'text/html', + 'content-security-policy': cspHeader, + }, + }); + }, + renderJs(options: HttpResourcesResponseOptions) { + return response.ok({ + body: options.body, + headers: { + ...options.headers, + 'content-type': 'text/javascript', + 'content-security-policy': cspHeader, + }, + }); + }, + }; + } +} diff --git a/src/core/server/http_resources/index.ts b/src/core/server/http_resources/index.ts new file mode 100644 index 0000000000000..b373c6a9efa89 --- /dev/null +++ b/src/core/server/http_resources/index.ts @@ -0,0 +1,29 @@ +/* + * 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 { HttpResourcesService } from './http_resources_service'; + +export { + HttpResourcesRenderOptions, + HttpResourcesResponseOptions, + HttpResourcesServiceToolkit, + HttpResourcesRequestHandler, + HttpResources, + InternalHttpResourcesSetup, +} from './types'; diff --git a/src/core/server/http_resources/integration_tests/http_resources_service.test.ts b/src/core/server/http_resources/integration_tests/http_resources_service.test.ts new file mode 100644 index 0000000000000..0a5daa02e17e9 --- /dev/null +++ b/src/core/server/http_resources/integration_tests/http_resources_service.test.ts @@ -0,0 +1,203 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import * as kbnTestServer from '../../../../test_utils/kbn_server'; + +describe('http resources service', () => { + describe('register', () => { + let root: ReturnType; + const defaultCspRules = "script-src 'self'"; + beforeEach(async () => { + root = kbnTestServer.createRoot({ + csp: { + rules: [defaultCspRules], + }, + }); + }, 30000); + + afterEach(async () => { + await root.shutdown(); + }); + + describe('renderAnonymousCoreApp', () => { + it('renders core application', async () => { + const { http, httpResources } = await root.setup(); + + const router = http.createRouter(''); + const resources = httpResources.createRegistrar(router); + resources.register({ path: '/render-core', validate: false }, (context, req, res) => + res.renderAnonymousCoreApp() + ); + + await root.start(); + const response = await kbnTestServer.request.get(root, '/render-core').expect(200); + + expect(response.text.length).toBeGreaterThan(0); + }); + + it('attaches CSP header', async () => { + const { http, httpResources } = await root.setup(); + + const router = http.createRouter(''); + const resources = httpResources.createRegistrar(router); + resources.register({ path: '/render-core', validate: false }, (context, req, res) => + res.renderAnonymousCoreApp() + ); + + await root.start(); + const response = await kbnTestServer.request.get(root, '/render-core').expect(200); + + expect(response.header['content-security-policy']).toBe(defaultCspRules); + }); + + it('can attach headers, except the CSP header', async () => { + const { http, httpResources } = await root.setup(); + + const router = http.createRouter(''); + const resources = httpResources.createRegistrar(router); + resources.register({ path: '/render-core', validate: false }, (context, req, res) => + res.renderAnonymousCoreApp({ + headers: { + 'content-security-policy': "script-src 'unsafe-eval'", + 'x-kibana': '42', + }, + }) + ); + + await root.start(); + const response = await kbnTestServer.request.get(root, '/render-core').expect(200); + + expect(response.header['content-security-policy']).toBe(defaultCspRules); + expect(response.header['x-kibana']).toBe('42'); + }); + }); + + describe('custom renders', () => { + it('renders html', async () => { + const { http, httpResources } = await root.setup(); + + const router = http.createRouter(''); + const resources = httpResources.createRegistrar(router); + const htmlBody = ` + + + +

HTML body

+ + + `; + resources.register({ path: '/render-html', validate: false }, (context, req, res) => + res.renderHtml({ body: htmlBody }) + ); + + await root.start(); + const response = await kbnTestServer.request.get(root, '/render-html').expect(200); + + expect(response.text).toBe(htmlBody); + expect(response.header['content-type']).toBe('text/html; charset=utf-8'); + }); + + it('renders javascript', async () => { + const { http, httpResources } = await root.setup(); + + const router = http.createRouter(''); + const resources = httpResources.createRegistrar(router); + const jsBody = 'window.alert("from js body");'; + resources.register({ path: '/render-js', validate: false }, (context, req, res) => + res.renderJs({ body: jsBody }) + ); + + await root.start(); + const response = await kbnTestServer.request.get(root, '/render-js').expect(200); + + expect(response.text).toBe(jsBody); + expect(response.header['content-type']).toBe('text/javascript; charset=utf-8'); + }); + + it('attaches CSP header', async () => { + const { http, httpResources } = await root.setup(); + + const router = http.createRouter(''); + const resources = httpResources.createRegistrar(router); + const htmlBody = ` + + + +

HTML body

+ + + `; + resources.register({ path: '/render-html', validate: false }, (context, req, res) => + res.renderHtml({ body: htmlBody }) + ); + + await root.start(); + const response = await kbnTestServer.request.get(root, '/render-html').expect(200); + + expect(response.header['content-security-policy']).toBe(defaultCspRules); + }); + + it('can attach headers, except the CSP & "content-type" headers', async () => { + const { http, httpResources } = await root.setup(); + + const router = http.createRouter(''); + const resources = httpResources.createRegistrar(router); + resources.register({ path: '/render-core', validate: false }, (context, req, res) => + res.renderHtml({ + body: '

Hi

', + headers: { + 'content-security-policy': "script-src 'unsafe-eval'", + 'content-type': 'text/html', + 'x-kibana': '42', + }, + }) + ); + + await root.start(); + const response = await kbnTestServer.request.get(root, '/render-core').expect(200); + + expect(response.header['content-security-policy']).toBe(defaultCspRules); + expect(response.header['x-kibana']).toBe('42'); + }); + + it('can adjust route config', async () => { + const { http, httpResources } = await root.setup(); + + const router = http.createRouter(''); + const resources = httpResources.createRegistrar(router); + const validate = { + params: schema.object({ + id: schema.string(), + }), + }; + + resources.register({ path: '/render-js-with-param/{id}', validate }, (context, req, res) => + res.renderJs({ body: `window.alert(${req.params.id});` }) + ); + + await root.start(); + const response = await kbnTestServer.request + .get(root, '/render-js-with-param/42') + .expect(200); + + expect(response.text).toBe('window.alert(42);'); + }); + }); + }); +}); diff --git a/src/core/server/http_resources/types.ts b/src/core/server/http_resources/types.ts new file mode 100644 index 0000000000000..d761e2def1023 --- /dev/null +++ b/src/core/server/http_resources/types.ts @@ -0,0 +1,116 @@ +/* + * 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 { + IRouter, + RouteConfig, + IKibanaResponse, + ResponseHeaders, + HttpResponseOptions, + KibanaResponseFactory, + RequestHandler, +} from '../http'; + +/** + * Allows to configure HTTP response parameters + * @public + */ +export interface HttpResourcesRenderOptions { + /** + * HTTP Headers with additional information about response. + * @remarks + * All HTML pages are already pre-configured with `content-security-policy` header that cannot be overridden. + * */ + headers?: ResponseHeaders; +} + +/** + * HTTP Resources response parameters + * @public + */ +export type HttpResourcesResponseOptions = HttpResponseOptions; + +/** + * Extended set of {@link KibanaResponseFactory} helpers used to respond with HTML or JS resource. + * @public + */ +export interface HttpResourcesServiceToolkit { + /** To respond with HTML page bootstrapping Kibana application. */ + renderCoreApp: (options?: HttpResourcesRenderOptions) => Promise; + /** To respond with HTML page bootstrapping Kibana application without retrieving user-specific information. */ + renderAnonymousCoreApp: (options?: HttpResourcesRenderOptions) => Promise; + /** To respond with a custom HTML page. */ + renderHtml: (options: HttpResourcesResponseOptions) => IKibanaResponse; + /** To respond with a custom JS script file. */ + renderJs: (options: HttpResourcesResponseOptions) => IKibanaResponse; +} + +/** + * Extended version of {@link RequestHandler} having access to {@link HttpResourcesServiceToolkit} + * to respond with HTML or JS resources. + * @param context {@link RequestHandlerContext} - the core context exposed for this request. + * @param request {@link KibanaRequest} - object containing information about requested resource, + * such as path, method, headers, parameters, query, body, etc. + * @param response {@link KibanaResponseFactory} {@libk HttpResourcesServiceToolkit} - a set of helper functions used to respond to a request. + * + * @example + * ```typescript + * httpResources.register({ + * path: '/login', + * validate: { + * params: schema.object({ id: schema.string() }), + * }, + * }, + * async (context, request, response) => { + * //.. + * return response.renderCoreApp(); + * }); + * @public + */ +export type HttpResourcesRequestHandler

= RequestHandler< + P, + Q, + B, + 'get', + KibanaResponseFactory & HttpResourcesServiceToolkit +>; + +/** + * Allows to configure HTTP response parameters + * @internal + */ +export interface InternalHttpResourcesSetup { + createRegistrar(router: IRouter): HttpResources; +} + +/** + * HttpResources service is responsible for serving static & dynamic assets for Kibana application via HTTP. + * Provides API allowing plug-ins to respond with: + * - a pre-configured HTML page bootstrapping Kibana client app + * - custom HTML page + * - custom JS script file. + * @public + */ +export interface HttpResources { + /** To register a route handler executing passed function to form response. */ + register: ( + route: RouteConfig, + handler: HttpResourcesRequestHandler + ) => void; +} diff --git a/src/core/server/index.ts b/src/core/server/index.ts index a298f80f96d8f..ef57fae159d7e 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -47,7 +47,8 @@ import { } from './elasticsearch'; import { HttpServiceSetup } from './http'; -import { IScopedRenderingClient } from './rendering'; +import { HttpResources } from './http_resources'; + import { PluginsServiceSetup, PluginsServiceStart, PluginOpaqueId } from './plugins'; import { ContextSetup } from './context'; import { IUiSettingsClient, UiSettingsServiceSetup, UiSettingsServiceStart } from './ui_settings'; @@ -146,6 +147,7 @@ export { OnPreResponseInfo, RedirectResponseOptions, RequestHandler, + RequestHandlerWrapper, RequestHandlerContextContainer, RequestHandlerContextProvider, ResponseError, @@ -175,7 +177,15 @@ export { DestructiveRouteMethod, SafeRouteMethod, } from './http'; -export { RenderingServiceSetup, IRenderOptions } from './rendering'; + +export { + HttpResourcesRenderOptions, + HttpResourcesResponseOptions, + HttpResourcesServiceToolkit, + HttpResourcesRequestHandler, +} from './http_resources'; + +export { IRenderOptions } from './rendering'; export { Logger, LoggerFactory, LogMeta, LogRecord, LogLevel } from './logging'; export { @@ -227,6 +237,8 @@ export { SavedObjectsLegacyService, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, + SavedObjectsAddToNamespacesOptions, + SavedObjectsDeleteFromNamespacesOptions, SavedObjectsServiceStart, SavedObjectsServiceSetup, SavedObjectStatusMeta, @@ -242,6 +254,7 @@ export { SavedObjectsMappingProperties, SavedObjectTypeRegistry, ISavedObjectTypeRegistry, + SavedObjectsNamespaceType, SavedObjectsType, SavedObjectsTypeManagementDefinition, SavedObjectMigrationMap, @@ -310,8 +323,6 @@ export { * Plugin specific context passed to a route handler. * * Provides the following clients and services: - * - {@link IScopedRenderingClient | rendering} - Rendering client - * which uses the data of the incoming request * - {@link SavedObjectsClient | savedObjects.client} - Saved Objects client * which uses the credentials of the incoming request * - {@link ISavedObjectTypeRegistry | savedObjects.typeRegistry} - Type registry containing @@ -327,7 +338,6 @@ export { */ export interface RequestHandlerContext { core: { - rendering: IScopedRenderingClient; savedObjects: { client: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; @@ -359,7 +369,10 @@ export interface CoreSetup { getAuthHeaders: () => undefined, } as any, }, + httpResources: httpResourcesMock.createSetupContract(), savedObjects: savedObjectsServiceMock.createInternalSetupContract(), plugins: { initialized: true, contracts: new Map([['plugin-id', 'plugin-value']]), - uiPlugins: { - public: new Map([['plugin-id', {} as DiscoveredPlugin]]), - internal: new Map([ - [ - 'plugin-id', - { - publicTargetDir: 'path/to/target/public', - publicAssetsDir: '/plugins/name/assets/', - }, - ], - ]), - browserConfigs: new Map(), - }, }, rendering: renderingServiceMock, metrics: metricsServiceMock.createInternalSetupContract(), @@ -110,6 +99,19 @@ beforeEach(() => { status: statusServiceMock.createInternalSetupContract(), }, plugins: { 'plugin-id': 'plugin-value' }, + uiPlugins: { + public: new Map([['plugin-id', {} as DiscoveredPlugin]]), + internal: new Map([ + [ + 'plugin-id', + { + publicTargetDir: 'path/to/target/public', + publicAssetsDir: '/plugins/name/assets/', + }, + ], + ]), + browserConfigs: new Map(), + }, }; startDeps = { diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index f77230301ce02..fd3ebfe120599 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -269,6 +269,7 @@ export class LegacyService implements CoreService { uiSettings: { asScopedToClient: startDeps.core.uiSettings.asScopedToClient }, }; + const router = setupDeps.core.http.createRouter('', this.legacyId); const coreSetup: CoreSetup = { capabilities: setupDeps.core.capabilities, context: setupDeps.core.context, @@ -283,7 +284,8 @@ export class LegacyService implements CoreService { null, this.legacyId ), - createRouter: () => setupDeps.core.http.createRouter('', this.legacyId), + createRouter: () => router, + resources: setupDeps.core.httpResources.createRegistrar(router), registerOnPreAuth: setupDeps.core.http.registerOnPreAuth, registerAuth: setupDeps.core.http.registerAuth, registerOnPostAuth: setupDeps.core.http.registerOnPostAuth, @@ -342,7 +344,7 @@ export class LegacyService implements CoreService { }, hapiServer: setupDeps.core.http.server, kibanaMigrator: startDeps.core.savedObjects.migrator, - uiPlugins: setupDeps.core.plugins.uiPlugins, + uiPlugins: setupDeps.uiPlugins, elasticsearch: setupDeps.core.elasticsearch, rendering: setupDeps.core.rendering, uiSettings: setupDeps.core.uiSettings, diff --git a/src/core/server/legacy/types.ts b/src/core/server/legacy/types.ts index 0c1a7730f92a7..38cb9e6835ce9 100644 --- a/src/core/server/legacy/types.ts +++ b/src/core/server/legacy/types.ts @@ -22,8 +22,8 @@ import { Server } from 'hapi'; import { ChromeNavLink } from '../../public'; import { KibanaRequest, LegacyRequest } from '../http'; import { InternalCoreSetup, InternalCoreStart } from '../internal_types'; -import { PluginsServiceSetup, PluginsServiceStart } from '../plugins'; -import { RenderingServiceSetup } from '../rendering'; +import { PluginsServiceSetup, PluginsServiceStart, UiPlugins } from '../plugins'; +import { InternalRenderingServiceSetup } from '../rendering'; import { SavedObjectsLegacyUiExports } from '../types'; /** @@ -34,7 +34,7 @@ export type LegacyVars = Record; type LegacyCoreSetup = InternalCoreSetup & { plugins: PluginsServiceSetup; - rendering: RenderingServiceSetup; + rendering: InternalRenderingServiceSetup; }; type LegacyCoreStart = InternalCoreStart & { plugins: PluginsServiceStart }; @@ -173,6 +173,7 @@ export type LegacyUiExports = SavedObjectsLegacyUiExports & { export interface LegacyServiceSetupDeps { core: LegacyCoreSetup; plugins: Record; + uiPlugins: UiPlugins; } /** diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index faf73044cac4d..3b9a39db72278 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -23,10 +23,12 @@ import { CspConfig } from './csp'; import { loggingServiceMock } from './logging/logging_service.mock'; import { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service.mock'; import { httpServiceMock } from './http/http_service.mock'; +import { httpResourcesMock } from './http_resources/http_resources_service.mock'; import { contextServiceMock } from './context/context_service.mock'; import { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; import { savedObjectsClientMock } from './saved_objects/service/saved_objects_client.mock'; import { typeRegistryMock as savedObjectsTypeRegistryMock } from './saved_objects/saved_objects_type_registry.mock'; +import { renderingMock } from './rendering/rendering_service.mock'; import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { SharedGlobalConfig } from './plugins'; import { InternalCoreSetup, InternalCoreStart } from './internal_types'; @@ -36,6 +38,7 @@ import { uuidServiceMock } from './uuid/uuid_service.mock'; import { statusServiceMock } from './status/status_service.mock'; export { httpServerMock } from './http/http_server.mocks'; +export { httpResourcesMock } from './http_resources/http_resources_service.mock'; export { sessionStorageMock } from './http/cookie_session_storage.mocks'; export { configServiceMock } from './config/config_service.mock'; export { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service.mock'; @@ -45,6 +48,7 @@ export { savedObjectsRepositoryMock } from './saved_objects/service/lib/reposito export { typeRegistryMock as savedObjectsTypeRegistryMock } from './saved_objects/saved_objects_type_registry.mock'; export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; export { metricsServiceMock } from './metrics/metrics_service.mock'; +export { renderingMock } from './rendering/rendering_service.mock'; export function pluginInitializerContextConfigMock(config: T) { const globalConfig: SharedGlobalConfig = { @@ -120,6 +124,7 @@ function createCoreSetupMock({ get: httpService.auth.get, isAuthenticated: httpService.auth.isAuthenticated, }, + resources: httpResourcesMock.createRegistrar(), getServerInfo: httpService.getServerInfo, }; httpMock.createRouter.mockImplementation(() => httpService.createRouter('')); @@ -167,6 +172,8 @@ function createInternalCoreSetupMock() { savedObjects: savedObjectsServiceMock.createInternalSetupContract(), status: statusServiceMock.createInternalSetupContract(), uuid: uuidServiceMock.createSetupContract(), + httpResources: httpResourcesMock.createSetupContract(), + rendering: renderingMock.createSetupContract(), uiSettings: uiSettingsServiceMock.createSetupContract(), }; return setupDeps; @@ -184,9 +191,6 @@ function createInternalCoreStartMock() { function createCoreRequestHandlerContextMock() { return { - rendering: { - render: jest.fn(), - }, savedObjects: { client: savedObjectsClientMock.create(), typeRegistry: savedObjectsTypeRegistryMock.create(), diff --git a/src/core/server/plugins/index.ts b/src/core/server/plugins/index.ts index c7ef213c8f187..e480de750bb1a 100644 --- a/src/core/server/plugins/index.ts +++ b/src/core/server/plugins/index.ts @@ -17,7 +17,12 @@ * under the License. */ -export { PluginsService, PluginsServiceSetup, PluginsServiceStart } from './plugins_service'; +export { + PluginsService, + PluginsServiceSetup, + PluginsServiceStart, + UiPlugins, +} from './plugins_service'; export { config } from './plugins_config'; /** @internal */ export { isNewPlatformPlugin } from './discovery'; diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index 61d97aea97459..ab18a9cbbc062 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -136,6 +136,8 @@ export function createPluginSetupContext( deps: PluginsServiceSetupDeps, plugin: PluginWrapper ): CoreSetup { + const router = deps.http.createRouter('', plugin.opaqueId); + return { capabilities: { registerProvider: deps.capabilities.registerProvider, @@ -155,7 +157,8 @@ export function createPluginSetupContext( null, plugin.opaqueId ), - createRouter: () => deps.http.createRouter('', plugin.opaqueId), + createRouter: () => router, + resources: deps.httpResources.createRegistrar(router), registerOnPreAuth: deps.http.registerOnPreAuth, registerAuth: deps.http.registerAuth, registerOnPostAuth: deps.http.registerOnPostAuth, diff --git a/src/core/server/plugins/plugins_service.mock.ts b/src/core/server/plugins/plugins_service.mock.ts index 29e5b83b2e4c7..a40566767ddae 100644 --- a/src/core/server/plugins/plugins_service.mock.ts +++ b/src/core/server/plugins/plugins_service.mock.ts @@ -23,14 +23,10 @@ type PluginsServiceMock = jest.Mocked>; const createSetupContractMock = (): PluginsServiceSetup => ({ contracts: new Map(), - uiPlugins: { - browserConfigs: new Map(), - internal: new Map(), - public: new Map(), - }, initialized: true, }); const createStartContractMock = () => ({ contracts: new Map() }); + const createServiceMock = (): PluginsServiceMock => ({ discover: jest.fn(), setup: jest.fn().mockResolvedValue(createSetupContractMock()), @@ -38,8 +34,17 @@ const createServiceMock = (): PluginsServiceMock => ({ stop: jest.fn(), }); +function createUiPlugins() { + return { + browserConfigs: new Map(), + internal: new Map(), + public: new Map(), + }; +} + export const pluginServiceMock = { create: createServiceMock, createSetupContract: createSetupContractMock, createStartContract: createStartContractMock, + createUiPlugins, }; diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index 14147ab9f2a8d..38fda12bd290f 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -120,6 +120,7 @@ describe('PluginsService', () => { pluginsService = new PluginsService({ coreId, env, logger, configService }); [mockPluginSystem] = MockPluginsSystem.mock.instances as any; + mockPluginSystem.uiPlugins.mockReturnValue(new Map()); }); afterEach(() => { @@ -202,7 +203,6 @@ describe('PluginsService', () => { .mockImplementation(path => Promise.resolve(!path.includes('disabled'))); mockPluginSystem.setupPlugins.mockResolvedValue(new Map()); - mockPluginSystem.uiPlugins.mockReturnValue(new Map()); mockDiscover.mockReturnValue({ error$: from([]), @@ -234,8 +234,6 @@ describe('PluginsService', () => { const setup = await pluginsService.setup(setupDeps); expect(setup.contracts).toBeInstanceOf(Map); - expect(setup.uiPlugins.public).toBeInstanceOf(Map); - expect(setup.uiPlugins.internal).toBeInstanceOf(Map); expect(mockPluginSystem.addPlugin).not.toHaveBeenCalled(); expect(mockPluginSystem.setupPlugins).toHaveBeenCalledTimes(1); expect(mockPluginSystem.setupPlugins).toHaveBeenCalledWith(setupDeps); @@ -273,7 +271,8 @@ describe('PluginsService', () => { plugin$: from([firstPlugin, secondPlugin]), }); - await expect(pluginsService.discover()).resolves.toBeUndefined(); + const { pluginTree } = await pluginsService.discover(); + expect(pluginTree).toBeUndefined(); expect(mockDiscover).toHaveBeenCalledTimes(1); expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2); @@ -308,7 +307,8 @@ describe('PluginsService', () => { plugin$: from([firstPlugin, secondPlugin, thirdPlugin, lastPlugin, missingDepsPlugin]), }); - await expect(pluginsService.discover()).resolves.toBeUndefined(); + const { pluginTree } = await pluginsService.discover(); + expect(pluginTree).toBeUndefined(); expect(mockDiscover).toHaveBeenCalledTimes(1); expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(4); @@ -466,12 +466,8 @@ describe('PluginsService', () => { }); mockPluginSystem.uiPlugins.mockReturnValue(new Map([pluginToDiscoveredEntry(plugin)])); - await pluginsService.discover(); - const { - uiPlugins: { browserConfigs }, - } = await pluginsService.setup(setupDeps); - - const uiConfig$ = browserConfigs.get('plugin-with-expose'); + const { uiPlugins } = await pluginsService.discover(); + const uiConfig$ = uiPlugins.browserConfigs.get('plugin-with-expose'); expect(uiConfig$).toBeDefined(); const uiConfig = await uiConfig$!.pipe(take(1)).toPromise(); @@ -506,12 +502,8 @@ describe('PluginsService', () => { }); mockPluginSystem.uiPlugins.mockReturnValue(new Map([pluginToDiscoveredEntry(plugin)])); - await pluginsService.discover(); - const { - uiPlugins: { browserConfigs }, - } = await pluginsService.setup(setupDeps); - - expect([...browserConfigs.entries()]).toHaveLength(0); + const { uiPlugins } = await pluginsService.discover(); + expect([...uiPlugins.browserConfigs.entries()]).toHaveLength(0); }); }); @@ -539,8 +531,7 @@ describe('PluginsService', () => { describe('uiPlugins.internal', () => { it('includes disabled plugins', async () => { config$.next({ plugins: { initialize: true }, plugin1: { enabled: false } }); - await pluginsService.discover(); - const { uiPlugins } = await pluginsService.setup(setupDeps); + const { uiPlugins } = await pluginsService.discover(); expect(uiPlugins.internal).toMatchInlineSnapshot(` Map { "plugin-1" => Object { diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index a0ecee47c675f..d7a348affe94f 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -39,23 +39,25 @@ export interface PluginsServiceSetup { initialized: boolean; /** Setup contracts returned by plugins. */ contracts: Map; - uiPlugins: { - /** - * Paths to all discovered ui plugin entrypoints on the filesystem, even if - * disabled. - */ - internal: Map; - - /** - * Information needed by client-side to load plugins and wire dependencies. - */ - public: Map; - - /** - * Configuration for plugins to be exposed to the client-side. - */ - browserConfigs: Map>; - }; +} + +/** @internal */ +export interface UiPlugins { + /** + * Paths to all discovered ui plugin entrypoints on the filesystem, even if + * disabled. + */ + internal: Map; + + /** + * Information needed by client-side to load plugins and wire dependencies. + */ + public: Map; + + /** + * Configuration for plugins to be exposed to the client-side. + */ + browserConfigs: Map>; } /** @internal */ @@ -97,8 +99,17 @@ export class PluginsService implements CoreService; -export const setupMock: jest.Mocked = { +export const setupMock: jest.Mocked = { render: jest.fn(), }; export const mockSetup = jest.fn().mockResolvedValue(setupMock); diff --git a/src/core/server/rendering/rendering_service.mock.ts b/src/core/server/rendering/rendering_service.mock.ts new file mode 100644 index 0000000000000..7eba332512386 --- /dev/null +++ b/src/core/server/rendering/rendering_service.mock.ts @@ -0,0 +1,31 @@ +/* + * 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 { InternalRenderingServiceSetup } from './types'; + +function createRenderingSetup() { + const mocked: jest.Mocked = { + render: jest.fn().mockResolvedValue(''), + }; + return mocked; +} + +export const renderingMock = { + createSetupContract: createRenderingSetup, +}; diff --git a/src/core/server/rendering/rendering_service.test.ts b/src/core/server/rendering/rendering_service.test.ts index 43ff4f633085c..d1c527aca4dba 100644 --- a/src/core/server/rendering/rendering_service.test.ts +++ b/src/core/server/rendering/rendering_service.test.ts @@ -22,7 +22,7 @@ import { load } from 'cheerio'; import { httpServerMock } from '../http/http_server.mocks'; import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; import { mockRenderingServiceParams, mockRenderingSetupDeps } from './__mocks__/params'; -import { RenderingServiceSetup } from './types'; +import { InternalRenderingServiceSetup } from './types'; import { RenderingService } from './rendering_service'; const INJECTED_METADATA = { @@ -62,15 +62,9 @@ describe('RenderingService', () => { }); describe('setup()', () => { - it('creates instance of RenderingServiceSetup', async () => { - const rendering = await service.setup(mockRenderingSetupDeps); - - expect(rendering.render).toBeInstanceOf(Function); - }); - describe('render()', () => { let uiSettings: ReturnType; - let render: RenderingServiceSetup['render']; + let render: InternalRenderingServiceSetup['render']; beforeEach(async () => { uiSettings = uiSettingsServiceMock.createClient(); @@ -78,6 +72,13 @@ describe('RenderingService', () => { registered: { name: 'title' }, }); render = (await service.setup(mockRenderingSetupDeps)).render; + await service.start({ + legacy: { + legacyInternals: { + getVars: () => ({}), + }, + }, + } as any); }); it('renders "core" page', async () => { diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx index dbafd5806bd74..a02d85d22b2cb 100644 --- a/src/core/server/rendering/rendering_service.tsx +++ b/src/core/server/rendering/rendering_service.tsx @@ -23,41 +23,37 @@ import { take } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; +import { UiPlugins } from '../plugins'; import { CoreService } from '../../types'; import { CoreContext } from '../core_context'; import { Template } from './views'; +import { LegacyService } from '../legacy'; import { IRenderOptions, RenderingSetupDeps, - RenderingServiceSetup, + InternalRenderingServiceSetup, RenderingMetadata, } from './types'; /** @internal */ -export class RenderingService implements CoreService { +export class RenderingService implements CoreService { + private legacyInternals?: LegacyService['legacyInternals']; constructor(private readonly coreContext: CoreContext) {} public async setup({ http, legacyPlugins, - plugins, - }: RenderingSetupDeps): Promise { - async function getUiConfig(pluginId: string) { - const browserConfig = plugins.uiPlugins.browserConfigs.get(pluginId); - - return ((await browserConfig?.pipe(take(1)).toPromise()) ?? {}) as Record; - } - + uiPlugins, + }: RenderingSetupDeps): Promise { return { render: async ( request, uiSettings, - { - app = { getId: () => 'core' }, - includeUserSettings = true, - vars = {}, - }: IRenderOptions = {} + { app = { getId: () => 'core' }, includeUserSettings = true, vars }: IRenderOptions = {} ) => { + if (!this.legacyInternals) { + throw new Error('Cannot render before "start"'); + } const { env } = this.coreContext; const basePath = http.basePath.get(request); const serverBasePath = http.basePath.serverBasePath; @@ -87,12 +83,12 @@ export class RenderingService implements CoreService { translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`, }, csp: { warnLegacyBrowsers: http.csp.warnLegacyBrowsers }, - vars, + vars: vars ?? (await this.legacyInternals!.getVars('core', request)), uiPlugins: await Promise.all( - [...plugins.uiPlugins.public].map(async ([id, plugin]) => ({ + [...uiPlugins.public].map(async ([id, plugin]) => ({ id, plugin, - config: await getUiConfig(id), + config: await this.getUiConfig(uiPlugins, id), })) ), legacyMetadata: { @@ -116,7 +112,15 @@ export class RenderingService implements CoreService { }; } - public async start() {} + public async start({ legacy }: { legacy: LegacyService }) { + this.legacyInternals = legacy.legacyInternals; + } public async stop() {} + + private async getUiConfig(uiPlugins: UiPlugins, pluginId: string) { + const browserConfig = uiPlugins.browserConfigs.get(pluginId); + + return ((await browserConfig?.pipe(take(1)).toPromise()) ?? {}) as Record; + } } diff --git a/src/core/server/rendering/types.ts b/src/core/server/rendering/types.ts index cfaa23d491139..2a3be93055006 100644 --- a/src/core/server/rendering/types.ts +++ b/src/core/server/rendering/types.ts @@ -23,7 +23,7 @@ import { Env } from '../config'; import { ICspConfig } from '../csp'; import { InternalHttpServiceSetup, KibanaRequest, LegacyRequest } from '../http'; import { LegacyNavLink, LegacyServiceDiscoverPlugins } from '../legacy'; -import { PluginsServiceSetup, DiscoveredPlugin } from '../plugins'; +import { UiPlugins, DiscoveredPlugin } from '../plugins'; import { IUiSettingsClient, UserProvidedValues } from '../ui_settings'; /** @internal */ @@ -75,7 +75,7 @@ export interface RenderingMetadata { export interface RenderingSetupDeps { http: InternalHttpServiceSetup; legacyPlugins: LegacyServiceDiscoverPlugins; - plugins: PluginsServiceSetup; + uiPlugins: UiPlugins; } /** @public */ @@ -102,31 +102,8 @@ export interface IRenderOptions { vars?: Record; } -/** @public */ -export interface IScopedRenderingClient { - /** - * Generate a `KibanaResponse` which renders an HTML page bootstrapped - * with the `core` bundle. Intended as a response body for HTTP route handlers. - * - * @example - * ```ts - * router.get( - * { path: '/', validate: false }, - * (context, request, response) => - * response.ok({ - * body: await context.core.rendering.render(), - * headers: { - * 'content-security-policy': context.core.http.csp.header, - * }, - * }) - * ); - * ``` - */ - render(options?: Pick): Promise; -} - /** @internal */ -export interface RenderingServiceSetup { +export interface InternalRenderingServiceSetup { /** * Generate a `KibanaResponse` which renders an HTML page bootstrapped * with the `core` bundle or the ID of another specified legacy bundle. diff --git a/src/core/server/saved_objects/__snapshots__/utils.test.ts.snap b/src/core/server/saved_objects/__snapshots__/utils.test.ts.snap index 5431d2ca47892..7cd0297e57857 100644 --- a/src/core/server/saved_objects/__snapshots__/utils.test.ts.snap +++ b/src/core/server/saved_objects/__snapshots__/utils.test.ts.snap @@ -16,7 +16,7 @@ Array [ }, "migrations": Object {}, "name": "typeA", - "namespaceAgnostic": false, + "namespaceType": "single", }, Object { "convertToAliasScript": undefined, @@ -32,7 +32,7 @@ Array [ }, "migrations": Object {}, "name": "typeB", - "namespaceAgnostic": false, + "namespaceType": "single", }, Object { "convertToAliasScript": undefined, @@ -48,7 +48,7 @@ Array [ }, "migrations": Object {}, "name": "typeC", - "namespaceAgnostic": false, + "namespaceType": "single", }, ] `; @@ -72,7 +72,7 @@ Array [ "2.0.4": [Function], }, "name": "typeA", - "namespaceAgnostic": true, + "namespaceType": "agnostic", }, Object { "convertToAliasScript": "some alias script", @@ -91,7 +91,7 @@ Array [ }, "migrations": Object {}, "name": "typeB", - "namespaceAgnostic": false, + "namespaceType": "single", }, Object { "convertToAliasScript": undefined, @@ -109,7 +109,7 @@ Array [ "1.5.3": [Function], }, "name": "typeC", - "namespaceAgnostic": false, + "namespaceType": "single", }, ] `; @@ -130,7 +130,23 @@ Array [ }, "migrations": Object {}, "name": "typeA", - "namespaceAgnostic": true, + "namespaceType": "agnostic", + }, + Object { + "convertToAliasScript": undefined, + "hidden": false, + "indexPattern": "barBaz", + "management": undefined, + "mappings": Object { + "properties": Object { + "fieldB": Object { + "type": "text", + }, + }, + }, + "migrations": Object {}, + "name": "typeB", + "namespaceType": "multiple", }, Object { "convertToAliasScript": undefined, @@ -146,7 +162,23 @@ Array [ }, "migrations": Object {}, "name": "typeC", - "namespaceAgnostic": false, + "namespaceType": "single", + }, + Object { + "convertToAliasScript": undefined, + "hidden": false, + "indexPattern": "bazQux", + "management": undefined, + "mappings": Object { + "properties": Object { + "fieldD": Object { + "type": "text", + }, + }, + }, + "migrations": Object {}, + "name": "typeD", + "namespaceType": "agnostic", }, ] `; diff --git a/src/core/server/saved_objects/index.ts b/src/core/server/saved_objects/index.ts index fe4795cad11a5..a294b28753f7b 100644 --- a/src/core/server/saved_objects/index.ts +++ b/src/core/server/saved_objects/index.ts @@ -69,6 +69,7 @@ export { } from './migrations'; export { + SavedObjectsNamespaceType, SavedObjectStatusMeta, SavedObjectsType, SavedObjectsTypeManagementDefinition, diff --git a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap index fc26d7e9cf6e9..bc9a66926e880 100644 --- a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap @@ -8,6 +8,7 @@ Object { "bbb": "18c78c995965207ed3f6e7fc5c6e55fe", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", @@ -28,6 +29,9 @@ Object { "namespace": Object { "type": "keyword", }, + "namespaces": Object { + "type": "keyword", + }, "references": Object { "properties": Object { "id": Object { @@ -59,6 +63,7 @@ Object { "firstType": "635418ab953d81d93f1190b70a8d3f57", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", "secondType": "72d57924f415fbadb3ee293b67d233ab", "thirdType": "510f1f0adb69830cf8a1c5ce2923ed82", @@ -83,6 +88,9 @@ Object { "namespace": Object { "type": "keyword", }, + "namespaces": Object { + "type": "keyword", + }, "references": Object { "properties": Object { "id": Object { diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts index 4d1a607414ca6..418ed95f14e05 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts @@ -142,6 +142,9 @@ function defaultMapping(): IndexMapping { namespace: { type: 'keyword', }, + namespaces: { + type: 'keyword', + }, updated_at: { type: 'date', }, diff --git a/src/core/server/saved_objects/migrations/core/build_index_map.test.ts b/src/core/server/saved_objects/migrations/core/build_index_map.test.ts index 44add4e977006..2c710d4eaa079 100644 --- a/src/core/server/saved_objects/migrations/core/build_index_map.test.ts +++ b/src/core/server/saved_objects/migrations/core/build_index_map.test.ts @@ -26,7 +26,7 @@ const createRegistry = (...types: Array>) => { types.forEach(type => registry.registerType({ name: 'unknown', - namespaceAgnostic: false, + namespaceType: 'single', hidden: false, mappings: { properties: {} }, migrations: {}, @@ -41,7 +41,7 @@ test('mappings without index pattern goes to default index', () => { kibanaIndexName: '.kibana', registry: createRegistry({ name: 'type1', - namespaceAgnostic: false, + namespaceType: 'single', }), indexMap: { type1: { @@ -73,7 +73,7 @@ test(`mappings with custom index pattern doesn't go to default index`, () => { kibanaIndexName: '.kibana', registry: createRegistry({ name: 'type1', - namespaceAgnostic: false, + namespaceType: 'single', indexPattern: '.other_kibana', }), indexMap: { @@ -106,7 +106,7 @@ test('creating a script gets added to the index pattern', () => { kibanaIndexName: '.kibana', registry: createRegistry({ name: 'type1', - namespaceAgnostic: false, + namespaceType: 'single', indexPattern: '.other_kibana', convertToAliasScript: `ctx._id = ctx._source.type + ':' + ctx._id`, }), @@ -141,12 +141,12 @@ test('throws when two scripts are defined for an index pattern', () => { const registry = createRegistry( { name: 'type1', - namespaceAgnostic: false, + namespaceType: 'single', convertToAliasScript: `ctx._id = ctx._source.type + ':' + ctx._id`, }, { name: 'type2', - namespaceAgnostic: false, + namespaceType: 'single', convertToAliasScript: `ctx._id = ctx._source.type + ':' + ctx._id`, } ); diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index ef3f546b5e574..64270c677ff20 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -32,7 +32,7 @@ const createRegistry = (...types: Array>) => { types.forEach(type => registry.registerType({ name: 'unknown', - namespaceAgnostic: false, + namespaceType: 'single', hidden: false, mappings: { properties: {} }, migrations: {}, diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index 1c2d3f501ff80..19208e6c83596 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -61,6 +61,7 @@ describe('IndexMigrator', () => { foo: '18c78c995965207ed3f6e7fc5c6e55fe', migrationVersion: '4a1746014a75ade3a714e1db5763276f', namespace: '2f4316de49999235636386fe51dc06c1', + namespaces: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -70,6 +71,7 @@ describe('IndexMigrator', () => { foo: { type: 'long' }, migrationVersion: { dynamic: 'true', type: 'object' }, namespace: { type: 'keyword' }, + namespaces: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, references: { @@ -178,6 +180,7 @@ describe('IndexMigrator', () => { foo: '625b32086eb1d1203564cf85062dd22e', migrationVersion: '4a1746014a75ade3a714e1db5763276f', namespace: '2f4316de49999235636386fe51dc06c1', + namespaces: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -188,6 +191,7 @@ describe('IndexMigrator', () => { foo: { type: 'text' }, migrationVersion: { dynamic: 'true', type: 'object' }, namespace: { type: 'keyword' }, + namespaces: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, references: { diff --git a/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap b/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap index 507c0b0d9339f..3453f3fc80310 100644 --- a/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap +++ b/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap @@ -8,6 +8,7 @@ Object { "bmap": "510f1f0adb69830cf8a1c5ce2923ed82", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", @@ -36,6 +37,9 @@ Object { "namespace": Object { "type": "keyword", }, + "namespaces": Object { + "type": "keyword", + }, "references": Object { "properties": Object { "id": Object { diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.mock.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.mock.ts index 257b32c1e4c23..3f5c0c3876615 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.mock.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.mock.ts @@ -27,7 +27,7 @@ const defaultSavedObjectTypes: SavedObjectsType[] = [ { name: 'testtype', hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', mappings: { properties: { name: { type: 'keyword' }, diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts index 336eeff99f47b..cda0e86f15bdf 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts @@ -28,8 +28,8 @@ const createRegistry = (types: Array>) => { types.forEach(type => registry.registerType({ name: 'unknown', - namespaceAgnostic: false, hidden: false, + namespaceType: 'single', mappings: { properties: {} }, migrations: {}, ...type, @@ -120,7 +120,7 @@ function mockOptions(): KibanaMigratorOptions { { name: 'testtype', hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', mappings: { properties: { name: { type: 'keyword' }, @@ -131,7 +131,7 @@ function mockOptions(): KibanaMigratorOptions { { name: 'testtype2', hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', indexPattern: 'other-index', mappings: { properties: { diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index c72d3e241b882..c4a03a0e2e7d2 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -187,7 +187,7 @@ describe('POST /internal/saved_objects/_import', () => { references: [], error: { statusCode: 409, - message: 'version conflict, document already exists', + message: 'Saved object [index-pattern/my-pattern] conflict', }, }, { diff --git a/src/core/server/saved_objects/routes/integration_tests/test_utils.ts b/src/core/server/saved_objects/routes/integration_tests/test_utils.ts index 82a889f75d3c1..23e0285201dc7 100644 --- a/src/core/server/saved_objects/routes/integration_tests/test_utils.ts +++ b/src/core/server/saved_objects/routes/integration_tests/test_utils.ts @@ -49,7 +49,7 @@ export const createExportableType = (name: string): SavedObjectsType => { return { name, hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', mappings: { properties: {}, }, diff --git a/src/core/server/saved_objects/saved_objects_service.test.ts b/src/core/server/saved_objects/saved_objects_service.test.ts index 018117776dcc8..819d79803f371 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -138,7 +138,7 @@ describe('SavedObjectsService', () => { const type = { name: 'someType', hidden: false, - namespaceAgnostic: false, + namespaceType: 'single' as 'single', mappings: { properties: {} }, }; setup.registerType(type); @@ -251,7 +251,7 @@ describe('SavedObjectsService', () => { setup.registerType({ name: 'someType', hidden: false, - namespaceAgnostic: false, + namespaceType: 'single' as 'single', mappings: { properties: {} }, }); }).toThrowErrorMatchingInlineSnapshot( diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 62027928c0bb5..ed4ffef5729ab 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -124,7 +124,7 @@ export interface SavedObjectsServiceSetup { * export const myType: SavedObjectsType = { * name: 'MyType', * hidden: false, - * namespaceAgnostic: true, + * namespaceType: 'multiple', * mappings: { * properties: { * textField: { diff --git a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts index 8c8458d7a5ce4..8bb66859feca2 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts @@ -27,6 +27,8 @@ const createRegistryMock = (): jest.Mocked type === 'global'); + mock.isSingleNamespace.mockImplementation( + (type: string) => type !== 'global' && type !== 'shared' + ); + mock.isMultiNamespace.mockImplementation((type: string) => type === 'shared'); mock.isImportableAndExportable.mockReturnValue(true); return mock; diff --git a/src/core/server/saved_objects/saved_objects_type_registry.test.ts b/src/core/server/saved_objects/saved_objects_type_registry.test.ts index 4d1d5c1eacc25..f82822f90f489 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.test.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.test.ts @@ -23,7 +23,7 @@ import { SavedObjectsType } from './types'; const createType = (type: Partial): SavedObjectsType => ({ name: 'unknown', hidden: false, - namespaceAgnostic: false, + namespaceType: 'single' as 'single', mappings: { properties: {} }, migrations: {}, ...type, @@ -164,18 +164,68 @@ describe('SavedObjectTypeRegistry', () => { }); describe('#isNamespaceAgnostic', () => { - it('returns correct value for the type', () => { - registry.registerType(createType({ name: 'typeA', namespaceAgnostic: true })); - registry.registerType(createType({ name: 'typeB', namespaceAgnostic: false })); + const expectResult = (expected: boolean, schemaDefinition?: Partial) => { + registry = new SavedObjectTypeRegistry(); + registry.registerType(createType({ name: 'foo', ...schemaDefinition })); + expect(registry.isNamespaceAgnostic('foo')).toBe(expected); + }; - expect(registry.isNamespaceAgnostic('typeA')).toEqual(true); - expect(registry.isNamespaceAgnostic('typeB')).toEqual(false); + it(`returns false when the type is not registered`, () => { + expect(registry.isNamespaceAgnostic('unknownType')).toEqual(false); }); - it('returns false when the type is not registered', () => { - registry.registerType(createType({ name: 'typeA', namespaceAgnostic: true })); - registry.registerType(createType({ name: 'typeB', namespaceAgnostic: false })); - expect(registry.isNamespaceAgnostic('unknownType')).toEqual(false); + it(`returns true for namespaceType 'agnostic'`, () => { + expectResult(true, { namespaceType: 'agnostic' }); + }); + + it(`returns false for other namespaceType`, () => { + expectResult(false, { namespaceType: 'multiple' }); + expectResult(false, { namespaceType: 'single' }); + expectResult(false, { namespaceType: undefined }); + }); + }); + + describe('#isSingleNamespace', () => { + const expectResult = (expected: boolean, schemaDefinition?: Partial) => { + registry = new SavedObjectTypeRegistry(); + registry.registerType(createType({ name: 'foo', ...schemaDefinition })); + expect(registry.isSingleNamespace('foo')).toBe(expected); + }; + + it(`returns true when the type is not registered`, () => { + expect(registry.isSingleNamespace('unknownType')).toEqual(true); + }); + + it(`returns true for namespaceType 'single'`, () => { + expectResult(true, { namespaceType: 'single' }); + expectResult(true, { namespaceType: undefined }); + }); + + it(`returns false for other namespaceType`, () => { + expectResult(false, { namespaceType: 'agnostic' }); + expectResult(false, { namespaceType: 'multiple' }); + }); + }); + + describe('#isMultiNamespace', () => { + const expectResult = (expected: boolean, schemaDefinition?: Partial) => { + registry = new SavedObjectTypeRegistry(); + registry.registerType(createType({ name: 'foo', ...schemaDefinition })); + expect(registry.isMultiNamespace('foo')).toBe(expected); + }; + + it(`returns false when the type is not registered`, () => { + expect(registry.isMultiNamespace('unknownType')).toEqual(false); + }); + + it(`returns true for namespaceType 'multiple'`, () => { + expectResult(true, { namespaceType: 'multiple' }); + }); + + it(`returns false for other namespaceType`, () => { + expectResult(false, { namespaceType: 'agnostic' }); + expectResult(false, { namespaceType: 'single' }); + expectResult(false, { namespaceType: undefined }); }); }); @@ -206,8 +256,8 @@ describe('SavedObjectTypeRegistry', () => { expect(registry.getIndex('typeWithNoIndex')).toBeUndefined(); }); it('returns undefined when the type is not registered', () => { - registry.registerType(createType({ name: 'typeA', namespaceAgnostic: true })); - registry.registerType(createType({ name: 'typeB', namespaceAgnostic: false })); + registry.registerType(createType({ name: 'typeA', namespaceType: 'agnostic' })); + registry.registerType(createType({ name: 'typeB', namespaceType: 'single' })); expect(registry.getIndex('unknownType')).toBeUndefined(); }); diff --git a/src/core/server/saved_objects/saved_objects_type_registry.ts b/src/core/server/saved_objects/saved_objects_type_registry.ts index 5580ce3815d0d..740313a53d1e2 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.ts @@ -25,16 +25,7 @@ import { SavedObjectsType } from './types'; * * @public */ -export type ISavedObjectTypeRegistry = Pick< - SavedObjectTypeRegistry, - | 'getType' - | 'getAllTypes' - | 'getIndex' - | 'isNamespaceAgnostic' - | 'isHidden' - | 'getImportableAndExportableTypes' - | 'isImportableAndExportable' ->; +export type ISavedObjectTypeRegistry = Omit; /** * Registry holding information about all the registered {@link SavedObjectsType | saved object types}. @@ -77,11 +68,28 @@ export class SavedObjectTypeRegistry { } /** - * Returns the `namespaceAgnostic` property for given type, or `false` if - * the type is not registered. + * Returns whether the type is namespace-agnostic (global); + * resolves to `false` if the type is not registered */ public isNamespaceAgnostic(type: string) { - return this.types.get(type)?.namespaceAgnostic ?? false; + return this.types.get(type)?.namespaceType === 'agnostic'; + } + + /** + * Returns whether the type is single-namespace (isolated); + * resolves to `true` if the type is not registered + */ + public isSingleNamespace(type: string) { + // in the case we somehow registered a type with an invalid `namespaceType`, treat it as single-namespace + return !this.isNamespaceAgnostic(type) && !this.isMultiNamespace(type); + } + + /** + * Returns whether the type is multi-namespace (shareable); + * resolves to `false` if the type is not registered + */ + public isMultiNamespace(type: string) { + return this.types.get(type)?.namespaceType === 'multiple'; } /** diff --git a/src/core/server/saved_objects/schema/schema.test.ts b/src/core/server/saved_objects/schema/schema.test.ts index 43cf27fbae790..f2daa13e43fce 100644 --- a/src/core/server/saved_objects/schema/schema.test.ts +++ b/src/core/server/saved_objects/schema/schema.test.ts @@ -17,32 +17,90 @@ * under the License. */ -import { SavedObjectsSchema } from './schema'; +import { SavedObjectsSchema, SavedObjectsSchemaDefinition } from './schema'; describe('#isNamespaceAgnostic', () => { + const expectResult = (expected: boolean, schemaDefinition?: SavedObjectsSchemaDefinition) => { + const schema = new SavedObjectsSchema(schemaDefinition); + const result = schema.isNamespaceAgnostic('foo'); + expect(result).toBe(expected); + }; + + it(`returns false when no schema is defined`, () => { + expectResult(false); + }); + it(`returns false for unknown types`, () => { - const schema = new SavedObjectsSchema(); - const result = schema.isNamespaceAgnostic('bar'); - expect(result).toBe(false); + expectResult(false, { bar: {} }); }); - it(`returns true for explicitly namespace agnostic type`, () => { - const schema = new SavedObjectsSchema({ - foo: { - isNamespaceAgnostic: true, - }, - }); - const result = schema.isNamespaceAgnostic('foo'); - expect(result).toBe(true); + it(`returns false for non-namespace-agnostic type`, () => { + expectResult(false, { foo: { isNamespaceAgnostic: false } }); + expectResult(false, { foo: { isNamespaceAgnostic: undefined } }); }); - it(`returns false for explicitly namespaced type`, () => { - const schema = new SavedObjectsSchema({ - foo: { - isNamespaceAgnostic: false, - }, - }); - const result = schema.isNamespaceAgnostic('foo'); - expect(result).toBe(false); + it(`returns true for explicitly namespace-agnostic type`, () => { + expectResult(true, { foo: { isNamespaceAgnostic: true } }); + }); +}); + +describe('#isSingleNamespace', () => { + const expectResult = (expected: boolean, schemaDefinition?: SavedObjectsSchemaDefinition) => { + const schema = new SavedObjectsSchema(schemaDefinition); + const result = schema.isSingleNamespace('foo'); + expect(result).toBe(expected); + }; + + it(`returns true when no schema is defined`, () => { + expectResult(true); + }); + + it(`returns true for unknown types`, () => { + expectResult(true, { bar: {} }); + }); + + it(`returns false for explicitly namespace-agnostic type`, () => { + expectResult(false, { foo: { isNamespaceAgnostic: true } }); + }); + + it(`returns false for explicitly multi-namespace type`, () => { + expectResult(false, { foo: { multiNamespace: true } }); + }); + + it(`returns true for non-namespace-agnostic and non-multi-namespace type`, () => { + expectResult(true, { foo: { isNamespaceAgnostic: false, multiNamespace: false } }); + expectResult(true, { foo: { isNamespaceAgnostic: false, multiNamespace: undefined } }); + expectResult(true, { foo: { isNamespaceAgnostic: undefined, multiNamespace: false } }); + expectResult(true, { foo: { isNamespaceAgnostic: undefined, multiNamespace: undefined } }); + }); +}); + +describe('#isMultiNamespace', () => { + const expectResult = (expected: boolean, schemaDefinition?: SavedObjectsSchemaDefinition) => { + const schema = new SavedObjectsSchema(schemaDefinition); + const result = schema.isMultiNamespace('foo'); + expect(result).toBe(expected); + }; + + it(`returns false when no schema is defined`, () => { + expectResult(false); + }); + + it(`returns false for unknown types`, () => { + expectResult(false, { bar: {} }); + }); + + it(`returns false for explicitly namespace-agnostic type`, () => { + expectResult(false, { foo: { isNamespaceAgnostic: true } }); + }); + + it(`returns false for non-multi-namespace type`, () => { + expectResult(false, { foo: { multiNamespace: false } }); + expectResult(false, { foo: { multiNamespace: undefined } }); + }); + + it(`returns true for non-namespace-agnostic and explicitly multi-namespace type`, () => { + expectResult(true, { foo: { isNamespaceAgnostic: false, multiNamespace: true } }); + expectResult(true, { foo: { isNamespaceAgnostic: undefined, multiNamespace: true } }); }); }); diff --git a/src/core/server/saved_objects/schema/schema.ts b/src/core/server/saved_objects/schema/schema.ts index 17ca406ea109a..ba1905158e822 100644 --- a/src/core/server/saved_objects/schema/schema.ts +++ b/src/core/server/saved_objects/schema/schema.ts @@ -24,7 +24,8 @@ import { LegacyConfig } from '../../legacy'; * @internal **/ interface SavedObjectsSchemaTypeDefinition { - isNamespaceAgnostic: boolean; + isNamespaceAgnostic?: boolean; + multiNamespace?: boolean; hidden?: boolean; indexPattern?: ((config: LegacyConfig) => string) | string; convertToAliasScript?: string; @@ -72,7 +73,7 @@ export class SavedObjectsSchema { } public isNamespaceAgnostic(type: string) { - // if no plugins have registered a uiExports.savedObjectSchemas, + // if no plugins have registered a Saved Objects Schema, // this.schema will be undefined, and no types are namespace agnostic if (!this.definition) { return false; @@ -84,4 +85,32 @@ export class SavedObjectsSchema { } return Boolean(typeSchema.isNamespaceAgnostic); } + + public isSingleNamespace(type: string) { + // if no plugins have registered a Saved Objects Schema, + // this.schema will be undefined, and all types are namespace isolated + if (!this.definition) { + return true; + } + + const typeSchema = this.definition[type]; + if (!typeSchema) { + return true; + } + return !Boolean(typeSchema.isNamespaceAgnostic) && !Boolean(typeSchema.multiNamespace); + } + + public isMultiNamespace(type: string) { + // if no plugins have registered a Saved Objects Schema, + // this.schema will be undefined, and no types are multi-namespace + if (!this.definition) { + return false; + } + + const typeSchema = this.definition[type]; + if (!typeSchema) { + return false; + } + return !Boolean(typeSchema.isNamespaceAgnostic) && Boolean(typeSchema.multiNamespace); + } } diff --git a/src/core/server/saved_objects/serialization/serializer.test.ts b/src/core/server/saved_objects/serialization/serializer.test.ts index 8f09b25bb3908..1a7dfdd2d130e 100644 --- a/src/core/server/saved_objects/serialization/serializer.test.ts +++ b/src/core/server/saved_objects/serialization/serializer.test.ts @@ -19,101 +19,101 @@ import _ from 'lodash'; import { SavedObjectsSerializer } from './serializer'; +import { SavedObjectsRawDoc } from './types'; import { typeRegistryMock } from '../saved_objects_type_registry.mock'; import { encodeVersion } from '../version'; -describe('saved object conversion', () => { - let typeRegistry: ReturnType; - - beforeEach(() => { - typeRegistry = typeRegistryMock.create(); - typeRegistry.isNamespaceAgnostic.mockReturnValue(false); +let typeRegistry = typeRegistryMock.create(); +typeRegistry.isNamespaceAgnostic.mockReturnValue(true); +typeRegistry.isSingleNamespace.mockReturnValue(false); +typeRegistry.isMultiNamespace.mockReturnValue(false); +const namespaceAgnosticSerializer = new SavedObjectsSerializer(typeRegistry); + +typeRegistry = typeRegistryMock.create(); +typeRegistry.isNamespaceAgnostic.mockReturnValue(false); +typeRegistry.isSingleNamespace.mockReturnValue(true); +typeRegistry.isMultiNamespace.mockReturnValue(false); +const singleNamespaceSerializer = new SavedObjectsSerializer(typeRegistry); + +typeRegistry = typeRegistryMock.create(); +typeRegistry.isNamespaceAgnostic.mockReturnValue(false); +typeRegistry.isSingleNamespace.mockReturnValue(false); +typeRegistry.isMultiNamespace.mockReturnValue(true); +const multiNamespaceSerializer = new SavedObjectsSerializer(typeRegistry); + +const sampleTemplate = { + _id: 'foo:bar', + _source: { + type: 'foo', + }, +}; +const createSampleDoc = (raw: any, template = sampleTemplate): SavedObjectsRawDoc => + _.defaultsDeep(raw, template); + +describe('#rawToSavedObject', () => { + test('it copies the _source.type property to type', () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, + }); + expect(actual).toHaveProperty('type', 'foo'); }); - describe('#rawToSavedObject', () => { - test('it copies the _source.type property to type', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - }, - }); - expect(actual).toHaveProperty('type', 'foo'); + test('it copies the _source.references property to references', () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + references: [{ name: 'ref_0', type: 'index-pattern', id: 'pattern*' }], + }, }); + expect(actual).toHaveProperty('references', [ + { + name: 'ref_0', + type: 'index-pattern', + id: 'pattern*', + }, + ]); + }); - test('it copies the _source.references property to references', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - references: [{ name: 'ref_0', type: 'index-pattern', id: 'pattern*' }], - }, - }); - expect(actual).toHaveProperty('references', [ - { - name: 'ref_0', - type: 'index-pattern', - id: 'pattern*', + test('if specified it copies the _source.migrationVersion property to migrationVersion', () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + migrationVersion: { + hello: '1.2.3', + acl: '33.3.5', }, - ]); + }, }); - - test('if specified it copies the _source.migrationVersion property to migrationVersion', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - migrationVersion: { - hello: '1.2.3', - acl: '33.3.5', - }, - }, - }); - expect(actual).toHaveProperty('migrationVersion', { - hello: '1.2.3', - acl: '33.3.5', - }); + expect(actual).toHaveProperty('migrationVersion', { + hello: '1.2.3', + acl: '33.3.5', }); + }); - test(`if _source.migrationVersion is unspecified it doesn't set migrationVersion`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - }, - }); - expect(actual).not.toHaveProperty('migrationVersion'); + test(`if _source.migrationVersion is unspecified it doesn't set migrationVersion`, () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, }); + expect(actual).not.toHaveProperty('migrationVersion'); + }); - test('it converts the id and type properties, and retains migrationVersion', () => { - const now = String(new Date()); - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'hello:world', - _seq_no: 3, - _primary_term: 1, - _source: { - type: 'hello', - hello: { - a: 'b', - c: 'd', - }, - migrationVersion: { - hello: '1.2.3', - acl: '33.3.5', - }, - updated_at: now, - }, - }); - const expected = { - id: 'world', + test('it converts the id and type properties, and retains migrationVersion', () => { + const now = String(new Date()); + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'hello:world', + _seq_no: 3, + _primary_term: 1, + _source: { type: 'hello', - version: encodeVersion(3, 1), - attributes: { + hello: { a: 'b', c: 'd', }, @@ -122,909 +122,937 @@ describe('saved object conversion', () => { acl: '33.3.5', }, updated_at: now, - references: [], - }; - expect(expected).toEqual(actual); + }, + }); + const expected = { + id: 'world', + type: 'hello', + version: encodeVersion(3, 1), + attributes: { + a: 'b', + c: 'd', + }, + migrationVersion: { + hello: '1.2.3', + acl: '33.3.5', + }, + updated_at: now, + references: [], + }; + expect(expected).toEqual(actual); + }); + + test(`if version is unspecified it doesn't set version`, () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + hello: {}, + }, }); + expect(actual).not.toHaveProperty('version'); + }); - test(`if version is unspecified it doesn't set version`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ + test(`if specified it encodes _seq_no and _primary_term to version`, () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _seq_no: 4, + _primary_term: 1, + _source: { + type: 'foo', + hello: {}, + }, + }); + expect(actual).toHaveProperty('version', encodeVersion(4, 1)); + }); + + test(`if only _seq_no is specified it throws`, () => { + expect(() => + singleNamespaceSerializer.rawToSavedObject({ _id: 'foo:bar', + _seq_no: 4, _source: { type: 'foo', hello: {}, }, - }); - expect(actual).not.toHaveProperty('version'); - }); + }) + ).toThrowErrorMatchingInlineSnapshot(`"_primary_term from elasticsearch must be an integer"`); + }); - test(`if specified it encodes _seq_no and _primary_term to version`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ + test(`if only _primary_term is throws`, () => { + expect(() => + singleNamespaceSerializer.rawToSavedObject({ _id: 'foo:bar', - _seq_no: 4, _primary_term: 1, _source: { type: 'foo', hello: {}, }, - }); - expect(actual).toHaveProperty('version', encodeVersion(4, 1)); - }); + }) + ).toThrowErrorMatchingInlineSnapshot(`"_seq_no from elasticsearch must be an integer"`); + }); - test(`if only _seq_no is specified it throws`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect(() => - serializer.rawToSavedObject({ - _id: 'foo:bar', - _seq_no: 4, - _source: { - type: 'foo', - hello: {}, - }, - }) - ).toThrowErrorMatchingInlineSnapshot(`"_primary_term from elasticsearch must be an integer"`); + test('if specified it copies the _source.updated_at property to updated_at', () => { + const now = Date(); + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + updated_at: now, + }, }); + expect(actual).toHaveProperty('updated_at', now); + }); - test(`if only _primary_term is throws`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect(() => - serializer.rawToSavedObject({ - _id: 'foo:bar', - _primary_term: 1, - _source: { - type: 'foo', - hello: {}, - }, - }) - ).toThrowErrorMatchingInlineSnapshot(`"_seq_no from elasticsearch must be an integer"`); + test(`if _source.updated_at is unspecified it doesn't set updated_at`, () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, }); + expect(actual).not.toHaveProperty('updated_at'); + }); - test('if specified it copies the _source.updated_at property to updated_at', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const now = Date(); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - updated_at: now, + test('it does not pass unknown properties through', () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'universe', + _source: { + type: 'hello', + hello: { + world: 'earth', }, - }); - expect(actual).toHaveProperty('updated_at', now); + banjo: 'Steve Martin', + }, + }); + expect(actual).toEqual({ + id: 'universe', + type: 'hello', + attributes: { + world: 'earth', + }, + references: [], }); + }); - test(`if _source.updated_at is unspecified it doesn't set updated_at`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - }, - }); - expect(actual).not.toHaveProperty('updated_at'); + test('it does not create attributes if [type] is missing', () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'universe', + _source: { + type: 'hello', + }, }); + expect(actual).toEqual({ + id: 'universe', + type: 'hello', + references: [], + }); + }); - test('it does not pass unknown properties through', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ + test('it fails for documents which do not specify a type', () => { + expect(() => + singleNamespaceSerializer.rawToSavedObject({ _id: 'universe', _source: { - type: 'hello', hello: { world: 'earth', }, - banjo: 'Steve Martin', + } as any, + }) + ).toThrow(/Expected "undefined" to be a saved object type/); + }); + + test('it is complimentary with savedObjectToRaw', () => { + const raw = { + _id: 'foo-namespace:foo:bar', + _primary_term: 24, + _seq_no: 42, + _source: { + type: 'foo', + foo: { + meaning: 42, + nested: { stuff: 'here' }, }, - }); - expect(actual).toEqual({ - id: 'universe', - type: 'hello', - attributes: { - world: 'earth', + migrationVersion: { + foo: '1.2.3', + bar: '9.8.7', }, + namespace: 'foo-namespace', + updated_at: String(new Date()), references: [], - }); - }); + }, + }; + + expect( + singleNamespaceSerializer.savedObjectToRaw( + singleNamespaceSerializer.rawToSavedObject(_.cloneDeep(raw)) + ) + ).toEqual(raw); + }); - test('it does not create attributes if [type] is missing', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'universe', - _source: { - type: 'hello', - }, - }); - expect(actual).toEqual({ - id: 'universe', + test('it handles unprefixed ids', () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'universe', + _source: { type: 'hello', - references: [], - }); + }, }); - test('it fails for documents which do not specify a type', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect(() => - serializer.rawToSavedObject({ - _id: 'universe', - _source: { - hello: { - world: 'earth', - }, - } as any, - }) - ).toThrow(/Expected "undefined" to be a saved object type/); + expect(actual).toHaveProperty('id', 'universe'); + }); + + describe('namespace-agnostic type with a namespace', () => { + const raw = createSampleDoc({ _source: { namespace: 'baz' } }); + const actual = namespaceAgnosticSerializer.rawToSavedObject(raw); + + test(`removes type prefix from _id`, () => { + expect(actual).toHaveProperty('id', 'bar'); }); - test('it is complimentary with savedObjectToRaw', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const raw = { - _id: 'foo-namespace:foo:bar', - _primary_term: 24, - _seq_no: 42, - _source: { - type: 'foo', - foo: { - meaning: 42, - nested: { stuff: 'here' }, - }, - migrationVersion: { - foo: '1.2.3', - bar: '9.8.7', - }, - namespace: 'foo-namespace', - updated_at: String(new Date()), - references: [], - }, - }; + test(`copies _id to id if prefixed by namespace and type`, () => { + const _id = `${raw._source.namespace}:${raw._id}`; + const _actual = namespaceAgnosticSerializer.rawToSavedObject({ ...raw, _id }); + expect(_actual).toHaveProperty('id', _id); + }); - expect(serializer.savedObjectToRaw(serializer.rawToSavedObject(_.cloneDeep(raw)))).toEqual( - raw - ); + test(`doesn't copy _source.namespace to namespace`, () => { + expect(actual).not.toHaveProperty('namespace'); }); + }); - test('it handles unprefixed ids', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'universe', - _source: { - type: 'hello', - }, - }); + describe('namespace-agnostic type with namespaces', () => { + const raw = createSampleDoc({ _source: { namespaces: ['baz'] } }); + const actual = namespaceAgnosticSerializer.rawToSavedObject(raw); - expect(actual).toHaveProperty('id', 'universe'); + test(`doesn't copy _source.namespaces to namespaces`, () => { + expect(actual).not.toHaveProperty('namespaces'); }); + }); - describe('namespaced type without a namespace', () => { - test(`removes type prefix from _id`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - }, - }); + describe('single-namespace type without a namespace', () => { + const raw = createSampleDoc({}); + const actual = singleNamespaceSerializer.rawToSavedObject(raw); - expect(actual).toHaveProperty('id', 'bar'); - }); + test(`removes type prefix from _id`, () => { + expect(actual).toHaveProperty('id', 'bar'); + }); - test(`if prefixed by random prefix and type it copies _id to id`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'random:foo:bar', - _source: { - type: 'foo', - }, - }); + test(`copies _id to id if prefixed by random prefix and type`, () => { + const _id = `random:${raw._id}`; + const _actual = singleNamespaceSerializer.rawToSavedObject({ ...raw, _id }); + expect(_actual).toHaveProperty('id', _id); + }); - expect(actual).toHaveProperty('id', 'random:foo:bar'); - }); + test(`doesn't specify namespace`, () => { + expect(actual).not.toHaveProperty('namespace'); + }); + }); - test(`doesn't specify namespace`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - }, - }); + describe('single-namespace type with a namespace', () => { + const namespace = 'baz'; + const raw = createSampleDoc({ _source: { namespace } }); + const actual = singleNamespaceSerializer.rawToSavedObject(raw); - expect(actual).not.toHaveProperty('namespace'); - }); + test(`removes type and namespace prefix from _id`, () => { + const _id = `${namespace}:${raw._id}`; + const _actual = singleNamespaceSerializer.rawToSavedObject({ ...raw, _id }); + expect(_actual).toHaveProperty('id', 'bar'); }); - describe('namespaced type with a namespace', () => { - test(`removes type and namespace prefix from _id`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'baz:foo:bar', - _source: { - type: 'foo', - namespace: 'baz', - }, - }); + test(`copies _id to id if prefixed only by type`, () => { + expect(actual).toHaveProperty('id', raw._id); + }); - expect(actual).toHaveProperty('id', 'bar'); - }); + test(`copies _id to id if prefixed by random prefix and type`, () => { + const _id = `random:${raw._id}`; + const _actual = singleNamespaceSerializer.rawToSavedObject({ ...raw, _id }); + expect(_actual).toHaveProperty('id', _id); + }); - test(`if prefixed by only type it copies _id to id`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - namespace: 'baz', - }, - }); + test(`copies _source.namespace to namespace`, () => { + expect(actual).toHaveProperty('namespace', 'baz'); + }); + }); - expect(actual).toHaveProperty('id', 'foo:bar'); - }); + describe('single-namespace type with namespaces', () => { + const raw = createSampleDoc({ _source: { namespaces: ['baz'] } }); + const actual = singleNamespaceSerializer.rawToSavedObject(raw); - test(`if prefixed by random prefix and type it copies _id to id`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'random:foo:bar', - _source: { - type: 'foo', - namespace: 'baz', - }, - }); + test(`doesn't copy _source.namespaces to namespaces`, () => { + expect(actual).not.toHaveProperty('namespaces'); + }); + }); - expect(actual).toHaveProperty('id', 'random:foo:bar'); - }); + describe('multi-namespace type with a namespace', () => { + const raw = createSampleDoc({ _source: { namespace: 'baz' } }); + const actual = multiNamespaceSerializer.rawToSavedObject(raw); - test(`copies _source.namespace to namespace`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'baz:foo:bar', - _source: { - type: 'foo', - namespace: 'baz', - }, - }); + test(`removes type prefix from _id`, () => { + expect(actual).toHaveProperty('id', 'bar'); + }); - expect(actual).toHaveProperty('namespace', 'baz'); - }); + test(`copies _id to id if prefixed by namespace and type`, () => { + const _id = `${raw._source.namespace}:${raw._id}`; + const _actual = multiNamespaceSerializer.rawToSavedObject({ ...raw, _id }); + expect(_actual).toHaveProperty('id', _id); }); - describe('namespace agnostic type with a namespace', () => { - beforeEach(() => { - typeRegistry.isNamespaceAgnostic.mockReturnValue(true); - }); + test(`doesn't copy _source.namespace to namespace`, () => { + expect(actual).not.toHaveProperty('namespace'); + }); + }); - test(`removes type prefix from _id`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - namespace: 'baz', - }, - }); + describe('multi-namespace type with namespaces', () => { + const raw = createSampleDoc({ _source: { namespaces: ['baz'] } }); + const actual = multiNamespaceSerializer.rawToSavedObject(raw); - expect(actual).toHaveProperty('id', 'bar'); - }); + test(`copies _source.namespaces to namespaces`, () => { + expect(actual).toHaveProperty('namespaces', ['baz']); + }); + }); +}); - test(`if prefixed by namespace and type it copies _id to id`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'baz:foo:bar', - _source: { - type: 'foo', - namespace: 'baz', - }, - }); +describe('#savedObjectToRaw', () => { + test('it copies the type property to _source.type and uses the ROOT_TYPE as _type', () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + attributes: {}, + } as any); - expect(actual).toHaveProperty('id', 'baz:foo:bar'); - }); + expect(actual._source).toHaveProperty('type', 'foo'); + }); - test(`doesn't copy _source.namespace to namespace`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'baz:foo:bar', - _source: { - type: 'foo', - namespace: 'baz', - }, - }); + test('it copies the references property to _source.references', () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + id: '1', + type: 'foo', + attributes: {}, + references: [{ name: 'ref_0', type: 'index-pattern', id: 'pattern*' }], + }); + expect(actual._source).toHaveProperty('references', [ + { + name: 'ref_0', + type: 'index-pattern', + id: 'pattern*', + }, + ]); + }); + + test('if specified it copies the updated_at property to _source.updated_at', () => { + const now = new Date(); + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + updated_at: now, + } as any); + + expect(actual._source).toHaveProperty('updated_at', now); + }); + + test(`if unspecified it doesn't add updated_at property to _source`, () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + } as any); - expect(actual).not.toHaveProperty('namespace'); - }); + expect(actual._source).not.toHaveProperty('updated_at'); + }); + + test('it copies the migrationVersion property to _source.migrationVersion', () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + migrationVersion: { + foo: '1.2.3', + bar: '9.8.7', + }, + } as any); + + expect(actual._source).toHaveProperty('migrationVersion', { + foo: '1.2.3', + bar: '9.8.7', }); }); - describe('#savedObjectToRaw', () => { - test('it copies the type property to _source.type and uses the ROOT_TYPE as _type', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: 'foo', + test(`if unspecified it doesn't add migrationVersion property to _source`, () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + } as any); + + expect(actual._source).not.toHaveProperty('migrationVersion'); + }); + + test('it decodes the version property to _seq_no and _primary_term', () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + version: encodeVersion(1, 2), + } as any); + + expect(actual).toHaveProperty('_seq_no', 1); + expect(actual).toHaveProperty('_primary_term', 2); + }); + + test(`if unspecified it doesn't add _seq_no or _primary_term properties`, () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + } as any); + + expect(actual).not.toHaveProperty('_seq_no'); + expect(actual).not.toHaveProperty('_primary_term'); + }); + + test(`if version invalid it throws`, () => { + expect(() => + singleNamespaceSerializer.savedObjectToRaw({ + type: '', attributes: {}, - } as any); + version: 'foo', + } as any) + ).toThrowErrorMatchingInlineSnapshot(`"Invalid version [foo]"`); + }); - expect(actual._source).toHaveProperty('type', 'foo'); + test('it copies attributes to _source[type]', () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + attributes: { + foo: true, + bar: 'quz', + }, + } as any); + + expect(actual._source).toHaveProperty('foo', { + foo: true, + bar: 'quz', }); + }); - test('it copies the references property to _source.references', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - id: '1', + describe('single-namespace type without a namespace', () => { + test('generates an id prefixed with type, if no id is specified', () => { + const v1 = singleNamespaceSerializer.savedObjectToRaw({ type: 'foo', - attributes: {}, - references: [{ name: 'ref_0', type: 'index-pattern', id: 'pattern*' }], - }); - expect(actual._source).toHaveProperty('references', [ - { - name: 'ref_0', - type: 'index-pattern', - id: 'pattern*', - }, - ]); + attributes: { bar: true }, + } as any); + + const v2 = singleNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + attributes: { bar: true }, + } as any); + + expect(v1._id).toMatch(/^foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); }); - test('if specified it copies the updated_at property to _source.updated_at', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const now = new Date(); - const actual = serializer.savedObjectToRaw({ + test(`doesn't specify _source.namespace`, () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ type: '', attributes: {}, - updated_at: now, } as any); - expect(actual._source).toHaveProperty('updated_at', now); + expect(actual._source).not.toHaveProperty('namespace'); }); + }); - test(`if unspecified it doesn't add updated_at property to _source`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: '', + describe('single-namespace type with a namespace', () => { + test('generates an id prefixed with namespace and type, if no id is specified', () => { + const v1 = singleNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: { bar: true }, + } as any); + + const v2 = singleNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: { bar: true }, + } as any); + + expect(v1._id).toMatch(/^bar\:foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); + }); + + test(`it copies namespace to _source.namespace`, () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: 'foo', attributes: {}, + namespace: 'bar', } as any); - expect(actual._source).not.toHaveProperty('updated_at'); + expect(actual._source).toHaveProperty('namespace', 'bar'); }); + }); - test('it copies the migrationVersion property to _source.migrationVersion', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: '', + describe('single-namespace type with namespaces', () => { + test('generates an id prefixed with type, if no id is specified', () => { + const v1 = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespaces: ['bar'], + attributes: { bar: true }, + } as any); + + const v2 = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespaces: ['bar'], + attributes: { bar: true }, + } as any); + + expect(v1._id).toMatch(/^foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); + }); + + test(`doesn't specify _source.namespaces`, () => { + const actual = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespaces: ['bar'], attributes: {}, - migrationVersion: { - foo: '1.2.3', - bar: '9.8.7', - }, } as any); - expect(actual._source).toHaveProperty('migrationVersion', { - foo: '1.2.3', - bar: '9.8.7', - }); + expect(actual._source).not.toHaveProperty('namespaces'); }); + }); - test(`if unspecified it doesn't add migrationVersion property to _source`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: '', + describe('namespace-agnostic type with a namespace', () => { + test('generates an id prefixed with type, if no id is specified', () => { + const v1 = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: { bar: true }, + } as any); + + const v2 = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: { bar: true }, + } as any); + + expect(v1._id).toMatch(/^foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); + }); + + test(`doesn't specify _source.namespace`, () => { + const actual = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', attributes: {}, } as any); - expect(actual._source).not.toHaveProperty('migrationVersion'); + expect(actual._source).not.toHaveProperty('namespace'); }); + }); - test('it decodes the version property to _seq_no and _primary_term', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: '', + describe('namespace-agnostic type with namespaces', () => { + test('generates an id prefixed with type, if no id is specified', () => { + const v1 = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespaces: ['bar'], + attributes: { bar: true }, + } as any); + + const v2 = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespaces: ['bar'], + attributes: { bar: true }, + } as any); + + expect(v1._id).toMatch(/^foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); + }); + + test(`doesn't specify _source.namespaces`, () => { + const actual = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespaces: ['bar'], attributes: {}, - version: encodeVersion(1, 2), } as any); - expect(actual).toHaveProperty('_seq_no', 1); - expect(actual).toHaveProperty('_primary_term', 2); + expect(actual._source).not.toHaveProperty('namespaces'); }); + }); - test(`if unspecified it doesn't add _seq_no or _primary_term properties`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: '', + describe('multi-namespace type with a namespace', () => { + test('generates an id prefixed with type, if no id is specified', () => { + const v1 = multiNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: { bar: true }, + } as any); + + const v2 = multiNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: { bar: true }, + } as any); + + expect(v1._id).toMatch(/^foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); + }); + + test(`doesn't specify _source.namespace`, () => { + const actual = multiNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', attributes: {}, } as any); - expect(actual).not.toHaveProperty('_seq_no'); - expect(actual).not.toHaveProperty('_primary_term'); + expect(actual._source).not.toHaveProperty('namespace'); }); + }); + + describe('multi-namespace type with namespaces', () => { + test('generates an id prefixed with type, if no id is specified', () => { + const v1 = multiNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + namespaces: ['bar'], + attributes: { bar: true }, + } as any); + + const v2 = multiNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + namespaces: ['bar'], + attributes: { bar: true }, + } as any); - test(`if version invalid it throws`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect(() => - serializer.savedObjectToRaw({ - type: '', - attributes: {}, - version: 'foo', - } as any) - ).toThrowErrorMatchingInlineSnapshot(`"Invalid version [foo]"`); + expect(v1._id).toMatch(/^foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); }); - test('it copies attributes to _source[type]', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ + test(`it copies namespaces to _source.namespaces`, () => { + const actual = multiNamespaceSerializer.savedObjectToRaw({ type: 'foo', - attributes: { - foo: true, - bar: 'quz', - }, + namespaces: ['bar'], + attributes: {}, } as any); - expect(actual._source).toHaveProperty('foo', { - foo: true, - bar: 'quz', - }); + expect(actual._source).toHaveProperty('namespaces', ['bar']); }); + }); +}); - describe('namespaced type without a namespace', () => { - test('generates an id prefixed with type, if no id is specified', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const v1 = serializer.savedObjectToRaw({ - type: 'foo', - attributes: { - bar: true, +describe('#isRawSavedObject', () => { + describe('single-namespace type without a namespace', () => { + test('is true if the id is prefixed and the type matches', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + hello: {}, }, - } as any); + }) + ).toBeTruthy(); + }); - const v2 = serializer.savedObjectToRaw({ - type: 'foo', - attributes: { - bar: true, + test('is false if the id is not prefixed', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'world', + _source: { + type: 'hello', + hello: {}, }, - } as any); + }) + ).toBeFalsy(); + }); - expect(v1._id).toMatch(/foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); + test('is false if the type attribute is missing', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + hello: {}, + } as any, + }) + ).toBeFalsy(); + }); - test(`doesn't specify _source.namespace`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: '', - attributes: {}, - } as any); + test(`is false if the type prefix omits the :`, () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'helloworld', + _source: { + type: 'hello', + hello: {}, + }, + }) + ).toBeFalsy(); + }); - expect(actual._source).not.toHaveProperty('namespace'); - }); + test('is false if the type attribute does not match the id', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'jam', + jam: {}, + hello: {}, + }, + }) + ).toBeFalsy(); }); - describe('namespaced type with a namespace', () => { - test('generates an id prefixed with namespace and type, if no id is specified', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const v1 = serializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { - bar: true, + test('is false if there is no [type] attribute', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + jam: {}, }, - } as any); + }) + ).toBeFalsy(); + }); + }); - const v2 = serializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { - bar: true, + describe('single-namespace type with a namespace', () => { + test('is true if the id is prefixed with type and namespace and the type matches', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'foo:hello:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', }, - } as any); + }) + ).toBeTruthy(); + }); - expect(v1._id).toMatch(/bar\:foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); + test('is false if the id is not prefixed by anything', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); - test(`it copies namespace to _source.namespace`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: 'foo', - attributes: {}, - namespace: 'bar', - } as any); + test('is false if the id is prefixed only with type and the type matches', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if the id is prefixed only with namespace and the namespace matches', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'foo:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); - expect(actual._source).toHaveProperty('namespace', 'bar'); - }); + test(`is false if the id prefix omits the trailing :`, () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'foo:helloworld', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); }); - describe('namespace agnostic type with a namespace', () => { - beforeEach(() => { - typeRegistry.isNamespaceAgnostic.mockReturnValue(true); - }); + test('is false if the type attribute is missing', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'foo:hello:world', + _source: { + hello: {}, + namespace: 'foo', + } as any, + }) + ).toBeFalsy(); + }); - test('generates an id prefixed with type, if no id is specified', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const v1 = serializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { - bar: true, + test('is false if the type attribute does not match the id', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'foo:hello:world', + _source: { + type: 'jam', + jam: {}, + hello: {}, + namespace: 'foo', }, - } as any); + }) + ).toBeFalsy(); + }); - const v2 = serializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { - bar: true, + test('is false if the namespace attribute does not match the id', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'bar:jam:world', + _source: { + type: 'jam', + jam: {}, + hello: {}, + namespace: 'foo', }, - } as any); + }) + ).toBeFalsy(); + }); - expect(v1._id).toMatch(/foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); + test('is false if there is no [type] attribute', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + jam: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + }); - test(`doesn't specify _source.namespace`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: {}, - } as any); + describe('namespace-agnostic type with a namespace', () => { + test('is true if the id is prefixed with type and the type matches', () => { + expect( + namespaceAgnosticSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeTruthy(); + }); + + test('is false if the id is not prefixed', () => { + expect( + namespaceAgnosticSerializer.isRawSavedObject({ + _id: 'world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if the id is prefixed with type and namespace', () => { + expect( + namespaceAgnosticSerializer.isRawSavedObject({ + _id: 'foo:hello:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test(`is false if the type prefix omits the :`, () => { + expect( + namespaceAgnosticSerializer.isRawSavedObject({ + _id: 'helloworld', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if the type attribute is missing', () => { + expect( + namespaceAgnosticSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + hello: {}, + namespace: 'foo', + } as any, + }) + ).toBeFalsy(); + }); + + test('is false if the type attribute does not match the id', () => { + expect( + namespaceAgnosticSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'jam', + jam: {}, + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if there is no [type] attribute', () => { + expect( + namespaceAgnosticSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + jam: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + }); +}); - expect(actual._source).not.toHaveProperty('namespace'); - }); +describe('#generateRawId', () => { + describe('single-namespace type without a namespace', () => { + test('generates an id if none is specified', () => { + const id = singleNamespaceSerializer.generateRawId('', 'goodbye'); + expect(id).toMatch(/^goodbye\:[\w-]+$/); + }); + + test('uses the id that is specified', () => { + const id = singleNamespaceSerializer.generateRawId('', 'hello', 'world'); + expect(id).toEqual('hello:world'); }); }); - describe('#isRawSavedObject', () => { - describe('namespaced type without a namespace', () => { - test('is true if the id is prefixed and the type matches', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'hello', - hello: {}, - }, - }) - ).toBeTruthy(); - }); - - test('is false if the id is not prefixed', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'world', - _source: { - type: 'hello', - hello: {}, - }, - }) - ).toBeFalsy(); - }); - - test('is false if the type attribute is missing', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - hello: {}, - } as any, - }) - ).toBeFalsy(); - }); - - test(`is false if the type prefix omits the :`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'helloworld', - _source: { - type: 'hello', - hello: {}, - }, - }) - ).toBeFalsy(); - }); - - test('is false if the type attribute does not match the id', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'jam', - jam: {}, - hello: {}, - }, - }) - ).toBeFalsy(); - }); - - test('is false if there is no [type] attribute', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'hello', - jam: {}, - }, - }) - ).toBeFalsy(); - }); - }); - - describe('namespaced type with a namespace', () => { - test('is true if the id is prefixed with type and namespace and the type matches', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'foo:hello:world', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeTruthy(); - }); - - test('is false if the id is not prefixed by anything', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'world', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test('is false if the id is prefixed only with type and the type matches', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test('is false if the id is prefixed only with namespace and the namespace matches', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'foo:world', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test(`is false if the id prefix omits the trailing :`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'foo:helloworld', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test('is false if the type attribute is missing', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'foo:hello:world', - _source: { - hello: {}, - namespace: 'foo', - } as any, - }) - ).toBeFalsy(); - }); - - test('is false if the type attribute does not match the id', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'foo:hello:world', - _source: { - type: 'jam', - jam: {}, - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test('is false if the namespace attribute does not match the id', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'bar:jam:world', - _source: { - type: 'jam', - jam: {}, - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test('is false if there is no [type] attribute', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'hello', - jam: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - }); - - describe('namespace agnostic type with a namespace', () => { - beforeEach(() => { - typeRegistry.isNamespaceAgnostic.mockReturnValue(true); - }); - - test('is true if the id is prefixed with type and the type matches', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeTruthy(); - }); - - test('is false if the id is not prefixed', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'world', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test('is false if the id is prefixed with type and namespace', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'foo:hello:world', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test(`is false if the type prefix omits the :`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'helloworld', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test('is false if the type attribute is missing', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - hello: {}, - namespace: 'foo', - } as any, - }) - ).toBeFalsy(); - }); - - test('is false if the type attribute does not match the id', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'jam', - jam: {}, - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test('is false if there is no [type] attribute', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'hello', - jam: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); + describe('single-namespace type with a namespace', () => { + test('generates an id if none is specified and prefixes namespace', () => { + const id = singleNamespaceSerializer.generateRawId('foo', 'goodbye'); + expect(id).toMatch(/^foo:goodbye\:[\w-]+$/); + }); + + test('uses the id that is specified and prefixes the namespace', () => { + const id = singleNamespaceSerializer.generateRawId('foo', 'hello', 'world'); + expect(id).toEqual('foo:hello:world'); }); }); - describe('#generateRawId', () => { - describe('namespaced type without a namespace', () => { - test('generates an id if none is specified', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const id = serializer.generateRawId('', 'goodbye'); - expect(id).toMatch(/goodbye\:[\w-]+$/); - }); - - test('uses the id that is specified', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const id = serializer.generateRawId('', 'hello', 'world'); - expect(id).toMatch('hello:world'); - }); - }); - - describe('namespaced type with a namespace', () => { - test('generates an id if none is specified and prefixes namespace', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const id = serializer.generateRawId('foo', 'goodbye'); - expect(id).toMatch(/foo:goodbye\:[\w-]+$/); - }); - - test('uses the id that is specified and prefixes the namespace', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const id = serializer.generateRawId('foo', 'hello', 'world'); - expect(id).toMatch('foo:hello:world'); - }); - }); - - describe('namespace agnostic type with a namespace', () => { - test(`generates an id if none is specified and doesn't prefix namespace`, () => { - typeRegistry.isNamespaceAgnostic.mockReturnValue(true); - - const serializer = new SavedObjectsSerializer(typeRegistry); - const id = serializer.generateRawId('foo', 'goodbye'); - expect(id).toMatch(/goodbye\:[\w-]+$/); - }); - - test(`uses the id that is specified and doesn't prefix the namespace`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const id = serializer.generateRawId('foo', 'hello', 'world'); - expect(id).toMatch('hello:world'); - }); + describe('namespace-agnostic type with a namespace', () => { + test(`generates an id if none is specified and doesn't prefix namespace`, () => { + const id = namespaceAgnosticSerializer.generateRawId('foo', 'goodbye'); + expect(id).toMatch(/^goodbye\:[\w-]+$/); + }); + + test(`uses the id that is specified and doesn't prefix the namespace`, () => { + const id = namespaceAgnosticSerializer.generateRawId('foo', 'hello', 'world'); + expect(id).toEqual('hello:world'); }); }); }); diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 99d6b0c6b59f9..3b19d494d8ecf 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -49,7 +49,7 @@ export class SavedObjectsSerializer { public isRawSavedObject(rawDoc: SavedObjectsRawDoc) { const { type, namespace } = rawDoc._source; const namespacePrefix = - namespace && !this.registry.isNamespaceAgnostic(type) ? `${namespace}:` : ''; + namespace && this.registry.isSingleNamespace(type) ? `${namespace}:` : ''; return Boolean( type && rawDoc._id.startsWith(`${namespacePrefix}${type}:`) && @@ -64,7 +64,7 @@ export class SavedObjectsSerializer { */ public rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc { const { _id, _source, _seq_no, _primary_term } = doc; - const { type, namespace } = _source; + const { type, namespace, namespaces } = _source; const version = _seq_no != null || _primary_term != null @@ -74,7 +74,8 @@ export class SavedObjectsSerializer { return { type, id: this.trimIdPrefix(namespace, type, _id), - ...(namespace && !this.registry.isNamespaceAgnostic(type) && { namespace }), + ...(namespace && this.registry.isSingleNamespace(type) && { namespace }), + ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), attributes: _source[type], references: _source.references || [], ...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }), @@ -93,6 +94,7 @@ export class SavedObjectsSerializer { id, type, namespace, + namespaces, attributes, migrationVersion, updated_at, @@ -103,7 +105,8 @@ export class SavedObjectsSerializer { [type]: attributes, type, references, - ...(namespace && !this.registry.isNamespaceAgnostic(type) && { namespace }), + ...(namespace && this.registry.isSingleNamespace(type) && { namespace }), + ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), ...(migrationVersion && { migrationVersion }), ...(updated_at && { updated_at }), }; @@ -124,7 +127,7 @@ export class SavedObjectsSerializer { */ public generateRawId(namespace: string | undefined, type: string, id?: string) { const namespacePrefix = - namespace && !this.registry.isNamespaceAgnostic(type) ? `${namespace}:` : ''; + namespace && this.registry.isSingleNamespace(type) ? `${namespace}:` : ''; return `${namespacePrefix}${type}:${id || uuid.v1()}`; } @@ -133,7 +136,7 @@ export class SavedObjectsSerializer { assertNonEmptyString(type, 'saved object type'); const namespacePrefix = - namespace && !this.registry.isNamespaceAgnostic(type) ? `${namespace}:` : ''; + namespace && this.registry.isSingleNamespace(type) ? `${namespace}:` : ''; const prefix = `${namespacePrefix}${type}:`; if (!id.startsWith(prefix)) { diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index dfaec127ba159..7ea61f67e9496 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -36,6 +36,7 @@ export interface SavedObjectsRawDoc { export interface SavedObjectsRawDocSource { type: string; namespace?: string; + namespaces?: string[]; migrationVersion?: SavedObjectsMigrationVersion; updated_at?: string; references?: SavedObjectReference[]; @@ -54,6 +55,7 @@ interface SavedObjectDoc { id?: string; // NOTE: SavedObjectDoc is used for uncreated objects where `id` is optional type: string; namespace?: string; + namespaces?: string[]; migrationVersion?: SavedObjectsMigrationVersion; version?: string; updated_at?: string; diff --git a/src/core/server/saved_objects/service/index.ts b/src/core/server/saved_objects/service/index.ts index f44824238aa21..9f625b4732e26 100644 --- a/src/core/server/saved_objects/service/index.ts +++ b/src/core/server/saved_objects/service/index.ts @@ -36,7 +36,6 @@ export interface SavedObjectsLegacyService { getScopedSavedObjectsClient: SavedObjectsClientProvider['getClient']; SavedObjectsClient: typeof SavedObjectsClient; types: string[]; - importAndExportableTypes: string[]; schema: SavedObjectsSchema; getSavedObjectsRepository(...rest: any[]): any; importExport: { diff --git a/src/core/server/saved_objects/service/lib/__snapshots__/repository.test.js.snap b/src/core/server/saved_objects/service/lib/__snapshots__/repository.test.js.snap deleted file mode 100644 index 609906c97d599..0000000000000 --- a/src/core/server/saved_objects/service/lib/__snapshots__/repository.test.js.snap +++ /dev/null @@ -1,5 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SavedObjectsRepository #deleteByNamespace requires namespace to be a string 1`] = `"namespace is required, and must be a string"`; - -exports[`SavedObjectsRepository #deleteByNamespace requires namespace to be defined 1`] = `"namespace is required, and must be a string"`; diff --git a/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts b/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts index 2fd9b487f470a..1fdebd87397eb 100644 --- a/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts +++ b/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts @@ -100,6 +100,38 @@ describe('savedObjectsClient/decorateEsError', () => { expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(true); }); + describe('when es.BadRequest has a reason', () => { + it('makes a SavedObjectsClient/esCannotExecuteScriptError error when script context is disabled', () => { + const error = new esErrors.BadRequest(); + (error as Record).body = { + error: { reason: 'cannot execute scripts using [update] context' }, + }; + expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(false); + expect(decorateEsError(error)).toBe(error); + expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(true); + expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(false); + }); + + it('makes a SavedObjectsClient/esCannotExecuteScriptError error when inline scripts are disabled', () => { + const error = new esErrors.BadRequest(); + (error as Record).body = { + error: { reason: 'cannot execute [inline] scripts' }, + }; + expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(false); + expect(decorateEsError(error)).toBe(error); + expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(true); + expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(false); + }); + + it('makes a SavedObjectsClient/BadRequest error for any other reason', () => { + const error = new esErrors.BadRequest(); + (error as Record).body = { error: { reason: 'some other reason' } }; + expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(false); + expect(decorateEsError(error)).toBe(error); + expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(true); + }); + }); + it('returns other errors as Boom errors', () => { const error = new Error(); expect(error).not.toHaveProperty('isBoom'); diff --git a/src/core/server/saved_objects/service/lib/decorate_es_error.ts b/src/core/server/saved_objects/service/lib/decorate_es_error.ts index eb9bc89636435..e57f08aa7a527 100644 --- a/src/core/server/saved_objects/service/lib/decorate_es_error.ts +++ b/src/core/server/saved_objects/service/lib/decorate_es_error.ts @@ -35,6 +35,8 @@ const { NotFound, BadRequest, } = legacyElasticsearch.errors; +const SCRIPT_CONTEXT_DISABLED_REGEX = /(?:cannot execute scripts using \[)([a-z]*)(?:\] context)/; +const INLINE_SCRIPTS_DISABLED_MESSAGE = 'cannot execute [inline] scripts'; import { SavedObjectsErrorHelpers } from './errors'; @@ -43,7 +45,7 @@ export function decorateEsError(error: Error) { throw new Error('Expected an instance of Error'); } - const { reason } = get(error, 'body.error', { reason: undefined }); + const { reason } = get(error, 'body.error', { reason: undefined }) as { reason?: string }; if ( error instanceof ConnectionFault || error instanceof ServiceUnavailable || @@ -74,6 +76,12 @@ export function decorateEsError(error: Error) { } if (error instanceof BadRequest) { + if ( + SCRIPT_CONTEXT_DISABLED_REGEX.test(reason || '') || + reason === INLINE_SCRIPTS_DISABLED_MESSAGE + ) { + return SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError(error, reason); + } return SavedObjectsErrorHelpers.decorateBadRequestError(error, reason); } diff --git a/src/core/server/saved_objects/service/lib/errors.test.ts b/src/core/server/saved_objects/service/lib/errors.test.ts index 12fc913f93090..4a43835d795d1 100644 --- a/src/core/server/saved_objects/service/lib/errors.test.ts +++ b/src/core/server/saved_objects/service/lib/errors.test.ts @@ -34,6 +34,7 @@ describe('savedObjectsClient/errorTypes', () => { }); it('has boom properties', () => { + expect(errorObj).toHaveProperty('isBoom', true); expect(errorObj.output.payload).toMatchObject({ statusCode: 400, message: "Unsupported saved object type: 'someType': Bad Request", @@ -57,6 +58,7 @@ describe('savedObjectsClient/errorTypes', () => { }); it('has boom properties', () => { + expect(errorObj).toHaveProperty('isBoom', true); expect(errorObj.output.payload).toMatchObject({ statusCode: 400, message: 'test reason message: Bad Request', @@ -80,14 +82,7 @@ describe('savedObjectsClient/errorTypes', () => { it('adds boom properties', () => { const error = SavedObjectsErrorHelpers.decorateBadRequestError(new Error()); - expect(typeof error.output).toBe('object'); - expect(error.output.statusCode).toBe(400); - }); - - it('preserves boom properties of input', () => { - const error = Boom.notFound(); - SavedObjectsErrorHelpers.decorateBadRequestError(error); - expect(error.output.statusCode).toBe(404); + expect(error).toHaveProperty('isBoom', true); }); describe('error.output', () => { @@ -95,6 +90,7 @@ describe('savedObjectsClient/errorTypes', () => { const error = SavedObjectsErrorHelpers.decorateBadRequestError(new Error('foobar')); expect(error.output.payload).toHaveProperty('message', 'foobar'); }); + it('prefixes message with passed reason', () => { const error = SavedObjectsErrorHelpers.decorateBadRequestError( new Error('foobar'), @@ -102,13 +98,21 @@ describe('savedObjectsClient/errorTypes', () => { ); expect(error.output.payload).toHaveProperty('message', 'biz: foobar'); }); + it('sets statusCode to 400', () => { const error = SavedObjectsErrorHelpers.decorateBadRequestError(new Error('foo')); expect(error.output).toHaveProperty('statusCode', 400); }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + SavedObjectsErrorHelpers.decorateBadRequestError(error); + expect(error.output).toHaveProperty('statusCode', 404); + }); }); }); }); + describe('NotAuthorized error', () => { describe('decorateNotAuthorizedError', () => { it('returns original object', () => { @@ -125,14 +129,7 @@ describe('savedObjectsClient/errorTypes', () => { it('adds boom properties', () => { const error = SavedObjectsErrorHelpers.decorateNotAuthorizedError(new Error()); - expect(typeof error.output).toBe('object'); - expect(error.output.statusCode).toBe(401); - }); - - it('preserves boom properties of input', () => { - const error = Boom.notFound(); - SavedObjectsErrorHelpers.decorateNotAuthorizedError(error); - expect(error.output.statusCode).toBe(404); + expect(error).toHaveProperty('isBoom', true); }); describe('error.output', () => { @@ -140,6 +137,7 @@ describe('savedObjectsClient/errorTypes', () => { const error = SavedObjectsErrorHelpers.decorateNotAuthorizedError(new Error('foobar')); expect(error.output.payload).toHaveProperty('message', 'foobar'); }); + it('prefixes message with passed reason', () => { const error = SavedObjectsErrorHelpers.decorateNotAuthorizedError( new Error('foobar'), @@ -147,13 +145,21 @@ describe('savedObjectsClient/errorTypes', () => { ); expect(error.output.payload).toHaveProperty('message', 'biz: foobar'); }); + it('sets statusCode to 401', () => { const error = SavedObjectsErrorHelpers.decorateNotAuthorizedError(new Error('foo')); expect(error.output).toHaveProperty('statusCode', 401); }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + SavedObjectsErrorHelpers.decorateNotAuthorizedError(error); + expect(error.output).toHaveProperty('statusCode', 404); + }); }); }); }); + describe('Forbidden error', () => { describe('decorateForbiddenError', () => { it('returns original object', () => { @@ -170,14 +176,7 @@ describe('savedObjectsClient/errorTypes', () => { it('adds boom properties', () => { const error = SavedObjectsErrorHelpers.decorateForbiddenError(new Error()); - expect(typeof error.output).toBe('object'); - expect(error.output.statusCode).toBe(403); - }); - - it('preserves boom properties of input', () => { - const error = Boom.notFound(); - SavedObjectsErrorHelpers.decorateForbiddenError(error); - expect(error.output.statusCode).toBe(404); + expect(error).toHaveProperty('isBoom', true); }); describe('error.output', () => { @@ -185,17 +184,26 @@ describe('savedObjectsClient/errorTypes', () => { const error = SavedObjectsErrorHelpers.decorateForbiddenError(new Error('foobar')); expect(error.output.payload).toHaveProperty('message', 'foobar'); }); + it('prefixes message with passed reason', () => { const error = SavedObjectsErrorHelpers.decorateForbiddenError(new Error('foobar'), 'biz'); expect(error.output.payload).toHaveProperty('message', 'biz: foobar'); }); + it('sets statusCode to 403', () => { const error = SavedObjectsErrorHelpers.decorateForbiddenError(new Error('foo')); expect(error.output).toHaveProperty('statusCode', 403); }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + SavedObjectsErrorHelpers.decorateForbiddenError(error); + expect(error.output).toHaveProperty('statusCode', 404); + }); }); }); }); + describe('NotFound error', () => { describe('createGenericNotFoundError', () => { it('makes an error identifiable as a NotFound error', () => { @@ -203,11 +211,9 @@ describe('savedObjectsClient/errorTypes', () => { expect(SavedObjectsErrorHelpers.isNotFoundError(error)).toBe(true); }); - it('is a boom error, has boom properties', () => { + it('returns a boom error', () => { const error = SavedObjectsErrorHelpers.createGenericNotFoundError(); - expect(error).toHaveProperty('isBoom'); - expect(typeof error.output).toBe('object'); - expect(error.output.statusCode).toBe(404); + expect(error).toHaveProperty('isBoom', true); }); describe('error.output', () => { @@ -215,6 +221,7 @@ describe('savedObjectsClient/errorTypes', () => { const error = SavedObjectsErrorHelpers.createGenericNotFoundError(); expect(error.output.payload).toHaveProperty('message', 'Not Found'); }); + it('sets statusCode to 404', () => { const error = SavedObjectsErrorHelpers.createGenericNotFoundError(); expect(error.output).toHaveProperty('statusCode', 404); @@ -222,6 +229,7 @@ describe('savedObjectsClient/errorTypes', () => { }); }); }); + describe('Conflict error', () => { describe('decorateConflictError', () => { it('returns original object', () => { @@ -238,14 +246,7 @@ describe('savedObjectsClient/errorTypes', () => { it('adds boom properties', () => { const error = SavedObjectsErrorHelpers.decorateConflictError(new Error()); - expect(typeof error.output).toBe('object'); - expect(error.output.statusCode).toBe(409); - }); - - it('preserves boom properties of input', () => { - const error = Boom.notFound(); - SavedObjectsErrorHelpers.decorateConflictError(error); - expect(error.output.statusCode).toBe(404); + expect(error).toHaveProperty('isBoom', true); }); describe('error.output', () => { @@ -253,17 +254,77 @@ describe('savedObjectsClient/errorTypes', () => { const error = SavedObjectsErrorHelpers.decorateConflictError(new Error('foobar')); expect(error.output.payload).toHaveProperty('message', 'foobar'); }); + it('prefixes message with passed reason', () => { const error = SavedObjectsErrorHelpers.decorateConflictError(new Error('foobar'), 'biz'); expect(error.output.payload).toHaveProperty('message', 'biz: foobar'); }); + it('sets statusCode to 409', () => { const error = SavedObjectsErrorHelpers.decorateConflictError(new Error('foo')); expect(error.output).toHaveProperty('statusCode', 409); }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + SavedObjectsErrorHelpers.decorateConflictError(error); + expect(error.output).toHaveProperty('statusCode', 404); + }); + }); + }); + }); + + describe('EsCannotExecuteScript error', () => { + describe('decorateEsCannotExecuteScriptError', () => { + it('returns original object', () => { + const error = new Error(); + expect(SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError(error)).toBe(error); + }); + + it('makes the error identifiable as a EsCannotExecuteScript error', () => { + const error = new Error(); + expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(false); + SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError(error); + expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(true); + }); + + it('adds boom properties', () => { + const error = SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError(new Error()); + expect(error).toHaveProperty('isBoom', true); + }); + + describe('error.output', () => { + it('defaults to message of error', () => { + const error = SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError( + new Error('foobar') + ); + expect(error.output.payload).toHaveProperty('message', 'foobar'); + }); + + it('prefixes message with passed reason', () => { + const error = SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError( + new Error('foobar'), + 'biz' + ); + expect(error.output.payload).toHaveProperty('message', 'biz: foobar'); + }); + + it('sets statusCode to 501', () => { + const error = SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError( + new Error('foo') + ); + expect(error.output).toHaveProperty('statusCode', 400); + }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError(error); + expect(error.output).toHaveProperty('statusCode', 404); + }); }); }); }); + describe('EsUnavailable error', () => { describe('decorateEsUnavailableError', () => { it('returns original object', () => { @@ -280,14 +341,7 @@ describe('savedObjectsClient/errorTypes', () => { it('adds boom properties', () => { const error = SavedObjectsErrorHelpers.decorateEsUnavailableError(new Error()); - expect(typeof error.output).toBe('object'); - expect(error.output.statusCode).toBe(503); - }); - - it('preserves boom properties of input', () => { - const error = Boom.notFound(); - SavedObjectsErrorHelpers.decorateEsUnavailableError(error); - expect(error.output.statusCode).toBe(404); + expect(error).toHaveProperty('isBoom', true); }); describe('error.output', () => { @@ -295,6 +349,7 @@ describe('savedObjectsClient/errorTypes', () => { const error = SavedObjectsErrorHelpers.decorateEsUnavailableError(new Error('foobar')); expect(error.output.payload).toHaveProperty('message', 'foobar'); }); + it('prefixes message with passed reason', () => { const error = SavedObjectsErrorHelpers.decorateEsUnavailableError( new Error('foobar'), @@ -302,13 +357,21 @@ describe('savedObjectsClient/errorTypes', () => { ); expect(error.output.payload).toHaveProperty('message', 'biz: foobar'); }); + it('sets statusCode to 503', () => { const error = SavedObjectsErrorHelpers.decorateEsUnavailableError(new Error('foo')); expect(error.output).toHaveProperty('statusCode', 503); }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + SavedObjectsErrorHelpers.decorateEsUnavailableError(error); + expect(error.output).toHaveProperty('statusCode', 404); + }); }); }); }); + describe('General error', () => { describe('decorateGeneralError', () => { it('returns original object', () => { @@ -318,14 +381,7 @@ describe('savedObjectsClient/errorTypes', () => { it('adds boom properties', () => { const error = SavedObjectsErrorHelpers.decorateGeneralError(new Error()); - expect(typeof error.output).toBe('object'); - expect(error.output.statusCode).toBe(500); - }); - - it('preserves boom properties of input', () => { - const error = Boom.notFound(); - SavedObjectsErrorHelpers.decorateGeneralError(error); - expect(error.output.statusCode).toBe(404); + expect(error).toHaveProperty('isBoom', true); }); describe('error.output', () => { @@ -333,10 +389,17 @@ describe('savedObjectsClient/errorTypes', () => { const error = SavedObjectsErrorHelpers.decorateGeneralError(new Error('foobar')); expect(error.output.payload.message).toMatch(/internal server error/i); }); + it('sets statusCode to 500', () => { const error = SavedObjectsErrorHelpers.decorateGeneralError(new Error('foo')); expect(error.output).toHaveProperty('statusCode', 500); }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + SavedObjectsErrorHelpers.decorateGeneralError(error); + expect(error.output).toHaveProperty('statusCode', 404); + }); }); }); }); @@ -363,9 +426,7 @@ describe('savedObjectsClient/errorTypes', () => { it('returns a boom error', () => { const error = SavedObjectsErrorHelpers.createEsAutoCreateIndexError(); - expect(error).toHaveProperty('isBoom'); - expect(typeof error.output).toBe('object'); - expect(error.output.statusCode).toBe(503); + expect(error).toHaveProperty('isBoom', true); }); describe('error.output', () => { @@ -373,6 +434,7 @@ describe('savedObjectsClient/errorTypes', () => { const error = SavedObjectsErrorHelpers.createEsAutoCreateIndexError(); expect(error.output.payload).toHaveProperty('message', 'Automatic index creation failed'); }); + it('sets statusCode to 503', () => { const error = SavedObjectsErrorHelpers.createEsAutoCreateIndexError(); expect(error.output).toHaveProperty('statusCode', 503); diff --git a/src/core/server/saved_objects/service/lib/errors.ts b/src/core/server/saved_objects/service/lib/errors.ts index e9138e9b8a347..478c6b6d26d53 100644 --- a/src/core/server/saved_objects/service/lib/errors.ts +++ b/src/core/server/saved_objects/service/lib/errors.ts @@ -33,6 +33,8 @@ const CODE_REQUEST_ENTITY_TOO_LARGE = 'SavedObjectsClient/requestEntityTooLarge' const CODE_NOT_FOUND = 'SavedObjectsClient/notFound'; // 409 - Conflict const CODE_CONFLICT = 'SavedObjectsClient/conflict'; +// 400 - Es Cannot Execute Script +const CODE_ES_CANNOT_EXECUTE_SCRIPT = 'SavedObjectsClient/esCannotExecuteScript'; // 503 - Es Unavailable const CODE_ES_UNAVAILABLE = 'SavedObjectsClient/esUnavailable'; // 503 - Unable to automatically create index because of action.auto_create_index setting @@ -152,10 +154,24 @@ export class SavedObjectsErrorHelpers { return decorate(error, CODE_CONFLICT, 409, reason); } + public static createConflictError(type: string, id: string) { + return SavedObjectsErrorHelpers.decorateConflictError( + Boom.conflict(`Saved object [${type}/${id}] conflict`) + ); + } + public static isConflictError(error: Error | DecoratedError) { return isSavedObjectsClientError(error) && error[code] === CODE_CONFLICT; } + public static decorateEsCannotExecuteScriptError(error: Error, reason?: string) { + return decorate(error, CODE_ES_CANNOT_EXECUTE_SCRIPT, 400, reason); + } + + public static isEsCannotExecuteScriptError(error: Error | DecoratedError) { + return isSavedObjectsClientError(error) && error[code] === CODE_ES_CANNOT_EXECUTE_SCRIPT; + } + public static decorateEsUnavailableError(error: Error, reason?: string) { return decorate(error, CODE_ES_UNAVAILABLE, 503, reason); } diff --git a/src/core/server/saved_objects/service/lib/included_fields.test.ts b/src/core/server/saved_objects/service/lib/included_fields.test.ts index 40d6552c2ad5f..ced99361f1ea0 100644 --- a/src/core/server/saved_objects/service/lib/included_fields.test.ts +++ b/src/core/server/saved_objects/service/lib/included_fields.test.ts @@ -26,7 +26,7 @@ describe('includedFields', () => { it('accepts type string', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(7); + expect(fields).toHaveLength(8); expect(fields).toContain('type'); }); @@ -37,6 +37,7 @@ Array [ "config.foo", "secret.foo", "namespace", + "namespaces", "type", "references", "migrationVersion", @@ -48,14 +49,14 @@ Array [ it('accepts field as string', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(7); + expect(fields).toHaveLength(8); expect(fields).toContain('config.foo'); }); it('accepts fields as an array', () => { const fields = includedFields('config', ['foo', 'bar']); - expect(fields).toHaveLength(9); + expect(fields).toHaveLength(10); expect(fields).toContain('config.foo'); expect(fields).toContain('config.bar'); }); @@ -69,6 +70,7 @@ Array [ "secret.foo", "secret.bar", "namespace", + "namespaces", "type", "references", "migrationVersion", @@ -81,31 +83,37 @@ Array [ it('includes namespace', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(7); + expect(fields).toHaveLength(8); expect(fields).toContain('namespace'); }); + it('includes namespaces', () => { + const fields = includedFields('config', 'foo'); + expect(fields).toHaveLength(8); + expect(fields).toContain('namespaces'); + }); + it('includes references', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(7); + expect(fields).toHaveLength(8); expect(fields).toContain('references'); }); it('includes migrationVersion', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(7); + expect(fields).toHaveLength(8); expect(fields).toContain('migrationVersion'); }); it('includes updated_at', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(7); + expect(fields).toHaveLength(8); expect(fields).toContain('updated_at'); }); it('uses wildcard when type is not provided', () => { const fields = includedFields(undefined, 'foo'); - expect(fields).toHaveLength(7); + expect(fields).toHaveLength(8); expect(fields).toContain('*.foo'); }); @@ -113,7 +121,7 @@ Array [ it('includes legacy field path', () => { const fields = includedFields('config', ['foo', 'bar']); - expect(fields).toHaveLength(9); + expect(fields).toHaveLength(10); expect(fields).toContain('foo'); expect(fields).toContain('bar'); }); diff --git a/src/core/server/saved_objects/service/lib/included_fields.ts b/src/core/server/saved_objects/service/lib/included_fields.ts index f372db5a1a635..c50ac22594008 100644 --- a/src/core/server/saved_objects/service/lib/included_fields.ts +++ b/src/core/server/saved_objects/service/lib/included_fields.ts @@ -37,6 +37,7 @@ export function includedFields(type: string | string[] = '*', fields?: string[] return [...acc, ...sourceFields.map(f => `${t}.${f}`)]; }, []) .concat('namespace') + .concat('namespaces') .concat('type') .concat('references') .concat('migrationVersion') diff --git a/src/core/server/saved_objects/service/lib/repository.mock.ts b/src/core/server/saved_objects/service/lib/repository.mock.ts index e69c0ff37d1be..afef378b7307b 100644 --- a/src/core/server/saved_objects/service/lib/repository.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.mock.ts @@ -28,6 +28,8 @@ const create = (): jest.Mocked => ({ find: jest.fn(), get: jest.fn(), update: jest.fn(), + addToNamespaces: jest.fn(), + deleteFromNamespaces: jest.fn(), deleteByNamespace: jest.fn(), incrementCounter: jest.fn(), }); diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 2e5eeec04e0a8..927171438ae99 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import _ from 'lodash'; import { SavedObjectsRepository } from './repository'; import * as getSearchDslNS from './search_dsl/search_dsl'; @@ -30,162 +29,31 @@ jest.mock('./search_dsl/search_dsl', () => ({ getSearchDsl: jest.fn() })); // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. +const createBadRequestError = (...args) => + SavedObjectsErrorHelpers.createBadRequestError(...args).output.payload; +const createConflictError = (...args) => + SavedObjectsErrorHelpers.createConflictError(...args).output.payload; +const createGenericNotFoundError = (...args) => + SavedObjectsErrorHelpers.createGenericNotFoundError(...args).output.payload; +const createUnsupportedTypeError = (...args) => + SavedObjectsErrorHelpers.createUnsupportedTypeError(...args).output.payload; + describe('SavedObjectsRepository', () => { let callAdminCluster; let savedObjectsRepository; let migrator; + let serializer; const mockTimestamp = '2017-08-14T15:49:14.886Z'; const mockTimestampFields = { updated_at: mockTimestamp }; const mockVersionProps = { _seq_no: 1, _primary_term: 1 }; const mockVersion = encodeHitVersion(mockVersionProps); - const noNamespaceSearchResults = { - hits: { - total: 4, - hits: [ - { - _index: '.kibana', - _id: 'index-pattern:logstash-*', - _score: 1, - ...mockVersionProps, - _source: { - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { - title: 'logstash-*', - timeFieldName: '@timestamp', - notExpandable: true, - }, - }, - }, - { - _index: '.kibana', - _id: 'config:6.0.0-alpha1', - _score: 1, - ...mockVersionProps, - _source: { - type: 'config', - ...mockTimestampFields, - config: { - buildNum: 8467, - defaultIndex: 'logstash-*', - }, - }, - }, - { - _index: '.kibana', - _id: 'index-pattern:stocks-*', - _score: 1, - ...mockVersionProps, - _source: { - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { - title: 'stocks-*', - timeFieldName: '@timestamp', - notExpandable: true, - }, - }, - }, - { - _index: '.kibana', - _id: 'globaltype:something', - _score: 1, - ...mockVersionProps, - _source: { - type: 'globaltype', - ...mockTimestampFields, - globaltype: { - name: 'bar', - }, - }, - }, - ], - }, - }; - - const namespacedSearchResults = { - hits: { - total: 4, - hits: [ - { - _index: '.kibana', - _id: 'foo-namespace:index-pattern:logstash-*', - _score: 1, - ...mockVersionProps, - _source: { - namespace: 'foo-namespace', - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { - title: 'logstash-*', - timeFieldName: '@timestamp', - notExpandable: true, - }, - }, - }, - { - _index: '.kibana', - _id: 'foo-namespace:config:6.0.0-alpha1', - _score: 1, - ...mockVersionProps, - _source: { - namespace: 'foo-namespace', - type: 'config', - ...mockTimestampFields, - config: { - buildNum: 8467, - defaultIndex: 'logstash-*', - }, - }, - }, - { - _index: '.kibana', - _id: 'foo-namespace:index-pattern:stocks-*', - _score: 1, - ...mockVersionProps, - _source: { - namespace: 'foo-namespace', - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { - title: 'stocks-*', - timeFieldName: '@timestamp', - notExpandable: true, - }, - }, - }, - { - _index: '.kibana', - _id: 'globaltype:something', - _score: 1, - ...mockVersionProps, - _source: { - type: 'globaltype', - ...mockTimestampFields, - globaltype: { - name: 'bar', - }, - }, - }, - ], - }, - }; - const deleteByQueryResults = { - took: 27, - timed_out: false, - total: 23, - deleted: 23, - batches: 1, - version_conflicts: 0, - noops: 0, - retries: { bulk: 0, search: 0 }, - throttled_millis: 0, - requests_per_second: -1, - throttled_until_millis: 0, - failures: [], - }; + const CUSTOM_INDEX_TYPE = 'customIndex'; + const NAMESPACE_AGNOSTIC_TYPE = 'globalType'; + const MULTI_NAMESPACE_TYPE = 'shareableType'; + const MULTI_NAMESPACE_CUSTOM_INDEX_TYPE = 'shareableTypeCustomIndex'; + const HIDDEN_TYPE = 'hiddenType'; const mappings = { properties: { @@ -194,43 +62,47 @@ describe('SavedObjectsRepository', () => { type: 'keyword', }, }, - foo: { + 'index-pattern': { properties: { - type: 'keyword', + someField: { + type: 'keyword', + }, }, }, - bar: { + dashboard: { properties: { - type: 'keyword', + otherField: { + type: 'keyword', + }, }, }, - baz: { + [CUSTOM_INDEX_TYPE]: { properties: { type: 'keyword', }, }, - 'index-pattern': { + [NAMESPACE_AGNOSTIC_TYPE]: { properties: { - someField: { + yetAnotherField: { type: 'keyword', }, }, }, - dashboard: { + [MULTI_NAMESPACE_TYPE]: { properties: { - otherField: { + evenYetAnotherField: { type: 'keyword', }, }, }, - globaltype: { + [MULTI_NAMESPACE_CUSTOM_INDEX_TYPE]: { properties: { - yetAnotherField: { + evenYetAnotherField: { type: 'keyword', }, }, }, - hiddenType: { + [HIDDEN_TYPE]: { properties: { someField: { type: 'keyword', @@ -240,96 +112,97 @@ describe('SavedObjectsRepository', () => { }, }; - const typeRegistry = new SavedObjectTypeRegistry(); - typeRegistry.registerType({ - name: 'config', - hidden: false, - namespaceAgnostic: false, - mappings: { - properties: { - type: 'keyword', - }, - }, + const createType = type => ({ + name: type, + mappings: { properties: mappings.properties[type].properties }, }); - typeRegistry.registerType({ - name: 'index-pattern', - hidden: false, - namespaceAgnostic: false, - mappings: { - properties: { - someField: { - type: 'keyword', - }, - }, - }, + + const registry = new SavedObjectTypeRegistry(); + registry.registerType(createType('config')); + registry.registerType(createType('index-pattern')); + registry.registerType(createType('dashboard')); + registry.registerType({ + ...createType(CUSTOM_INDEX_TYPE), + indexPattern: 'custom', }); - typeRegistry.registerType({ - name: 'dashboard', - hidden: false, - namespaceAgnostic: false, - mappings: { - properties: { - otherField: { - type: 'keyword', - }, - }, - }, + registry.registerType({ + ...createType(NAMESPACE_AGNOSTIC_TYPE), + namespaceType: 'agnostic', }); - typeRegistry.registerType({ - name: 'globaltype', - hidden: false, - namespaceAgnostic: true, - mappings: { - properties: { - yetAnotherField: { - type: 'keyword', - }, - }, - }, + registry.registerType({ + ...createType(MULTI_NAMESPACE_TYPE), + namespaceType: 'multiple', }); - typeRegistry.registerType({ - name: 'foo', - hidden: false, - namespaceAgnostic: true, - mappings: { - properties: { - type: 'keyword', - }, - }, + registry.registerType({ + ...createType(MULTI_NAMESPACE_CUSTOM_INDEX_TYPE), + namespaceType: 'multiple', + indexPattern: 'custom', }); - typeRegistry.registerType({ - name: 'bar', - hidden: false, - namespaceAgnostic: true, - mappings: { - properties: { - type: 'keyword', - }, - }, + registry.registerType({ + ...createType(HIDDEN_TYPE), + hidden: true, + namespaceType: 'agnostic', }); - typeRegistry.registerType({ - name: 'baz', - hidden: false, - namespaceAgnostic: false, - indexPattern: 'beats', - mappings: { - properties: { - type: 'keyword', - }, + + const getMockGetResponse = ({ type, id, references, namespace }) => ({ + // NOTE: Elasticsearch returns more fields (_index, _type) but the SavedObjectsRepository method ignores these + found: true, + _id: `${registry.isSingleNamespace(type) && namespace ? `${namespace}:` : ''}${type}:${id}`, + ...mockVersionProps, + _source: { + ...(registry.isSingleNamespace(type) && { namespace }), + ...(registry.isMultiNamespace(type) && { namespaces: [namespace ?? 'default'] }), + type, + [type]: { title: 'Testing' }, + references, + specialProperty: 'specialValue', + ...mockTimestampFields, }, }); - typeRegistry.registerType({ - name: 'hiddenType', - hidden: true, - namespaceAgnostic: true, - mappings: { - properties: { - someField: { - type: 'keyword', - }, - }, + + const getMockMgetResponse = (objects, namespace) => ({ + status: 200, + docs: objects.map(obj => + obj.found === false ? obj : getMockGetResponse({ ...obj, namespace }) + ), + }); + + const expectClusterCalls = (...actions) => { + for (let i = 0; i < actions.length; i++) { + expect(callAdminCluster).toHaveBeenNthCalledWith(i + 1, actions[i], expect.any(Object)); + } + expect(callAdminCluster).toHaveBeenCalledTimes(actions.length); + }; + const expectClusterCallArgs = (args, n = 1) => { + expect(callAdminCluster).toHaveBeenNthCalledWith( + n, + expect.any(String), + expect.objectContaining(args) + ); + }; + + expect.extend({ + toBeDocumentWithoutError(received, type, id) { + if (received.type === type && received.id === id && !received.error) { + return { message: () => `expected type and id not to match without error`, pass: true }; + } else { + return { message: () => `expected type and id to match without error`, pass: false }; + } }, }); + const expectSuccess = ({ type, id }) => expect.toBeDocumentWithoutError(type, id); + const expectError = ({ type, id }) => ({ type, id, error: expect.any(Object) }); + const expectErrorResult = ({ type, id }, error) => ({ type, id, error }); + const expectErrorNotFound = obj => + expectErrorResult(obj, createGenericNotFoundError(obj.type, obj.id)); + const expectErrorConflict = obj => expectErrorResult(obj, createConflictError(obj.type, obj.id)); + const expectErrorInvalidType = obj => + expectErrorResult(obj, createUnsupportedTypeError(obj.type, obj.id)); + + const expectMigrationArgs = (args, contains = true, n = 1) => { + const obj = contains ? expect.objectContaining(args) : expect.not.objectContaining(args); + expect(migrator.migrateDocument).toHaveBeenNthCalledWith(n, obj); + }; beforeEach(() => { callAdminCluster = jest.fn(); @@ -338,16 +211,28 @@ describe('SavedObjectsRepository', () => { runMigrations: async () => ({ status: 'skipped' }), }; - const serializer = new SavedObjectsSerializer(typeRegistry); - const allTypes = typeRegistry.getAllTypes().map(type => type.name); - const allowedTypes = [...new Set(allTypes.filter(type => !typeRegistry.isHidden(type)))]; + // create a mock serializer "shim" so we can track function calls, but use the real serializer's implementation + serializer = { + isRawSavedObject: jest.fn(), + rawToSavedObject: jest.fn(), + savedObjectToRaw: jest.fn(), + generateRawId: jest.fn(), + trimIdPrefix: jest.fn(), + }; + const _serializer = new SavedObjectsSerializer(registry); + Object.keys(serializer).forEach(key => { + serializer[key].mockImplementation((...args) => _serializer[key](...args)); + }); + + const allTypes = registry.getAllTypes().map(type => type.name); + const allowedTypes = [...new Set(allTypes.filter(type => !registry.isHidden(type)))]; savedObjectsRepository = new SavedObjectsRepository({ index: '.kibana-test', mappings, callCluster: callAdminCluster, migrator, - typeRegistry, + typeRegistry: registry, serializer, allowedTypes, }); @@ -356,2644 +241,2819 @@ describe('SavedObjectsRepository', () => { getSearchDslNS.getSearchDsl.mockReset(); }); - describe('#create', () => { - beforeEach(() => { - callAdminCluster.mockImplementation((method, params) => ({ - _id: params.id, - ...mockVersionProps, - })); - }); + const mockMigrationVersion = { foo: '2.3.4' }; + const mockMigrateDocument = doc => ({ + ...doc, + attributes: { + ...doc.attributes, + ...(doc.attributes?.title && { title: `${doc.attributes.title}!!` }), + }, + migrationVersion: mockMigrationVersion, + references: [{ name: 'search_0', type: 'search', id: '123' }], + }); - it('waits until migrations are complete before proceeding', async () => { - migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled()); + describe('#addToNamespaces', () => { + const id = 'some-id'; + const type = MULTI_NAMESPACE_TYPE; + const currentNs1 = 'default'; + const currentNs2 = 'foo-namespace'; + const newNs1 = 'bar-namespace'; + const newNs2 = 'baz-namespace'; + + const mockGetResponse = (type, id) => { + // mock a document that exists in two namespaces + const mockResponse = getMockGetResponse({ type, id }); + mockResponse._source.namespaces = [currentNs1, currentNs2]; + callAdminCluster.mockResolvedValueOnce(mockResponse); // this._callCluster('get', ...) + }; - await expect( - savedObjectsRepository.create( - 'index-pattern', - { - title: 'Logstash', - }, - { - id: 'logstash-*', - namespace: 'foo-namespace', - } - ) - ).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); + const addToNamespacesSuccess = async (type, id, namespaces, options) => { + mockGetResponse(type, id); // this._callCluster('get', ...) + callAdminCluster.mockResolvedValue({ + _id: `${type}:${id}`, + ...mockVersionProps, + result: 'updated', + }); // this._writeToCluster('update', ...) + const result = await savedObjectsRepository.addToNamespaces(type, id, namespaces, options); + expect(callAdminCluster).toHaveBeenCalledTimes(2); + return result; + }; - it('formats Elasticsearch response', async () => { - const response = await savedObjectsRepository.create( - 'index-pattern', - { - title: 'Logstash', - }, - { - id: 'logstash-*', - namespace: 'foo-namespace', - references: [ - { - name: 'ref_0', - type: 'test', - id: '123', - }, - ], - } - ); + describe('cluster calls', () => { + it(`should use ES get action then update action`, async () => { + await addToNamespacesSuccess(type, id, [newNs1, newNs2]); + expectClusterCalls('get', 'update'); + }); - expect(response).toEqual({ - type: 'index-pattern', - id: 'logstash-*', - ...mockTimestampFields, - version: mockVersion, - attributes: { - title: 'Logstash', - }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '123', - }, - ], + it(`defaults to the version of the existing document`, async () => { + await addToNamespacesSuccess(type, id, [newNs1, newNs2]); + const versionProperties = { + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + expectClusterCallArgs(versionProperties, 2); + }); + + it(`accepts version`, async () => { + await addToNamespacesSuccess(type, id, [newNs1, newNs2], { + version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }), + }); + expectClusterCallArgs({ if_seq_no: 100, if_primary_term: 200 }, 2); }); - }); - it('should use ES index action', async () => { - await savedObjectsRepository.create('index-pattern', { - id: 'logstash-*', - title: 'Logstash', + it(`defaults to a refresh setting of wait_for`, async () => { + await addToNamespacesSuccess(type, id, [newNs1, newNs2]); + expectClusterCallArgs({ refresh: 'wait_for' }, 2); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith('index', expect.any(Object)); + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + await addToNamespacesSuccess(type, id, [newNs1, newNs2], { refresh }); + expectClusterCallArgs({ refresh }, 2); + }); }); - it('should use default index', async () => { - await savedObjectsRepository.create('index-pattern', { - id: 'logstash-*', - title: 'Logstash', + describe('errors', () => { + const expectNotFoundError = async (type, id, namespaces, options) => { + await expect( + savedObjectsRepository.addToNamespaces(type, id, namespaces, options) + ).rejects.toThrowError(createGenericNotFoundError(type, id)); + }; + const expectBadRequestError = async (type, id, namespaces, message) => { + await expect( + savedObjectsRepository.addToNamespaces(type, id, namespaces) + ).rejects.toThrowError(createBadRequestError(message)); + }; + + it(`throws when type is invalid`, async () => { + await expectNotFoundError('unknownType', id, [newNs1, newNs2]); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - 'index', - expect.objectContaining({ - index: '.kibana-test', - }) - ); - }); + it(`throws when type is hidden`, async () => { + await expectNotFoundError(HIDDEN_TYPE, id, [newNs1, newNs2]); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); - it('should use custom index', async () => { - await savedObjectsRepository.create('baz', { - id: 'logstash-*', - title: 'Logstash', + it(`throws when type is not multi-namespace`, async () => { + const test = async type => { + const message = `${type} doesn't support multiple namespaces`; + await expectBadRequestError(type, id, [newNs1, newNs2], message); + expect(callAdminCluster).not.toHaveBeenCalled(); + }; + await test('index-pattern'); + await test(NAMESPACE_AGNOSTIC_TYPE); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - 'index', - expect.objectContaining({ - index: 'beats', - }) - ); - }); + it(`throws when namespaces is an empty array`, async () => { + const test = async namespaces => { + const message = 'namespaces must be a non-empty array of strings'; + await expectBadRequestError(type, id, namespaces, message); + expect(callAdminCluster).not.toHaveBeenCalled(); + }; + await test([]); + }); - it('migrates the doc', async () => { - migrator.migrateDocument = doc => { - doc.attributes.title = doc.attributes.title + '!!'; - doc.migrationVersion = { foo: '2.3.4' }; - doc.references = [{ name: 'search_0', type: 'search', id: '123' }]; - return doc; - }; + it(`throws when ES is unable to find the document during get`, async () => { + callAdminCluster.mockResolvedValue({ found: false }); // this._callCluster('get', ...) + await expectNotFoundError(type, id, [newNs1, newNs2]); + expectClusterCalls('get'); + }); - await savedObjectsRepository.create('index-pattern', { - id: 'logstash-*', - title: 'Logstash', + it(`throws when ES is unable to find the index during get`, async () => { + callAdminCluster.mockResolvedValue({ status: 404 }); // this._callCluster('get', ...) + await expectNotFoundError(type, id, [newNs1, newNs2]); + expectClusterCalls('get'); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - body: { - 'index-pattern': { id: 'logstash-*', title: 'Logstash!!' }, - migrationVersion: { foo: '2.3.4' }, - type: 'index-pattern', - updated_at: '2017-08-14T15:49:14.886Z', - references: [{ name: 'search_0', type: 'search', id: '123' }], - }, + it(`throws when the document exists, but not in this namespace`, async () => { + mockGetResponse(type, id); // this._callCluster('get', ...) + await expectNotFoundError(type, id, [newNs1, newNs2], { + namespace: 'some-other-namespace', + }); + expectClusterCalls('get'); }); - }); - it('defaults to a refresh setting of `wait_for`', async () => { - await savedObjectsRepository.create('index-pattern', { - id: 'logstash-*', - title: 'Logstash', + it(`throws when ES is unable to find the document during update`, async () => { + mockGetResponse(type, id); // this._callCluster('get', ...) + callAdminCluster.mockResolvedValue({ status: 404 }); // this._writeToCluster('update', ...) + await expectNotFoundError(type, id, [newNs1, newNs2]); + expectClusterCalls('get', 'update'); }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: 'wait_for', + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + let callAdminClusterCount = 0; + migrator.runMigrations = jest.fn(async () => + // runMigrations should resolve before callAdminCluster is initiated + expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) + ); + await expect(addToNamespacesSuccess(type, id, [newNs1, newNs2])).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveReturnedTimes(2); }); }); - it('accepts custom refresh settings', async () => { - await savedObjectsRepository.create( - 'index-pattern', - { - id: 'logstash-*', - title: 'Logstash', - }, - { - refresh: true, - } - ); + describe('returns', () => { + it(`returns an empty object on success`, async () => { + const result = await addToNamespacesSuccess(type, id, [newNs1, newNs2]); + expect(result).toEqual({}); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: true, + it(`succeeds when adding existing namespaces`, async () => { + const result = await addToNamespacesSuccess(type, id, [currentNs1]); + expect(result).toEqual({}); }); }); + }); - it('should use create action if ID defined and overwrite=false', async () => { - await savedObjectsRepository.create( - 'index-pattern', - { - title: 'Logstash', - }, - { - id: 'logstash-*', - } - ); + describe('#bulkCreate', () => { + const obj1 = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const obj2 = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + const namespace = 'foo-namespace'; - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith('create', expect.any(Object)); - }); + const getMockBulkCreateResponse = (objects, namespace) => { + return { + items: objects.map(({ type, id }) => ({ + create: { + _id: `${namespace ? `${namespace}:` : ''}${type}:${id}`, + ...mockVersionProps, + }, + })), + }; + }; - it('allows for id to be provided', async () => { - await savedObjectsRepository.create( - 'index-pattern', - { - title: 'Logstash', - }, - { id: 'logstash-*' } - ); + const bulkCreateSuccess = async (objects, options) => { + const multiNamespaceObjects = + options?.overwrite && + objects.filter(({ type, id }) => registry.isMultiNamespace(type) && id); + if (multiNamespaceObjects?.length) { + const response = getMockMgetResponse(multiNamespaceObjects, options?.namespace); + callAdminCluster.mockResolvedValueOnce(response); // this._callCluster('mget', ...) + } + const response = getMockBulkCreateResponse(objects, options?.namespace); + callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + const result = await savedObjectsRepository.bulkCreate(objects, options); + expect(callAdminCluster).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 2 : 1); + return result; + }; - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: 'index-pattern:logstash-*', - }) - ); + // bulk create calls have two objects for each source -- the action, and the source + const expectClusterCallArgsAction = ( + objects, + { method, _index = expect.any(String), getId = () => expect.any(String) } + ) => { + const body = []; + for (const { type, id } of objects) { + body.push({ [method]: { _index, _id: getId(type, id) } }); + body.push(expect.any(Object)); + } + expectClusterCallArgs({ body }); + }; + + const expectObjArgs = ({ type, attributes, references }, overrides) => [ + expect.any(Object), + expect.objectContaining({ + [type]: attributes, + references, + type, + ...overrides, + ...mockTimestampFields, + }), + ]; + + const expectSuccessResult = obj => ({ + ...obj, + migrationVersion: undefined, + version: mockVersion, + ...mockTimestampFields, }); - it('self-generates an ID', async () => { - await savedObjectsRepository.create('index-pattern', { - title: 'Logstash', + describe('cluster calls', () => { + it(`should use the ES bulk action by default`, async () => { + await bulkCreateSuccess([obj1, obj2]); + expectClusterCalls('bulk'); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), - }) - ); - }); + it(`should use the ES mget action before bulk action for any types that are multi-namespace, when overwrite=true`, async () => { + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; + await bulkCreateSuccess(objects, { overwrite: true }); + expectClusterCalls('mget', 'bulk'); + const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; + expectClusterCallArgs({ body: { docs } }, 1); + }); - it('prepends namespace to the id and adds namespace to body when providing namespace for namespaced type', async () => { - await savedObjectsRepository.create( - 'index-pattern', - { - title: 'Logstash', - }, - { - id: 'foo-id', - namespace: 'foo-namespace', - } - ); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: `foo-namespace:index-pattern:foo-id`, - body: expect.objectContaining({ - [`index-pattern`]: { title: 'Logstash' }, - namespace: 'foo-namespace', - type: 'index-pattern', - updated_at: '2017-08-14T15:49:14.886Z', - }), - }) - ); - }); + it(`should use the ES create method if ID is undefined and overwrite=true`, async () => { + const objects = [obj1, obj2].map(obj => ({ ...obj, id: undefined })); + await bulkCreateSuccess(objects, { overwrite: true }); + expectClusterCallArgsAction(objects, { method: 'create' }); + }); - it(`doesn't prepend namespace to the id or add namespace property when providing no namespace for namespaced type`, async () => { - await savedObjectsRepository.create( - 'index-pattern', - { - title: 'Logstash', - }, - { - id: 'foo-id', - } - ); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: `index-pattern:foo-id`, - body: expect.objectContaining({ - [`index-pattern`]: { title: 'Logstash' }, - type: 'index-pattern', - updated_at: '2017-08-14T15:49:14.886Z', - }), - }) - ); - }); + it(`should use the ES create method if ID is undefined and overwrite=false`, async () => { + const objects = [obj1, obj2].map(obj => ({ ...obj, id: undefined })); + await bulkCreateSuccess(objects); + expectClusterCallArgsAction(objects, { method: 'create' }); + }); - it(`doesn't prepend namespace to the id or add namespace property when providing namespace for namespace agnostic type`, async () => { - await savedObjectsRepository.create( - 'globaltype', - { - title: 'Logstash', - }, - { - id: 'foo-id', - namespace: 'foo-namespace', - } - ); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: `globaltype:foo-id`, - body: expect.objectContaining({ - [`globaltype`]: { title: 'Logstash' }, - type: 'globaltype', - updated_at: '2017-08-14T15:49:14.886Z', - }), - }) - ); - }); - - it('defaults to empty references array if none are provided', async () => { - await savedObjectsRepository.create( - 'index-pattern', - { - title: 'Logstash', - }, - { - id: 'logstash-*', - } - ); + it(`should use the ES index method if ID is defined and overwrite=true`, async () => { + await bulkCreateSuccess([obj1, obj2], { overwrite: true }); + expectClusterCallArgsAction([obj1, obj2], { method: 'index' }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: expect.objectContaining({ - references: [], - }), - }) - ); - }); - }); + it(`should use the ES create method if ID is defined and overwrite=false`, async () => { + await bulkCreateSuccess([obj1, obj2]); + expectClusterCallArgsAction([obj1, obj2], { method: 'create' }); + }); - describe('#bulkCreate', () => { - it('waits until migrations are complete before proceeding', async () => { - migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled()); - callAdminCluster.mockReturnValue({ - items: [ - { create: { type: 'config', id: 'config:one', _primary_term: 1, _seq_no: 1 } }, - { - create: { - type: 'index-pattern', - id: 'index-pattern:two', - _primary_term: 1, - _seq_no: 1, - }, - }, - ], + it(`formats the ES request`, async () => { + await bulkCreateSuccess([obj1, obj2]); + const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; + expectClusterCallArgs({ body }); }); - await expect( - savedObjectsRepository.bulkCreate([ - { type: 'config', id: 'one', attributes: { title: 'Test One' } }, - { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }, - ]) - ).resolves.toBeDefined(); + it(`adds namespace to request body for any types that are single-namespace`, async () => { + await bulkCreateSuccess([obj1, obj2], { namespace }); + const expected = expect.objectContaining({ namespace }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expectClusterCallArgs({ body }); + }); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); + it(`doesn't add namespace to request body for any types that are not single-namespace`, async () => { + const objects = [ + { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_TYPE }, + ]; + await bulkCreateSuccess(objects, { namespace }); + const expected = expect.not.objectContaining({ namespace: expect.anything() }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expectClusterCallArgs({ body }); + }); - it('formats Elasticsearch request', async () => { - callAdminCluster.mockReturnValue({ - items: [ - { create: { type: 'config', id: 'config:one', _primary_term: 1, _seq_no: 1 } }, - { create: { type: 'index-pattern', id: 'config:two', _primary_term: 1, _seq_no: 1 } }, - ], + it(`adds namespaces to request body for any types that are multi-namespace`, async () => { + const test = async namespace => { + const objects = [obj1, obj2].map(x => ({ ...x, type: MULTI_NAMESPACE_TYPE })); + const namespaces = [namespace ?? 'default']; + await bulkCreateSuccess(objects, { namespace, overwrite: true }); + const expected = expect.objectContaining({ namespaces }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expectClusterCallArgs({ body }, 2); + callAdminCluster.mockReset(); + }; + await test(undefined); + await test(namespace); }); - await savedObjectsRepository.bulkCreate([ - { - type: 'config', - id: 'one', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }, - { - type: 'index-pattern', - id: 'two', - attributes: { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }, - ]); + it(`doesn't add namespaces to request body for any types that are not multi-namespace`, async () => { + const test = async namespace => { + const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; + await bulkCreateSuccess(objects, { namespace, overwrite: true }); + const expected = expect.not.objectContaining({ namespaces: expect.anything() }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expectClusterCallArgs({ body }); + callAdminCluster.mockReset(); + }; + await test(undefined); + await test(namespace); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - const bulkCalls = callAdminCluster.mock.calls.filter(([path]) => path === 'bulk'); + it(`defaults to a refresh setting of wait_for`, async () => { + await bulkCreateSuccess([obj1, obj2]); + expectClusterCallArgs({ refresh: 'wait_for' }); + }); - expect(bulkCalls.length).toEqual(1); + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + await bulkCreateSuccess([obj1, obj2], { refresh }); + expectClusterCallArgs({ refresh }); + }); - expect(bulkCalls[0][1].body).toEqual([ - { create: { _index: '.kibana-test', _id: 'config:one' } }, - { - type: 'config', - ...mockTimestampFields, - config: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }, - { create: { _index: '.kibana-test', _id: 'index-pattern:two' } }, - { - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }, - ]); - }); + it(`should use default index`, async () => { + await bulkCreateSuccess([obj1, obj2]); + expectClusterCallArgsAction([obj1, obj2], { method: 'create', _index: '.kibana-test' }); + }); - it('defaults to a refresh setting of `wait_for`', async () => { - callAdminCluster.mockReturnValue({ - items: [{ create: { type: 'config', id: 'config:one', _primary_term: 1, _seq_no: 1 } }], + it(`should use custom index`, async () => { + await bulkCreateSuccess([obj1, obj2].map(x => ({ ...x, type: CUSTOM_INDEX_TYPE }))); + expectClusterCallArgsAction([obj1, obj2], { method: 'create', _index: 'custom' }); }); - await savedObjectsRepository.bulkCreate([ - { - type: 'config', - id: 'one', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }, - ]); + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + const getId = (type, id) => `${namespace}:${type}:${id}`; + await bulkCreateSuccess([obj1, obj2], { namespace }); + expectClusterCallArgsAction([obj1, obj2], { method: 'create', getId }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + const getId = (type, id) => `${type}:${id}`; + await bulkCreateSuccess([obj1, obj2]); + expectClusterCallArgsAction([obj1, obj2], { method: 'create', getId }); + }); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: 'wait_for', + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + const getId = (type, id) => `${type}:${id}`; + const objects = [ + { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_TYPE }, + ]; + await bulkCreateSuccess(objects, { namespace }); + expectClusterCallArgsAction(objects, { method: 'create', getId }); }); }); - it('accepts a custom refresh setting', async () => { - callAdminCluster.mockReturnValue({ - items: [ - { create: { type: 'config', id: 'config:one', _primary_term: 1, _seq_no: 1 } }, - { create: { type: 'index-pattern', id: 'config:two', _primary_term: 1, _seq_no: 1 } }, - ], - }); + describe('errors', () => { + const obj3 = { + type: 'dashboard', + id: 'three', + attributes: { title: 'Test Three' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; - await savedObjectsRepository.bulkCreate( - [ - { - type: 'config', - id: 'one', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }, - { - type: 'index-pattern', - id: 'two', - attributes: { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }, - ], - { - refresh: true, + const bulkCreateError = async (obj, esError, expectedError) => { + const objects = [obj1, obj, obj2]; + const response = getMockBulkCreateResponse(objects); + if (esError) { + response.items[1].create = { error: esError }; } - ); - - expect(callAdminCluster).toHaveBeenCalledTimes(1); + callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + + const result = await savedObjectsRepository.bulkCreate(objects); + expectClusterCalls('bulk'); + const objCall = esError ? expectObjArgs(obj) : []; + const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; + expectClusterCallArgs({ body }); + expect(result).toEqual({ + saved_objects: [expectSuccess(obj1), expectedError, expectSuccess(obj2)], + }); + }; - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: true, + it(`returns error when type is invalid`, async () => { + const obj = { ...obj3, type: 'unknownType' }; + await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); }); - }); - it('migrates the docs', async () => { - callAdminCluster.mockReturnValue({ - items: [ - { - create: { - error: false, - _id: '1', - _seq_no: 1, - _primary_term: 1, - }, - }, - { - create: { - error: false, - _id: '2', - _seq_no: 1, - _primary_term: 1, - }, - }, - ], + it(`returns error when type is hidden`, async () => { + const obj = { ...obj3, type: HIDDEN_TYPE }; + await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); }); - migrator.migrateDocument = doc => { - doc.attributes.title = doc.attributes.title + '!!'; - doc.migrationVersion = { foo: '2.3.4' }; - doc.references = [{ name: 'search_0', type: 'search', id: '123' }]; - return doc; - }; - - const bulkCreateResp = await savedObjectsRepository.bulkCreate([ - { type: 'config', id: 'one', attributes: { title: 'Test One' } }, - { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }, - ]); - - expect(callAdminCluster).toHaveBeenCalledWith( - 'bulk', - expect.objectContaining({ - body: [ - { create: { _index: '.kibana-test', _id: 'config:one' } }, - { - type: 'config', - ...mockTimestampFields, - config: { title: 'Test One!!' }, - migrationVersion: { foo: '2.3.4' }, - references: [{ name: 'search_0', type: 'search', id: '123' }], - }, - { create: { _index: '.kibana-test', _id: 'index-pattern:two' } }, + it(`returns error when there is a conflict with an existing multi-namespace saved object (get)`, async () => { + const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE }; + const response1 = { + status: 200, + docs: [ { - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { title: 'Test Two!!' }, - migrationVersion: { foo: '2.3.4' }, - references: [{ name: 'search_0', type: 'search', id: '123' }], + found: true, + _source: { + type: obj.type, + namespaces: ['bar-namespace'], + }, }, ], - }) - ); - - expect(bulkCreateResp).toEqual({ - saved_objects: [ - { - id: 'one', - type: 'config', - version: mockVersion, - updated_at: mockTimestamp, - attributes: { - title: 'Test One!!', - }, - references: [{ name: 'search_0', type: 'search', id: '123' }], - }, - { - id: 'two', - type: 'index-pattern', - version: mockVersion, - updated_at: mockTimestamp, - attributes: { - title: 'Test Two!!', - }, - references: [{ name: 'search_0', type: 'search', id: '123' }], - }, - ], + }; + callAdminCluster.mockResolvedValueOnce(response1); // this._callCluster('mget', ...) + const response2 = getMockBulkCreateResponse([obj1, obj2]); + callAdminCluster.mockResolvedValue(response2); // this._writeToCluster('bulk', ...) + + const options = { overwrite: true }; + const result = await savedObjectsRepository.bulkCreate([obj1, obj, obj2], options); + expectClusterCalls('mget', 'bulk'); + const body1 = { docs: [expect.objectContaining({ _id: `${obj.type}:${obj.id}` })] }; + expectClusterCallArgs({ body: body1 }, 1); + const body2 = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; + expectClusterCallArgs({ body: body2 }, 2); + expect(result).toEqual({ + saved_objects: [expectSuccess(obj1), expectErrorConflict(obj), expectSuccess(obj2)], + }); }); - }); - it('should overwrite objects if overwrite is truthy', async () => { - callAdminCluster.mockReturnValue({ - items: [{ create: { type: 'foo', id: 'bar', _primary_term: 1, _seq_no: 1 } }], + it(`returns error when there is a version conflict (bulk)`, async () => { + const esError = { type: 'version_conflict_engine_exception' }; + await bulkCreateError(obj3, esError, expectErrorConflict(obj3)); }); - await savedObjectsRepository.bulkCreate([{ type: 'foo', id: 'bar', attributes: {} }], { - overwrite: false, + it(`returns error when document is missing`, async () => { + const esError = { type: 'document_missing_exception' }; + await bulkCreateError(obj3, esError, expectErrorNotFound(obj3)); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - 'bulk', - expect.objectContaining({ - body: [ - // uses create because overwriting is not allowed - { create: { _index: '.kibana-test', _id: 'foo:bar' } }, - { type: 'foo', ...mockTimestampFields, foo: {}, references: [] }, - ], - }) - ); - - callAdminCluster.mockReset(); - callAdminCluster.mockReturnValue({ - items: [{ create: { type: 'foo', id: 'bar', _primary_term: 1, _seq_no: 1 } }], + it(`returns error reason for other errors`, async () => { + const esError = { reason: 'some_other_error' }; + await bulkCreateError(obj3, esError, expectErrorResult(obj3, { message: esError.reason })); }); - await savedObjectsRepository.bulkCreate([{ type: 'foo', id: 'bar', attributes: {} }], { - overwrite: true, + it(`returns error string for other errors if no reason is defined`, async () => { + const esError = { foo: 'some_other_error' }; + const expectedError = expectErrorResult(obj3, { message: JSON.stringify(esError) }); + await bulkCreateError(obj3, esError, expectedError); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - 'bulk', - expect.objectContaining({ - body: [ - // uses index because overwriting is allowed - { index: { _index: '.kibana-test', _id: 'foo:bar' } }, - { type: 'foo', ...mockTimestampFields, foo: {}, references: [] }, - ], - }) - ); }); - it('mockReturnValue document errors', async () => { - callAdminCluster.mockResolvedValue({ - errors: false, - items: [ - { - create: { - _id: 'config:one', - error: { - reason: 'type[config] missing', - }, - }, - }, - { - create: { - _id: 'index-pattern:two', - ...mockVersionProps, - }, - }, - ], + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + migrator.runMigrations = jest.fn(async () => + expect(callAdminCluster).not.toHaveBeenCalled() + ); + await expect(bulkCreateSuccess([obj1, obj2])).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveBeenCalledTimes(1); }); - const response = await savedObjectsRepository.bulkCreate([ - { type: 'config', id: 'one', attributes: { title: 'Test One' } }, - { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }, - ]); + it(`migrates the docs and serializes the migrated docs`, async () => { + migrator.migrateDocument.mockImplementation(mockMigrateDocument); + await bulkCreateSuccess([obj1, obj2]); + const docs = [obj1, obj2].map(x => ({ ...x, ...mockTimestampFields })); + expectMigrationArgs(docs[0], true, 1); + expectMigrationArgs(docs[1], true, 2); - expect(response).toEqual({ - saved_objects: [ - { - id: 'one', - type: 'config', - error: { message: 'type[config] missing' }, - }, - { - id: 'two', - type: 'index-pattern', - version: mockVersion, - ...mockTimestampFields, - attributes: { title: 'Test Two' }, - references: [], - }, - ], + const migratedDocs = docs.map(x => migrator.migrateDocument(x)); + expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(1, migratedDocs[0]); + expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(2, migratedDocs[1]); }); - }); - it('formats Elasticsearch response', async () => { - callAdminCluster.mockResolvedValue({ - errors: false, - items: [ - { - create: { - _id: 'config:one', - ...mockVersionProps, - }, - }, - { - create: { - _id: 'index-pattern:two', - ...mockVersionProps, - }, - }, - ], + it(`adds namespace to body when providing namespace for single-namespace type`, async () => { + await bulkCreateSuccess([obj1, obj2], { namespace }); + expectMigrationArgs({ namespace }, true, 1); + expectMigrationArgs({ namespace }, true, 2); }); - const response = await savedObjectsRepository.bulkCreate( - [ - { type: 'config', id: 'one', attributes: { title: 'Test One' } }, - { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }, - ], - { - namespace: 'foo-namespace', - } - ); + it(`doesn't add namespace to body when providing no namespace for single-namespace type`, async () => { + await bulkCreateSuccess([obj1, obj2]); + expectMigrationArgs({ namespace: expect.anything() }, false, 1); + expectMigrationArgs({ namespace: expect.anything() }, false, 2); + }); - expect(response).toEqual({ - saved_objects: [ - { - id: 'one', - type: 'config', - version: mockVersion, - ...mockTimestampFields, - attributes: { title: 'Test One' }, - references: [], - }, - { - id: 'two', - type: 'index-pattern', - version: mockVersion, - ...mockTimestampFields, - attributes: { title: 'Test Two' }, - references: [], - }, - ], + it(`doesn't add namespace to body when not using single-namespace type`, async () => { + const objects = [ + { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_TYPE }, + ]; + await bulkCreateSuccess(objects, { namespace }); + expectMigrationArgs({ namespace: expect.anything() }, false, 1); + expectMigrationArgs({ namespace: expect.anything() }, false, 2); }); - }); - it('prepends namespace to the id and adds namespace to body when providing namespace for namespaced type', async () => { - callAdminCluster.mockReturnValue({ - items: [ - { - create: { - _id: 'foo-namespace:config:one', - _index: '.kibana-test', - _primary_term: 1, - _seq_no: 2, - }, - }, - { - create: { - _id: 'foo-namespace:index-pattern:two', - _primary_term: 1, - _seq_no: 2, - }, - }, - ], + it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { + const objects = [obj1, obj2].map(obj => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + await bulkCreateSuccess(objects, { namespace }); + expectMigrationArgs({ namespaces: [namespace] }, true, 1); + expectMigrationArgs({ namespaces: [namespace] }, true, 2); }); - await savedObjectsRepository.bulkCreate( - [ - { type: 'config', id: 'one', attributes: { title: 'Test One' } }, - { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }, - ], - { - namespace: 'foo-namespace', - } - ); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - 'bulk', - expect.objectContaining({ - body: [ - { create: { _index: '.kibana-test', _id: 'foo-namespace:config:one' } }, - { - namespace: 'foo-namespace', - type: 'config', - ...mockTimestampFields, - config: { title: 'Test One' }, - references: [], - }, - { create: { _index: '.kibana-test', _id: 'foo-namespace:index-pattern:two' } }, - { - namespace: 'foo-namespace', - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { title: 'Test Two' }, - references: [], - }, - ], - }) - ); - }); - it(`doesn't prepend namespace to the id or add namespace property when providing no namespace for namespaced type`, async () => { - callAdminCluster.mockResolvedValue({ - errors: false, - items: [ - { - create: { - _id: 'config:one', - ...mockVersionProps, - }, - }, - { - create: { - _id: 'index-pattern:two', - ...mockVersionProps, - }, - }, - ], + it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { + const objects = [obj1, obj2].map(obj => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + await bulkCreateSuccess(objects); + expectMigrationArgs({ namespaces: ['default'] }, true, 1); + expectMigrationArgs({ namespaces: ['default'] }, true, 2); }); - await savedObjectsRepository.bulkCreate([ - { type: 'config', id: 'one', attributes: { title: 'Test One' } }, - { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }, - ]); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - 'bulk', - expect.objectContaining({ - body: [ - { create: { _id: 'config:one', _index: '.kibana-test' } }, - { - type: 'config', - ...mockTimestampFields, - config: { title: 'Test One' }, - references: [], - }, - { create: { _id: 'index-pattern:two', _index: '.kibana-test' } }, - { - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { title: 'Test Two' }, - references: [], - }, - ], - }) - ); - }); - it(`doesn't prepend namespace to the id or add namespace property when providing namespace for namespace agnostic type`, async () => { - callAdminCluster.mockReturnValue({ - items: [{ create: { _type: '_doc', _id: 'globaltype:one', _primary_term: 1, _seq_no: 2 } }], + it(`doesn't add namespaces to body when not using multi-namespace type`, async () => { + const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; + await bulkCreateSuccess(objects); + expectMigrationArgs({ namespaces: expect.anything() }, false, 1); + expectMigrationArgs({ namespaces: expect.anything() }, false, 2); }); - await savedObjectsRepository.bulkCreate( - [{ type: 'globaltype', id: 'one', attributes: { title: 'Test One' } }], - { - namespace: 'foo-namespace', - } - ); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - 'bulk', - expect.objectContaining({ - body: [ - { create: { _id: 'globaltype:one', _index: '.kibana-test' } }, - { - type: 'globaltype', - ...mockTimestampFields, - globaltype: { title: 'Test One' }, - references: [], - }, - ], - }) - ); }); - it('should return objects in the same order regardless of type', () => {}); - }); + describe('returns', () => { + it(`formats the ES response`, async () => { + const result = await bulkCreateSuccess([obj1, obj2]); + expect(result).toEqual({ + saved_objects: [obj1, obj2].map(x => expectSuccessResult(x)), + }); + }); - describe('#delete', () => { - it('waits until migrations are complete before proceeding', async () => { - migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled()); - callAdminCluster.mockReturnValue({ result: 'deleted' }); - await expect( - savedObjectsRepository.delete('index-pattern', 'logstash-*', { - namespace: 'foo-namespace', - }) - ).resolves.toBeDefined(); - - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + it(`should return objects in the same order regardless of type`, async () => { + // TODO + }); + + it(`handles a mix of successful creates and errors`, async () => { + const obj = { + type: 'unknownType', + id: 'three', + }; + const objects = [obj1, obj, obj2]; + const response = getMockBulkCreateResponse(objects); + callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + const result = await savedObjectsRepository.bulkCreate(objects); + expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + saved_objects: [expectSuccessResult(obj1), expectError(obj), expectSuccessResult(obj2)], + }); + }); }); + }); - it('throws notFound when ES is unable to find the document', async () => { - expect.assertions(1); + describe('#bulkGet', () => { + const obj1 = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Testing' }, + references: [ + { + name: 'ref_0', + type: 'test', + id: '1', + }, + ], + }; + const obj2 = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Testing' }, + references: [ + { + name: 'ref_0', + type: 'test', + id: '2', + }, + ], + }; + const namespace = 'foo-namespace'; - callAdminCluster.mockResolvedValue({ result: 'not_found' }); + const bulkGet = async (objects, options) => + savedObjectsRepository.bulkGet( + objects.map(({ type, id }) => ({ type, id })), // bulkGet only uses type and id + options + ); + const bulkGetSuccess = async (objects, options) => { + const response = getMockMgetResponse(objects, options?.namespace); + callAdminCluster.mockReturnValue(response); + const result = await bulkGet(objects, options); + expect(callAdminCluster).toHaveBeenCalledTimes(1); + return result; + }; - try { - await savedObjectsRepository.delete('index-pattern', 'logstash-*'); - } catch (e) { - expect(e.output.statusCode).toEqual(404); - } - }); + const _expectClusterCallArgs = ( + objects, + { _index = expect.any(String), getId = () => expect.any(String) } + ) => { + expectClusterCallArgs({ + body: { + docs: objects.map(({ type, id }) => + expect.objectContaining({ + _index, + _id: getId(type, id), + }) + ), + }, + }); + }; - it(`prepends namespace to the id when providing namespace for namespaced type`, async () => { - callAdminCluster.mockReturnValue({ result: 'deleted' }); - await savedObjectsRepository.delete('index-pattern', 'logstash-*', { - namespace: 'foo-namespace', + describe('cluster calls', () => { + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + const getId = (type, id) => `${namespace}:${type}:${id}`; + await bulkGetSuccess([obj1, obj2], { namespace }); + _expectClusterCallArgs([obj1, obj2], { getId }); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith('delete', { - id: 'foo-namespace:index-pattern:logstash-*', - refresh: 'wait_for', - index: '.kibana-test', - ignore: [404], + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + const getId = (type, id) => `${type}:${id}`; + await bulkGetSuccess([obj1, obj2]); + _expectClusterCallArgs([obj1, obj2], { getId }); }); - }); - it(`doesn't prepend namespace to the id when providing no namespace for namespaced type`, async () => { - callAdminCluster.mockReturnValue({ result: 'deleted' }); - await savedObjectsRepository.delete('index-pattern', 'logstash-*'); + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + const getId = (type, id) => `${type}:${id}`; + let objects = [obj1, obj2].map(obj => ({ ...obj, type: NAMESPACE_AGNOSTIC_TYPE })); + await bulkGetSuccess(objects, { namespace }); + _expectClusterCallArgs(objects, { getId }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith('delete', { - id: 'index-pattern:logstash-*', - refresh: 'wait_for', - index: '.kibana-test', - ignore: [404], + callAdminCluster.mockReset(); + objects = [obj1, obj2].map(obj => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + await bulkGetSuccess(objects, { namespace }); + _expectClusterCallArgs(objects, { getId }); }); }); - it(`doesn't prepend namespace to the id when providing namespace for namespace agnostic type`, async () => { - callAdminCluster.mockReturnValue({ result: 'deleted' }); - await savedObjectsRepository.delete('globaltype', 'logstash-*', { - namespace: 'foo-namespace', - }); + describe('errors', () => { + const bulkGetErrorInvalidType = async ([obj1, obj, obj2]) => { + const response = getMockMgetResponse([obj1, obj2]); + callAdminCluster.mockResolvedValue(response); + const result = await bulkGet([obj1, obj, obj2]); + expectClusterCalls('mget'); + expect(result).toEqual({ + saved_objects: [expectSuccess(obj1), expectErrorInvalidType(obj), expectSuccess(obj2)], + }); + }; - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith('delete', { - id: 'globaltype:logstash-*', - refresh: 'wait_for', - index: '.kibana-test', - ignore: [404], - }); - }); + const bulkGetErrorNotFound = async ([obj1, obj, obj2], options, response) => { + callAdminCluster.mockResolvedValue(response); + const result = await bulkGet([obj1, obj, obj2], options); + expectClusterCalls('mget'); + expect(result).toEqual({ + saved_objects: [expectSuccess(obj1), expectErrorNotFound(obj), expectSuccess(obj2)], + }); + }; - it('defaults to a refresh setting of `wait_for`', async () => { - callAdminCluster.mockReturnValue({ result: 'deleted' }); - await savedObjectsRepository.delete('globaltype', 'logstash-*'); + it(`returns error when type is invalid`, async () => { + const obj = { type: 'unknownType', id: 'three' }; + await bulkGetErrorInvalidType([obj1, obj, obj2]); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: 'wait_for', + it(`returns error when type is hidden`, async () => { + const obj = { type: HIDDEN_TYPE, id: 'three' }; + await bulkGetErrorInvalidType([obj1, obj, obj2]); }); - }); - it(`accepts a custom refresh setting`, async () => { - callAdminCluster.mockReturnValue({ result: 'deleted' }); - await savedObjectsRepository.delete('globaltype', 'logstash-*', { - refresh: false, + it(`returns error when document is not found`, async () => { + const obj = { type: 'dashboard', id: 'three', found: false }; + const response = getMockMgetResponse([obj1, obj, obj2]); + await bulkGetErrorNotFound([obj1, obj, obj2], undefined, response); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: false, + it(`handles missing ids gracefully`, async () => { + const obj = { type: 'dashboard', id: undefined, found: false }; + const response = getMockMgetResponse([obj1, obj, obj2]); + await bulkGetErrorNotFound([obj1, obj, obj2], undefined, response); }); - }); - }); - describe('#deleteByNamespace', () => { - it('requires namespace to be defined', async () => { - callAdminCluster.mockReturnValue(deleteByQueryResults); - expect(savedObjectsRepository.deleteByNamespace()).rejects.toThrowErrorMatchingSnapshot(); - expect(callAdminCluster).not.toHaveBeenCalled(); + it(`returns error when type is multi-namespace and the document exists, but not in this namespace`, async () => { + const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const response = getMockMgetResponse([obj1, obj, obj2]); + response.docs[1].namespaces = ['bar-namespace']; + await bulkGetErrorNotFound([obj1, obj, obj2], { namespace }, response); + }); }); - it('requires namespace to be a string', async () => { - callAdminCluster.mockReturnValue(deleteByQueryResults); - expect( - savedObjectsRepository.deleteByNamespace(['namespace-1', 'namespace-2']) - ).rejects.toThrowErrorMatchingSnapshot(); - expect(callAdminCluster).not.toHaveBeenCalled(); + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + migrator.runMigrations = jest.fn(async () => + expect(callAdminCluster).not.toHaveBeenCalled() + ); + await expect(bulkGetSuccess([obj1, obj2])).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + }); }); - it('constructs a deleteByQuery call using all types that are namespace aware', async () => { - callAdminCluster.mockReturnValue(deleteByQueryResults); - const result = await savedObjectsRepository.deleteByNamespace('my-namespace'); - - expect(result).toEqual(deleteByQueryResults); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - - expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, typeRegistry, { - namespace: 'my-namespace', - type: ['config', 'baz', 'index-pattern', 'dashboard'], + describe('returns', () => { + const expectSuccessResult = ({ type, id }, doc) => ({ + type, + id, + ...(doc._source.namespaces && { namespaces: doc._source.namespaces }), + ...(doc._source.updated_at && { updated_at: doc._source.updated_at }), + version: encodeHitVersion(doc), + attributes: doc._source[type], + references: doc._source.references || [], + migrationVersion: doc._source.migrationVersion, }); - expect(callAdminCluster).toHaveBeenCalledWith('deleteByQuery', { - body: { conflicts: 'proceed' }, - ignore: [404], - index: ['.kibana-test', 'beats'], - refresh: 'wait_for', + it(`returns early for empty objects argument`, async () => { + const result = await bulkGet([]); + expect(result).toEqual({ saved_objects: [] }); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - }); - - it('defaults to a refresh setting of `wait_for`', async () => { - callAdminCluster.mockReturnValue(deleteByQueryResults); - await savedObjectsRepository.deleteByNamespace('my-namespace'); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: 'wait_for', + it(`formats the ES response`, async () => { + const response = getMockMgetResponse([obj1, obj2]); + callAdminCluster.mockResolvedValue(response); + const result = await bulkGet([obj1, obj2]); + expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + saved_objects: [ + expectSuccessResult(obj1, response.docs[0]), + expectSuccessResult(obj2, response.docs[1]), + ], + }); }); - }); - it('accepts a custom refresh setting', async () => { - callAdminCluster.mockReturnValue(deleteByQueryResults); - await savedObjectsRepository.deleteByNamespace('my-namespace', { refresh: true }); + it(`handles a mix of successful gets and errors`, async () => { + const response = getMockMgetResponse([obj1, obj2]); + callAdminCluster.mockResolvedValue(response); + const obj = { type: 'unknownType', id: 'three' }; + const result = await bulkGet([obj1, obj, obj2]); + expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + saved_objects: [ + expectSuccessResult(obj1, response.docs[0]), + expectError(obj), + expectSuccessResult(obj2, response.docs[1]), + ], + }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: true, + it(`includes namespaces property for multi-namespace documents`, async () => { + const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const result = await bulkGetSuccess([obj1, obj]); + expect(result).toEqual({ + saved_objects: [ + expect.not.objectContaining({ namespaces: expect.anything() }), + expect.objectContaining({ namespaces: expect.any(Array) }), + ], + }); }); }); }); - describe('#find', () => { - it('waits until migrations are complete before proceeding', async () => { - migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled()); - - callAdminCluster.mockReturnValue(noNamespaceSearchResults); - await expect(savedObjectsRepository.find({ type: 'foo' })).resolves.toBeDefined(); - - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); - - it('requires type to be defined', async () => { - await expect(savedObjectsRepository.find({})).rejects.toThrow(/options\.type must be/); - expect(callAdminCluster).not.toHaveBeenCalled(); + describe('#bulkUpdate', () => { + const obj1 = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + }; + const obj2 = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + }; + const references = [{ name: 'ref_0', type: 'test', id: '1' }]; + const namespace = 'foo-namespace'; + + const getMockBulkUpdateResponse = (objects, options) => ({ + items: objects.map(({ type, id }) => ({ + update: { + _id: `${ + registry.isSingleNamespace(type) && options?.namespace ? `${options?.namespace}:` : '' + }${type}:${id}`, + ...mockVersionProps, + result: 'updated', + }, + })), }); - it('requires searchFields be an array if defined', async () => { - callAdminCluster.mockReturnValue(noNamespaceSearchResults); - try { - await savedObjectsRepository.find({ type: 'foo', searchFields: 'string' }); - throw new Error('expected find() to reject'); - } catch (error) { - expect(callAdminCluster).not.toHaveBeenCalled(); - expect(error.message).toMatch('must be an array'); + const bulkUpdateSuccess = async (objects, options) => { + const multiNamespaceObjects = objects.filter(({ type }) => registry.isMultiNamespace(type)); + if (multiNamespaceObjects?.length) { + const response = getMockMgetResponse(multiNamespaceObjects, options?.namespace); + callAdminCluster.mockResolvedValueOnce(response); // this._callCluster('mget', ...) } - }); + const response = getMockBulkUpdateResponse(objects, options?.namespace); + callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + const result = await savedObjectsRepository.bulkUpdate(objects, options); + expect(callAdminCluster).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 2 : 1); + return result; + }; - it('requires fields be an array if defined', async () => { - callAdminCluster.mockReturnValue(noNamespaceSearchResults); - try { - await savedObjectsRepository.find({ type: 'foo', fields: 'string' }); - throw new Error('expected find() to reject'); - } catch (error) { - expect(callAdminCluster).not.toHaveBeenCalled(); - expect(error.message).toMatch('must be an array'); + // bulk create calls have two objects for each source -- the action, and the source + const expectClusterCallArgsAction = ( + objects, + { method, _index = expect.any(String), getId = () => expect.any(String), overrides }, + n + ) => { + const body = []; + for (const { type, id } of objects) { + body.push({ + [method]: { + _index, + _id: getId(type, id), + ...overrides, + }, + }); + body.push(expect.any(Object)); } - }); - - it('passes mappings, schema, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl', async () => { - callAdminCluster.mockReturnValue(namespacedSearchResults); - const relevantOpts = { - namespace: 'foo-namespace', - search: 'foo*', - searchFields: ['foo'], - type: ['bar'], - sortField: 'name', - sortOrder: 'desc', - defaultSearchOperator: 'AND', - hasReference: { - type: 'foo', - id: '1', - }, - kueryNode: undefined, - }; + expectClusterCallArgs({ body }, n); + }; - await savedObjectsRepository.find(relevantOpts); - expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(1); - expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith( - mappings, - typeRegistry, - relevantOpts - ); - }); + const expectObjArgs = ({ type, attributes }) => [ + expect.any(Object), + { + doc: expect.objectContaining({ + [type]: attributes, + ...mockTimestampFields, + }), + }, + ]; - it('accepts KQL filter and passes keuryNode to getSearchDsl', async () => { - callAdminCluster.mockReturnValue(namespacedSearchResults); - const findOpts = { - namespace: 'foo-namespace', - search: 'foo*', - searchFields: ['foo'], - type: ['dashboard'], - sortField: 'name', - sortOrder: 'desc', - defaultSearchOperator: 'AND', - hasReference: { - type: 'foo', - id: '1', - }, - indexPattern: undefined, - filter: 'dashboard.attributes.otherField: *', + describe('cluster calls', () => { + it(`should use the ES bulk action by default`, async () => { + await bulkUpdateSuccess([obj1, obj2]); + expectClusterCalls('bulk'); + }); + + it(`should use the ES mget action before bulk action for any types that are multi-namespace`, async () => { + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; + await bulkUpdateSuccess(objects); + expectClusterCalls('mget', 'bulk'); + const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; + expectClusterCallArgs({ body: { docs } }, 1); + }); + + it(`formats the ES request`, async () => { + await bulkUpdateSuccess([obj1, obj2]); + const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; + expectClusterCallArgs({ body }); + }); + + it(`formats the ES request for any types that are multi-namespace`, async () => { + const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE }; + await bulkUpdateSuccess([obj1, _obj2]); + const body = [...expectObjArgs(obj1), ...expectObjArgs(_obj2)]; + expectClusterCallArgs({ body }, 2); + }); + + it(`doesnt call Elasticsearch if there are no valid objects to update`, async () => { + const objects = [obj1, obj2].map(x => ({ ...x, type: 'unknownType' })); + await savedObjectsRepository.bulkUpdate(objects); + expect(callAdminCluster).toHaveBeenCalledTimes(0); + }); + + it(`defaults to no references`, async () => { + await bulkUpdateSuccess([obj1, obj2]); + const expected = { doc: expect.not.objectContaining({ references: expect.anything() }) }; + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expectClusterCallArgs({ body }); + }); + + it(`accepts custom references array`, async () => { + const test = async references => { + const objects = [obj1, obj2].map(obj => ({ ...obj, references })); + await bulkUpdateSuccess(objects); + const expected = { doc: expect.objectContaining({ references }) }; + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expectClusterCallArgs({ body }); + callAdminCluster.mockReset(); + }; + await test(references); + await test(['string']); + await test([]); + }); + + it(`doesn't accept custom references if not an array`, async () => { + const test = async references => { + const objects = [obj1, obj2].map(obj => ({ ...obj, references })); + await bulkUpdateSuccess(objects); + const expected = { doc: expect.not.objectContaining({ references: expect.anything() }) }; + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expectClusterCallArgs({ body }); + callAdminCluster.mockReset(); + }; + await test('string'); + await test(123); + await test(true); + await test(null); + }); + + it(`defaults to a refresh setting of wait_for`, async () => { + await bulkUpdateSuccess([obj1, obj2]); + expectClusterCallArgs({ refresh: 'wait_for' }); + }); + + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + await bulkUpdateSuccess([obj1, obj2], { refresh }); + expectClusterCallArgs({ refresh }); + }); + + it(`defaults to the version of the existing document for multi-namespace types`, async () => { + // only multi-namespace documents are obtained using a pre-flight mget request + const objects = [ + { ...obj1, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_TYPE }, + ]; + await bulkUpdateSuccess(objects); + const overrides = { + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + expectClusterCallArgsAction(objects, { method: 'update', overrides }, 2); + }); + + it(`defaults to no version for types that are not multi-namespace`, async () => { + const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; + await bulkUpdateSuccess(objects); + expectClusterCallArgsAction(objects, { method: 'update' }); + }); + + it(`accepts version`, async () => { + const version = encodeHitVersion({ _seq_no: 100, _primary_term: 200 }); + // test with both non-multi-namespace and multi-namespace types + const objects = [ + { ...obj1, version }, + { ...obj2, type: MULTI_NAMESPACE_TYPE, version }, + ]; + await bulkUpdateSuccess(objects); + const overrides = { if_seq_no: 100, if_primary_term: 200 }; + expectClusterCallArgsAction(objects, { method: 'update', overrides }, 2); + }); + + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + const getId = (type, id) => `${namespace}:${type}:${id}`; + await bulkUpdateSuccess([obj1, obj2], { namespace }); + expectClusterCallArgsAction([obj1, obj2], { method: 'update', getId }); + }); + + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + const getId = (type, id) => `${type}:${id}`; + await bulkUpdateSuccess([obj1, obj2]); + expectClusterCallArgsAction([obj1, obj2], { method: 'update', getId }); + }); + + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + const getId = (type, id) => `${type}:${id}`; + const objects1 = [{ ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }]; + await bulkUpdateSuccess(objects1, { namespace }); + expectClusterCallArgsAction(objects1, { method: 'update', getId }); + callAdminCluster.mockReset(); + const overrides = { + // bulkUpdate uses a preflight `get` request for multi-namespace saved objects, and specifies that version on `update` + // we aren't testing for this here, but we need to include Jest assertions so this test doesn't fail + if_primary_term: expect.any(Number), + if_seq_no: expect.any(Number), + }; + const objects2 = [{ ...obj2, type: MULTI_NAMESPACE_TYPE }]; + await bulkUpdateSuccess(objects2, { namespace }); + expectClusterCallArgsAction(objects2, { method: 'update', getId, overrides }, 2); + }); + }); + + describe('errors', () => { + const obj = { + type: 'dashboard', + id: 'three', }; - await savedObjectsRepository.find(findOpts); - expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(1); - const { kueryNode } = getSearchDslNS.getSearchDsl.mock.calls[0][2]; - expect(kueryNode).toMatchInlineSnapshot(` - Object { - "arguments": Array [ - Object { - "type": "literal", - "value": "dashboard.otherField", - }, - Object { - "type": "wildcard", - "value": "@kuery-wildcard@", - }, - Object { - "type": "literal", - "value": false, - }, - ], - "function": "is", - "type": "function", + const bulkUpdateError = async (obj, esError, expectedError) => { + const objects = [obj1, obj, obj2]; + const mockResponse = getMockBulkUpdateResponse(objects); + if (esError) { + mockResponse.items[1].update = { error: esError }; } - `); - }); + callAdminCluster.mockResolvedValue(mockResponse); // this._writeToCluster('bulk', ...) + + const result = await savedObjectsRepository.bulkUpdate(objects); + expectClusterCalls('bulk'); + const objCall = esError ? expectObjArgs(obj) : []; + const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; + expectClusterCallArgs({ body }); + expect(result).toEqual({ + saved_objects: [expectSuccess(obj1), expectedError, expectSuccess(obj2)], + }); + }; - it('KQL filter syntax errors rejects with bad request', async () => { - callAdminCluster.mockReturnValue(namespacedSearchResults); - const findOpts = { - namespace: 'foo-namespace', - search: 'foo*', - searchFields: ['foo'], - type: ['dashboard'], - sortField: 'name', - sortOrder: 'desc', - defaultSearchOperator: 'AND', - hasReference: { - type: 'foo', - id: '1', - }, - indexPattern: undefined, - filter: 'dashboard.attributes.otherField:<', + const bulkUpdateMultiError = async ([obj1, _obj, obj2], options, mgetResponse) => { + callAdminCluster.mockResolvedValueOnce(mgetResponse); // this._callCluster('mget', ...) + const bulkResponse = getMockBulkUpdateResponse([obj1, obj2], namespace); + callAdminCluster.mockResolvedValue(bulkResponse); // this._writeToCluster('bulk', ...) + + const result = await savedObjectsRepository.bulkUpdate([obj1, _obj, obj2], options); + expectClusterCalls('mget', 'bulk'); + const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; + expectClusterCallArgs({ body }, 2); + expect(result).toEqual({ + saved_objects: [expectSuccess(obj1), expectErrorNotFound(_obj), expectSuccess(obj2)], + }); }; - await expect(savedObjectsRepository.find(findOpts)).rejects.toMatchInlineSnapshot(` - [Error: KQLSyntaxError: Expected "(", "{", value, whitespace but "<" found. - dashboard.attributes.otherField:< - --------------------------------^: Bad Request] - `); - expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(0); - }); + it(`returns error when type is invalid`, async () => { + const _obj = { ...obj, type: 'unknownType' }; + await bulkUpdateError(_obj, undefined, expectErrorNotFound(_obj)); + }); - it('merges output of getSearchDsl into es request body', async () => { - callAdminCluster.mockReturnValue(noNamespaceSearchResults); - getSearchDslNS.getSearchDsl.mockReturnValue({ query: 1, aggregations: 2 }); - await savedObjectsRepository.find({ type: 'foo' }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - 'search', - expect.objectContaining({ - body: expect.objectContaining({ - query: 1, - aggregations: 2, - }), - }) - ); - }); + it(`returns error when type is hidden`, async () => { + const _obj = { ...obj, type: HIDDEN_TYPE }; + await bulkUpdateError(_obj, undefined, expectErrorNotFound(_obj)); + }); - it('formats Elasticsearch response when there is no namespace', async () => { - callAdminCluster.mockReturnValue(noNamespaceSearchResults); - const count = noNamespaceSearchResults.hits.hits.length; + it(`returns error when ES is unable to find the document (mget)`, async () => { + const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE, found: false }; + const mgetResponse = getMockMgetResponse([_obj]); + await bulkUpdateMultiError([obj1, _obj, obj2], undefined, mgetResponse); + }); - const response = await savedObjectsRepository.find({ type: 'foo' }); + it(`returns error when ES is unable to find the index (mget)`, async () => { + const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE }; + const mgetResponse = { status: 404 }; + await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); + }); - expect(response.total).toBe(count); - expect(response.saved_objects).toHaveLength(count); + it(`returns error when there is a conflict with an existing multi-namespace saved object (mget)`, async () => { + const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE }; + const mgetResponse = getMockMgetResponse([_obj], 'bar-namespace'); + await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); + }); - noNamespaceSearchResults.hits.hits.forEach((doc, i) => { - expect(response.saved_objects[i]).toEqual({ - id: doc._id.replace(/(index-pattern|config|globaltype)\:/, ''), - type: doc._source.type, - ...mockTimestampFields, - version: mockVersion, - attributes: doc._source[doc._source.type], - references: [], - }); + it(`returns error when there is a version conflict (bulk)`, async () => { + const esError = { type: 'version_conflict_engine_exception' }; + await bulkUpdateError(obj, esError, expectErrorConflict(obj)); }); - }); - it('formats Elasticsearch response when there is a namespace', async () => { - callAdminCluster.mockReturnValue(namespacedSearchResults); - const count = namespacedSearchResults.hits.hits.length; + it(`returns error when document is missing (bulk)`, async () => { + const esError = { type: 'document_missing_exception' }; + await bulkUpdateError(obj, esError, expectErrorNotFound(obj)); + }); - const response = await savedObjectsRepository.find({ - type: 'foo', - namespace: 'foo-namespace', + it(`returns error reason for other errors (bulk)`, async () => { + const esError = { reason: 'some_other_error' }; + await bulkUpdateError(obj, esError, expectErrorResult(obj, { message: esError.reason })); }); - expect(response.total).toBe(count); - expect(response.saved_objects).toHaveLength(count); + it(`returns error string for other errors if no reason is defined (bulk)`, async () => { + const esError = { foo: 'some_other_error' }; + const expectedError = expectErrorResult(obj, { message: JSON.stringify(esError) }); + await bulkUpdateError(obj, esError, expectedError); + }); + }); - namespacedSearchResults.hits.hits.forEach((doc, i) => { - expect(response.saved_objects[i]).toEqual({ - id: doc._id.replace(/(foo-namespace\:)?(index-pattern|config|globaltype)\:/, ''), - type: doc._source.type, - ...mockTimestampFields, - version: mockVersion, - attributes: doc._source[doc._source.type], - references: [], - }); + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + migrator.runMigrations = jest.fn(async () => + expect(callAdminCluster).not.toHaveBeenCalled() + ); + await expect(bulkUpdateSuccess([obj1, obj2])).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveReturnedTimes(1); }); }); - it('accepts per_page/page', async () => { - callAdminCluster.mockReturnValue(noNamespaceSearchResults); - await savedObjectsRepository.find({ type: 'foo', perPage: 10, page: 6 }); + describe('returns', () => { + const expectSuccessResult = ({ type, id, attributes, references }) => ({ + type, + id, + attributes, + references, + version: mockVersion, + ...mockTimestampFields, + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - size: 10, - from: 50, - }) - ); - }); + it(`formats the ES response`, async () => { + const response = await bulkUpdateSuccess([obj1, obj2]); + expect(response).toEqual({ + saved_objects: [obj1, obj2].map(expectSuccessResult), + }); + }); - it('can filter by fields', async () => { - callAdminCluster.mockReturnValue(noNamespaceSearchResults); - await savedObjectsRepository.find({ type: 'foo', fields: ['title'] }); + it(`includes references`, async () => { + const objects = [obj1, obj2].map(obj => ({ ...obj, references })); + const response = await bulkUpdateSuccess(objects); + expect(response).toEqual({ + saved_objects: objects.map(expectSuccessResult), + }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - _source: [ - 'foo.title', - 'namespace', - 'type', - 'references', - 'migrationVersion', - 'updated_at', - 'title', + it(`handles a mix of successful updates and errors`, async () => { + const obj = { + type: 'unknownType', + id: 'three', + }; + const objects = [obj1, obj, obj2]; + const mockResponse = getMockBulkUpdateResponse(objects); + callAdminCluster.mockResolvedValue(mockResponse); // this._writeToCluster('bulk', ...) + const result = await savedObjectsRepository.bulkUpdate(objects); + expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + saved_objects: [expectSuccessResult(obj1), expectError(obj), expectSuccessResult(obj2)], + }); + }); + + it(`includes namespaces property for multi-namespace documents`, async () => { + const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const result = await bulkUpdateSuccess([obj1, obj]); + expect(result).toEqual({ + saved_objects: [ + expect.not.objectContaining({ namespaces: expect.anything() }), + expect.objectContaining({ namespaces: expect.any(Array) }), ], - }) - ); + }); + }); }); + }); - it('should set rest_total_hits_as_int to true on a request', async () => { - callAdminCluster.mockReturnValue(noNamespaceSearchResults); - await savedObjectsRepository.find({ type: 'foo' }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toHaveProperty('rest_total_hits_as_int', true); + describe('#create', () => { + beforeEach(() => { + callAdminCluster.mockImplementation((method, params) => ({ + _id: params.id, + ...mockVersionProps, + })); }); - }); - describe('#get', () => { - const noNamespaceResult = { - _id: 'index-pattern:logstash-*', - ...mockVersionProps, - _source: { - type: 'index-pattern', - specialProperty: 'specialValue', - ...mockTimestampFields, - 'index-pattern': { - title: 'Testing', - }, - }, - }; - const namespacedResult = { - _id: 'foo-namespace:index-pattern:logstash-*', - ...mockVersionProps, - _source: { - namespace: 'foo-namespace', - type: 'index-pattern', - specialProperty: 'specialValue', - ...mockTimestampFields, - 'index-pattern': { - title: 'Testing', - }, + const type = 'index-pattern'; + const attributes = { title: 'Logstash' }; + const id = 'logstash-*'; + const namespace = 'foo-namespace'; + const references = [ + { + name: 'ref_0', + type: 'test', + id: '123', }, - }; + ]; - it('waits until migrations are complete before proceeding', async () => { - migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled()); + const createSuccess = async (type, attributes, options) => { + const result = await savedObjectsRepository.create(type, attributes, options); + expect(callAdminCluster).toHaveBeenCalledTimes( + registry.isMultiNamespace(type) && options.overwrite ? 2 : 1 + ); + return result; + }; - callAdminCluster.mockResolvedValue(noNamespaceResult); - await expect( - savedObjectsRepository.get('index-pattern', 'logstash-*') - ).resolves.toBeDefined(); + describe('cluster calls', () => { + it(`should use the ES create action if ID is undefined and overwrite=true`, async () => { + await createSuccess(type, attributes, { overwrite: true }); + expectClusterCalls('create'); + }); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); + it(`should use the ES create action if ID is undefined and overwrite=false`, async () => { + await createSuccess(type, attributes); + expectClusterCalls('create'); + }); - it('formats Elasticsearch response when there is no namespace', async () => { - callAdminCluster.mockResolvedValue(noNamespaceResult); - const response = await savedObjectsRepository.get('index-pattern', 'logstash-*'); - expect(response).toEqual({ - id: 'logstash-*', - type: 'index-pattern', - updated_at: mockTimestamp, - version: mockVersion, - attributes: { - title: 'Testing', - }, - references: [], + it(`should use the ES index action if ID is defined and overwrite=true`, async () => { + await createSuccess(type, attributes, { id, overwrite: true }); + expectClusterCalls('index'); }); - }); - it('formats Elasticsearch response when there are namespaces', async () => { - callAdminCluster.mockResolvedValue(namespacedResult); - const response = await savedObjectsRepository.get('index-pattern', 'logstash-*'); - expect(response).toEqual({ - id: 'logstash-*', - type: 'index-pattern', - updated_at: mockTimestamp, - version: mockVersion, - attributes: { - title: 'Testing', - }, - references: [], + it(`should use the ES create action if ID is defined and overwrite=false`, async () => { + await createSuccess(type, attributes, { id }); + expectClusterCalls('create'); }); - }); - it('prepends namespace and type to the id when providing namespace for namespaced type', async () => { - callAdminCluster.mockResolvedValue(namespacedResult); - await savedObjectsRepository.get('index-pattern', 'logstash-*', { - namespace: 'foo-namespace', + it(`should use the ES get action then index action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { + await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, overwrite: true }); + expectClusterCalls('get', 'index'); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: 'foo-namespace:index-pattern:logstash-*', - }) - ); - }); + it(`defaults to empty references array`, async () => { + await createSuccess(type, attributes, { id }); + expectClusterCallArgs({ + body: expect.objectContaining({ references: [] }), + }); + }); - it(`only prepends type to the id when providing no namespace for namespaced type`, async () => { - callAdminCluster.mockResolvedValue(noNamespaceResult); - await savedObjectsRepository.get('index-pattern', 'logstash-*'); + it(`accepts custom references array`, async () => { + const test = async references => { + await createSuccess(type, attributes, { id, references }); + expectClusterCallArgs({ + body: expect.objectContaining({ references }), + }); + callAdminCluster.mockReset(); + }; + await test(references); + await test(['string']); + await test([]); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: 'index-pattern:logstash-*', - }) - ); - }); + it(`doesn't accept custom references if not an array`, async () => { + const test = async references => { + await createSuccess(type, attributes, { id, references }); + expectClusterCallArgs({ + body: expect.not.objectContaining({ references: expect.anything() }), + }); + callAdminCluster.mockReset(); + }; + await test('string'); + await test(123); + await test(true); + await test(null); + }); - it(`doesn't prepend namespace to the id when providing namespace for namespace agnostic type`, async () => { - callAdminCluster.mockResolvedValue(namespacedResult); - await savedObjectsRepository.get('globaltype', 'logstash-*', { - namespace: 'foo-namespace', + it(`defaults to a refresh setting of wait_for`, async () => { + await createSuccess(type, attributes); + expectClusterCallArgs({ refresh: 'wait_for' }); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: 'globaltype:logstash-*', - }) - ); - }); - }); + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + await createSuccess(type, attributes, { refresh }); + expectClusterCallArgs({ refresh }); + }); - describe('#bulkGet', () => { - it('waits until migrations are complete before proceeding', async () => { - migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled()); - - callAdminCluster.mockReturnValue({ docs: [] }); - await expect( - savedObjectsRepository.bulkGet([ - { id: 'one', type: 'config' }, - { id: 'two', type: 'index-pattern' }, - { id: 'three', type: 'globaltype' }, - ]) - ).resolves.toBeDefined(); - - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); + it(`should use default index`, async () => { + await createSuccess(type, attributes, { id }); + expectClusterCallArgs({ index: '.kibana-test' }); + }); - it('prepends type to id when getting objects when there is no namespace', async () => { - callAdminCluster.mockReturnValue({ docs: [] }); + it(`should use custom index`, async () => { + await createSuccess(CUSTOM_INDEX_TYPE, attributes, { id }); + expectClusterCallArgs({ index: 'custom' }); + }); - await savedObjectsRepository.bulkGet([ - { id: 'one', type: 'config' }, - { id: 'two', type: 'index-pattern' }, - { id: 'three', type: 'globaltype' }, - ]); + it(`self-generates an id if none is provided`, async () => { + await createSuccess(type, attributes); + expectClusterCallArgs({ + id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), + }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: { - docs: [ - { _id: 'config:one', _index: '.kibana-test' }, - { _id: 'index-pattern:two', _index: '.kibana-test' }, - { _id: 'globaltype:three', _index: '.kibana-test' }, - ], - }, - }) - ); - }); + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + await createSuccess(type, attributes, { id, namespace }); + expectClusterCallArgs({ id: `${namespace}:${type}:${id}` }); + }); - it('prepends namespace and type appropriately to id when getting objects when there is a namespace', async () => { - callAdminCluster.mockReturnValue({ docs: [] }); + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + await createSuccess(type, attributes, { id }); + expectClusterCallArgs({ id: `${type}:${id}` }); + }); - await savedObjectsRepository.bulkGet( - [ - { id: 'one', type: 'config' }, - { id: 'two', type: 'index-pattern' }, - { id: 'three', type: 'globaltype' }, - ], - { - namespace: 'foo-namespace', - } - ); + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id, namespace }); + expectClusterCallArgs({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }); + callAdminCluster.mockReset(); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: { - docs: [ - { _id: 'foo-namespace:config:one', _index: '.kibana-test' }, - { _id: 'foo-namespace:index-pattern:two', _index: '.kibana-test' }, - { _id: 'globaltype:three', _index: '.kibana-test' }, - ], - }, - }) - ); + await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); + expectClusterCallArgs({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }); + }); }); - it('mockReturnValue early for empty objects argument', async () => { - callAdminCluster.mockReturnValue({ docs: [] }); + describe('errors', () => { + it(`throws when type is invalid`, async () => { + await expect(savedObjectsRepository.create('unknownType', attributes)).rejects.toThrowError( + createUnsupportedTypeError('unknownType') + ); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); - const response = await savedObjectsRepository.bulkGet([]); + it(`throws when type is hidden`, async () => { + await expect(savedObjectsRepository.create(HIDDEN_TYPE, attributes)).rejects.toThrowError( + createUnsupportedTypeError(HIDDEN_TYPE) + ); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); - expect(response.saved_objects).toHaveLength(0); - expect(callAdminCluster).not.toHaveBeenCalled(); + it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { + const response = getMockGetResponse({ + type: MULTI_NAMESPACE_TYPE, + id, + namespace: 'bar-namespace', + }); + callAdminCluster.mockResolvedValue(response); // this._callCluster('get', ...) + await expect( + savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { + id, + overwrite: true, + namespace, + }) + ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); + expectClusterCalls('get'); + }); + + it(`throws when automatic index creation fails`, async () => { + // TODO + }); + + it(`throws when an unexpected failure occurs`, async () => { + // TODO + }); }); - it('handles missing ids gracefully', async () => { - callAdminCluster.mockResolvedValue({ - docs: [ - { - _id: 'config:good', - found: true, - ...mockVersionProps, - _source: { ...mockTimestampFields, config: { title: 'Test' } }, - }, - { - _id: 'config:bad', - found: false, - }, - ], + describe('migration', () => { + beforeEach(() => { + migrator.migrateDocument.mockImplementation(mockMigrateDocument); }); - const { saved_objects: savedObjects } = await savedObjectsRepository.bulkGet([ - { id: 'good', type: 'config' }, - { type: 'config' }, - ]); + it(`waits until migrations are complete before proceeding`, async () => { + migrator.runMigrations = jest.fn(async () => + expect(callAdminCluster).not.toHaveBeenCalled() + ); + await expect(createSuccess(type, attributes, { id, namespace })).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + }); - expect(savedObjects[1]).toEqual({ - type: 'config', - error: { statusCode: 404, message: 'Not found' }, + it(`migrates a document and serializes the migrated doc`, async () => { + const migrationVersion = mockMigrationVersion; + await createSuccess(type, attributes, { id, references, migrationVersion }); + const doc = { type, id, attributes, references, migrationVersion, ...mockTimestampFields }; + expectMigrationArgs(doc); + + const migratedDoc = migrator.migrateDocument(doc); + expect(serializer.savedObjectToRaw).toHaveBeenLastCalledWith(migratedDoc); }); - }); - it('reports error on missed objects', async () => { - callAdminCluster.mockResolvedValue({ - docs: [ - { - _id: 'config:good', - found: true, - ...mockVersionProps, - _source: { ...mockTimestampFields, config: { title: 'Test' } }, - }, - { - _id: 'config:bad', - found: false, - }, - ], + it(`adds namespace to body when providing namespace for single-namespace type`, async () => { + await createSuccess(type, attributes, { id, namespace }); + expectMigrationArgs({ namespace }); }); - const { saved_objects: savedObjects } = await savedObjectsRepository.bulkGet([ - { id: 'good', type: 'config' }, - { id: 'bad', type: 'config' }, - ]); + it(`doesn't add namespace to body when providing no namespace for single-namespace type`, async () => { + await createSuccess(type, attributes, { id }); + expectMigrationArgs({ namespace: expect.anything() }, false); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`doesn't add namespace to body when not using single-namespace type`, async () => { + await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id, namespace }); + expectMigrationArgs({ namespace: expect.anything() }, false, 1); - expect(savedObjects).toHaveLength(2); - expect(savedObjects[0]).toEqual({ - id: 'good', - type: 'config', - ...mockTimestampFields, - version: mockVersion, - attributes: { title: 'Test' }, - references: [], + callAdminCluster.mockReset(); + await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id }); + expectMigrationArgs({ namespace: expect.anything() }, false, 2); }); - expect(savedObjects[1]).toEqual({ - id: 'bad', - type: 'config', - error: { statusCode: 404, message: 'Not found' }, + + it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { + await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); + expectMigrationArgs({ namespaces: [namespace] }); }); - }); - it('returns errors when requesting unsupported types', async () => { - callAdminCluster.mockResolvedValue({ - docs: [ - { - _id: 'one', - found: true, - ...mockVersionProps, - _source: { ...mockTimestampFields, config: { title: 'Test1' } }, - }, - { - _id: 'three', - found: true, - ...mockVersionProps, - _source: { ...mockTimestampFields, config: { title: 'Test3' } }, - }, - { - _id: 'five', - found: true, - ...mockVersionProps, - _source: { ...mockTimestampFields, config: { title: 'Test5' } }, - }, - ], + it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { + await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id }); + expectMigrationArgs({ namespaces: ['default'] }); }); - const { saved_objects: savedObjects } = await savedObjectsRepository.bulkGet([ - { id: 'one', type: 'config' }, - { id: 'two', type: 'invalidtype' }, - { id: 'three', type: 'config' }, - { id: 'four', type: 'invalidtype' }, - { id: 'five', type: 'config' }, - ]); + it(`doesn't add namespaces to body when not using multi-namespace type`, async () => { + await createSuccess(type, attributes, { id }); + expectMigrationArgs({ namespaces: expect.anything() }, false, 1); - expect(savedObjects).toEqual([ - { - attributes: { title: 'Test1' }, - id: 'one', - ...mockTimestampFields, - references: [], - type: 'config', - version: mockVersion, - migrationVersion: undefined, - }, - { - attributes: { title: 'Test3' }, - id: 'three', - ...mockTimestampFields, - references: [], - type: 'config', - version: mockVersion, - migrationVersion: undefined, - }, - { - attributes: { title: 'Test5' }, - id: 'five', + callAdminCluster.mockReset(); + await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id }); + expectMigrationArgs({ namespaces: expect.anything() }, false, 2); + }); + }); + + describe('returns', () => { + it(`formats the ES response`, async () => { + const result = await createSuccess(type, attributes, { id, namespace, references }); + expect(result).toEqual({ + type, + id, ...mockTimestampFields, - references: [], - type: 'config', version: mockVersion, - migrationVersion: undefined, - }, - { - error: { - error: 'Bad Request', - message: "Unsupported saved object type: 'invalidtype': Bad Request", - statusCode: 400, - }, - id: 'two', - type: 'invalidtype', - }, - { - error: { - error: 'Bad Request', - message: "Unsupported saved object type: 'invalidtype': Bad Request", - statusCode: 400, - }, - id: 'four', - type: 'invalidtype', - }, - ]); + attributes, + references, + }); + }); }); }); - describe('#update', () => { - const id = 'logstash-*'; + describe('#delete', () => { const type = 'index-pattern'; - const attributes = { title: 'Testing' }; + const id = 'logstash-*'; + const namespace = 'foo-namespace'; - beforeEach(() => { - callAdminCluster.mockResolvedValue({ - _id: `${type}:${id}`, - ...mockVersionProps, - result: 'updated', + const deleteSuccess = async (type, id, options) => { + if (registry.isMultiNamespace(type)) { + const mockGetResponse = getMockGetResponse({ type, id, namespace: options?.namespace }); + callAdminCluster.mockResolvedValueOnce(mockGetResponse); // this._callCluster('get', ...) + } + callAdminCluster.mockResolvedValue({ result: 'deleted' }); // this._writeToCluster('delete', ...) + const result = await savedObjectsRepository.delete(type, id, options); + expect(callAdminCluster).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 2 : 1); + return result; + }; + + describe('cluster calls', () => { + it(`should use the ES delete action when not using a multi-namespace type`, async () => { + await deleteSuccess(type, id); + expectClusterCalls('delete'); }); - }); - it('waits until migrations are complete before proceeding', async () => { - migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled()); + it(`should use ES get action then delete action when using a multi-namespace type with no namespaces remaining`, async () => { + await deleteSuccess(MULTI_NAMESPACE_TYPE, id); + expectClusterCalls('get', 'delete'); + }); - await expect( - savedObjectsRepository.update('index-pattern', 'logstash-*', attributes, { - namespace: 'foo-namespace', - }) - ).resolves.toBeDefined(); + it(`should use ES get action then update action when using a multi-namespace type with one or more namespaces remaining`, async () => { + const mockResponse = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }); + mockResponse._source.namespaces = ['default', 'some-other-nameespace']; + callAdminCluster + .mockResolvedValueOnce(mockResponse) // this._callCluster('get', ...) + .mockResolvedValue({ result: 'updated' }); // this._writeToCluster('update', ...) + await savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id); + expectClusterCalls('get', 'update'); + }); - expect(migrator.runMigrations).toHaveReturnedTimes(1); - }); + it(`includes the version of the existing document when type is multi-namespace`, async () => { + await deleteSuccess(MULTI_NAMESPACE_TYPE, id); + const versionProperties = { + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + expectClusterCallArgs(versionProperties, 2); + }); - it('mockReturnValue current ES document _seq_no and _primary_term encoded as version', async () => { - const response = await savedObjectsRepository.update( - 'index-pattern', - 'logstash-*', - attributes, - { - namespace: 'foo-namespace', - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - } - ); - expect(response).toEqual({ - id, - type, - ...mockTimestampFields, - version: mockVersion, - attributes, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], + it(`defaults to a refresh setting of wait_for`, async () => { + await deleteSuccess(type, id); + expectClusterCallArgs({ refresh: 'wait_for' }); }); - }); - it('accepts version', async () => { - await savedObjectsRepository.update( - type, - id, - { title: 'Testing' }, - { - version: encodeHitVersion({ - _seq_no: 100, - _primary_term: 200, - }), - } - ); + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + await deleteSuccess(type, id, { refresh }); + expectClusterCallArgs({ refresh }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - if_seq_no: 100, - if_primary_term: 200, - }) - ); + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + await deleteSuccess(type, id, { namespace }); + expectClusterCallArgs({ id: `${namespace}:${type}:${id}` }); + }); + + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + await deleteSuccess(type, id); + expectClusterCallArgs({ id: `${type}:${id}` }); + }); + + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + await deleteSuccess(NAMESPACE_AGNOSTIC_TYPE, id, { namespace }); + expectClusterCallArgs({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }); + + callAdminCluster.mockReset(); + await deleteSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); + expectClusterCallArgs({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }); + }); }); - it('does not pass references if omitted', async () => { - await savedObjectsRepository.update(type, id, { title: 'Testing' }); + describe('errors', () => { + const expectNotFoundError = async (type, id, options) => { + await expect(savedObjectsRepository.delete(type, id, options)).rejects.toThrowError( + createGenericNotFoundError(type, id) + ); + }; + + it(`throws when type is invalid`, async () => { + await expectNotFoundError('unknownType', id); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); + + it(`throws when type is hidden`, async () => { + await expectNotFoundError(HIDDEN_TYPE, id); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); + + it(`throws when ES is unable to find the document during get`, async () => { + callAdminCluster.mockResolvedValue({ found: false }); // this._callCluster('get', ...) + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + expectClusterCalls('get'); + }); + + it(`throws when ES is unable to find the index during get`, async () => { + callAdminCluster.mockResolvedValue({ status: 404 }); // this._callCluster('get', ...) + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + expectClusterCalls('get'); + }); + + it(`throws when the type is multi-namespace and the document exists, but not in this namespace`, async () => { + const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + callAdminCluster.mockResolvedValue(response); // this._callCluster('get', ...) + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + expectClusterCalls('get'); + }); + + it(`throws when ES is unable to find the document during update`, async () => { + const mockResponse = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }); + mockResponse._source.namespaces = ['default', 'some-other-nameespace']; + callAdminCluster + .mockResolvedValueOnce(mockResponse) // this._callCluster('get', ...) + .mockResolvedValue({ status: 404 }); // this._writeToCluster('update', ...) + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + expectClusterCalls('get', 'update'); + }); + + it(`throws when ES is unable to find the document during delete`, async () => { + callAdminCluster.mockResolvedValue({ result: 'not_found' }); // this._writeToCluster('delete', ...) + await expectNotFoundError(type, id); + expectClusterCalls('delete'); + }); + + it(`throws when ES is unable to find the index during delete`, async () => { + callAdminCluster.mockResolvedValue({ error: { type: 'index_not_found_exception' } }); // this._writeToCluster('delete', ...) + await expectNotFoundError(type, id); + expectClusterCalls('delete'); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).not.toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: { - doc: expect.objectContaining({ - references: [], - }), - }, - }) - ); + it(`throws when ES returns an unexpected response`, async () => { + callAdminCluster.mockResolvedValue({ result: 'something unexpected' }); // this._writeToCluster('delete', ...) + await expect(savedObjectsRepository.delete(type, id)).rejects.toThrowError( + 'Unexpected Elasticsearch DELETE response' + ); + expectClusterCalls('delete'); + }); }); - it('passes references if they are provided', async () => { - await savedObjectsRepository.update(type, id, { title: 'Testing' }, { references: ['foo'] }); + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + let callAdminClusterCount = 0; + migrator.runMigrations = jest.fn(async () => + // runMigrations should resolve before callAdminCluster is initiated + expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) + ); + await expect(deleteSuccess(type, id)).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: { - doc: expect.objectContaining({ - references: ['foo'], - }), - }, - }) - ); + describe('returns', () => { + it(`returns an empty object on success`, async () => { + const result = await deleteSuccess(type, id); + expect(result).toEqual({}); + }); }); + }); - it('passes empty references array if empty references array is provided', async () => { - await savedObjectsRepository.update(type, id, { title: 'Testing' }, { references: [] }); + describe('#deleteByNamespace', () => { + const namespace = 'foo-namespace'; + const mockUpdateResults = { + took: 15, + timed_out: false, + total: 3, + updated: 2, + deleted: 1, + batches: 1, + version_conflicts: 0, + noops: 0, + retries: { bulk: 0, search: 0 }, + throttled_millis: 0, + requests_per_second: -1.0, + throttled_until_millis: 0, + failures: [], + }; + const deleteByNamespaceSuccess = async (namespace, options) => { + callAdminCluster.mockResolvedValue(mockUpdateResults); + const result = await savedObjectsRepository.deleteByNamespace(namespace, options); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(1); expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: { - doc: expect.objectContaining({ - references: [], - }), - }, - }) - ); - }); + return result; + }; - it(`prepends namespace to the id but doesn't add namespace to body when providing namespace for namespaced type`, async () => { - await savedObjectsRepository.update( - 'index-pattern', - 'logstash-*', - { - title: 'Testing', - }, - { - namespace: 'foo-namespace', - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - } - ); + describe('cluster calls', () => { + it(`should use the ES updateByQuery action`, async () => { + await deleteByNamespaceSuccess(namespace); + expectClusterCalls('updateByQuery'); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith('update', { - id: 'foo-namespace:index-pattern:logstash-*', - body: { - doc: { - updated_at: mockTimestamp, - 'index-pattern': { title: 'Testing' }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - }, - }, - ignore: [404], - refresh: 'wait_for', - index: '.kibana-test', + it(`defaults to a refresh setting of wait_for`, async () => { + await deleteByNamespaceSuccess(namespace); + expectClusterCallArgs({ refresh: 'wait_for' }); }); - }); - it(`doesn't prepend namespace to the id or add namespace property when providing no namespace for namespaced type`, async () => { - await savedObjectsRepository.update( - 'index-pattern', - 'logstash-*', - { - title: 'Testing', - }, - { - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - } - ); + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + await deleteByNamespaceSuccess(namespace, { refresh }); + expectClusterCallArgs({ refresh }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith('update', { - id: 'index-pattern:logstash-*', - body: { - doc: { - updated_at: mockTimestamp, - 'index-pattern': { title: 'Testing' }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - }, - }, - ignore: [404], - refresh: 'wait_for', - index: '.kibana-test', + it(`should use all indices for types that are not namespace-agnostic`, async () => { + await deleteByNamespaceSuccess(namespace); + expectClusterCallArgs({ index: ['.kibana-test', 'custom'] }, 1); }); }); - it(`doesn't prepend namespace to the id or add namespace property when providing namespace for namespace agnostic type`, async () => { - await savedObjectsRepository.update( - 'globaltype', - 'foo', - { - name: 'bar', - }, - { - namespace: 'foo-namespace', - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - } - ); - - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith('update', { - id: 'globaltype:foo', - body: { - doc: { - updated_at: mockTimestamp, - globaltype: { name: 'bar' }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - }, - }, - ignore: [404], - refresh: 'wait_for', - index: '.kibana-test', + describe('errors', () => { + it(`throws when namespace is not a string`, async () => { + const test = async namespace => { + await expect(savedObjectsRepository.deleteByNamespace(namespace)).rejects.toThrowError( + `namespace is required, and must be a string` + ); + expect(callAdminCluster).not.toHaveBeenCalled(); + }; + await test(undefined); + await test(['namespace']); + await test(123); + await test(true); }); }); - it('defaults to a refresh setting of `wait_for`', async () => { - await savedObjectsRepository.update('globaltype', 'foo', { - name: 'bar', + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + migrator.runMigrations = jest.fn(async () => + expect(callAdminCluster).not.toHaveBeenCalled() + ); + await expect(deleteByNamespaceSuccess(namespace)).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveBeenCalledTimes(1); }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: 'wait_for', + describe('returns', () => { + it(`returns the query results on success`, async () => { + const result = await deleteByNamespaceSuccess(namespace); + expect(result).toEqual(mockUpdateResults); }); }); - it('accepts a custom refresh setting', async () => { - await savedObjectsRepository.update( - 'globaltype', - 'foo', - { - name: 'bar', - }, - { - refresh: true, - namespace: 'foo-namespace', - } - ); - - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: true, + describe('search dsl', () => { + it(`constructs a query using all multi-namespace types, and another using all single-namespace types`, async () => { + await deleteByNamespaceSuccess(namespace); + const allTypes = registry.getAllTypes().map(type => type.name); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { + namespace, + type: allTypes.filter(type => !registry.isNamespaceAgnostic(type)), + }); }); }); }); - describe('#bulkUpdate', () => { - const { generateSavedObject, reset } = (() => { - let count = 0; + describe('#find', () => { + const generateSearchResults = namespace => { return { - generateSavedObject(overrides) { - count++; - return _.merge( + hits: { + total: 4, + hits: [ { - type: 'index-pattern', - id: `logstash-${count}`, - attributes: { title: `Testing ${count}` }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', + _index: '.kibana', + _id: `${namespace ? `${namespace}:` : ''}index-pattern:logstash-*`, + _score: 1, + ...mockVersionProps, + _source: { + namespace, + type: 'index-pattern', + ...mockTimestampFields, + 'index-pattern': { + title: 'logstash-*', + timeFieldName: '@timestamp', + notExpandable: true, }, - ], + }, }, - overrides - ); - }, - reset() { - count = 0; + { + _index: '.kibana', + _id: `${namespace ? `${namespace}:` : ''}config:6.0.0-alpha1`, + _score: 1, + ...mockVersionProps, + _source: { + namespace, + type: 'config', + ...mockTimestampFields, + config: { + buildNum: 8467, + defaultIndex: 'logstash-*', + }, + }, + }, + { + _index: '.kibana', + _id: `${namespace ? `${namespace}:` : ''}index-pattern:stocks-*`, + _score: 1, + ...mockVersionProps, + _source: { + namespace, + type: 'index-pattern', + ...mockTimestampFields, + 'index-pattern': { + title: 'stocks-*', + timeFieldName: '@timestamp', + notExpandable: true, + }, + }, + }, + { + _index: '.kibana', + _id: `${NAMESPACE_AGNOSTIC_TYPE}:something`, + _score: 1, + ...mockVersionProps, + _source: { + type: NAMESPACE_AGNOSTIC_TYPE, + ...mockTimestampFields, + [NAMESPACE_AGNOSTIC_TYPE]: { + name: 'bar', + }, + }, + }, + ], }, }; - })(); + }; - beforeEach(() => { - reset(); - }); + const type = 'index-pattern'; + const namespace = 'foo-namespace'; - const mockValidResponse = objects => - callAdminCluster.mockReturnValue({ - items: objects.map(items => ({ - update: { - _id: `${items.type}:${items.id}`, - ...mockVersionProps, - result: 'updated', - }, - })), + const findSuccess = async (options, namespace) => { + callAdminCluster.mockResolvedValue(generateSearchResults(namespace)); + const result = await savedObjectsRepository.find(options); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(1); + expect(callAdminCluster).toHaveBeenCalledTimes(1); + return result; + }; + + describe('cluster calls', () => { + it(`should use the ES search action`, async () => { + await findSuccess({ type }); + expectClusterCalls('search'); + }); + + it(`merges output of getSearchDsl into es request body`, async () => { + const query = { query: 1, aggregations: 2 }; + getSearchDslNS.getSearchDsl.mockReturnValue(query); + await findSuccess({ type }); + expectClusterCallArgs({ body: expect.objectContaining({ ...query }) }); }); - it('waits until migrations are complete before proceeding', async () => { - const objects = [generateSavedObject(), generateSavedObject()]; + it(`accepts per_page/page`, async () => { + await findSuccess({ type, perPage: 10, page: 6 }); + expectClusterCallArgs({ + size: 10, + from: 50, + }); + }); - migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled()); + it(`can filter by fields`, async () => { + await findSuccess({ type, fields: ['title'] }); + expectClusterCallArgs({ + _source: [ + `${type}.title`, + 'namespace', + 'namespaces', + 'type', + 'references', + 'migrationVersion', + 'updated_at', + 'title', + ], + }); + }); - mockValidResponse(objects); + it(`should set rest_total_hits_as_int to true on a request`, async () => { + await findSuccess({ type }); + expectClusterCallArgs({ rest_total_hits_as_int: true }); + }); - await expect( - savedObjectsRepository.bulkUpdate([generateSavedObject()]) - ).resolves.toBeDefined(); + it(`should not make a cluster call when attempting to find only invalid or hidden types`, async () => { + const test = async types => { + await savedObjectsRepository.find({ type: types }); + expect(callAdminCluster).not.toHaveBeenCalled(); + }; - expect(migrator.runMigrations).toHaveReturnedTimes(1); + await test('unknownType'); + await test(HIDDEN_TYPE); + await test(['unknownType', HIDDEN_TYPE]); + }); }); - it('returns current ES document, _seq_no and _primary_term encoded as version', async () => { - const objects = [generateSavedObject(), generateSavedObject()]; - - mockValidResponse(objects); + describe('errors', () => { + it(`throws when type is not defined`, async () => { + await expect(savedObjectsRepository.find({})).rejects.toThrowError( + 'options.type must be a string or an array of strings' + ); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); - const response = await savedObjectsRepository.bulkUpdate(objects); + it(`throws when searchFields is defined but not an array`, async () => { + await expect( + savedObjectsRepository.find({ type, searchFields: 'string' }) + ).rejects.toThrowError('options.searchFields must be an array'); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); - expect(response.saved_objects[0]).toMatchObject({ - ..._.pick(objects[0], 'id', 'type', 'attributes'), - version: mockVersion, - references: objects[0].references, + it(`throws when fields is defined but not an array`, async () => { + await expect(savedObjectsRepository.find({ type, fields: 'string' })).rejects.toThrowError( + 'options.fields must be an array' + ); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - expect(response.saved_objects[1]).toMatchObject({ - ..._.pick(objects[1], 'id', 'type', 'attributes'), - version: mockVersion, - references: objects[1].references, + + it(`throws when KQL filter syntax is invalid`, async () => { + const findOpts = { + namespace, + search: 'foo*', + searchFields: ['foo'], + type: ['dashboard'], + sortField: 'name', + sortOrder: 'desc', + defaultSearchOperator: 'AND', + hasReference: { + type: 'foo', + id: '1', + }, + indexPattern: undefined, + filter: 'dashboard.attributes.otherField:<', + }; + + await expect(savedObjectsRepository.find(findOpts)).rejects.toMatchInlineSnapshot(` + [Error: KQLSyntaxError: Expected "(", "{", value, whitespace but "<" found. + dashboard.attributes.otherField:< + --------------------------------^: Bad Request] + `); + expect(getSearchDslNS.getSearchDsl).not.toHaveBeenCalled(); + expect(callAdminCluster).not.toHaveBeenCalled(); }); }); - it('handles a mix of succesfull updates and errors', async () => { - const objects = [ - generateSavedObject(), - { - type: 'invalid-type', - id: 'invalid', - attributes: { title: 'invalid' }, - }, - generateSavedObject(), - generateSavedObject({ - id: 'version_clash', - }), - ]; - - callAdminCluster.mockReturnValue({ - items: objects - // remove invalid from mocks - .filter(item => item.id !== 'invalid') - .map(items => { - switch (items.id) { - case 'version_clash': - return { - update: { - _id: `${items.type}:${items.id}`, - error: { - type: 'version_conflict_engine_exception', - }, - }, - }; - default: - return { - update: { - _id: `${items.type}:${items.id}`, - ...mockVersionProps, - result: 'updated', - }, - }; - } - }), - }); - - const { - saved_objects: [firstUpdatedObject, invalidType, secondUpdatedObject, versionClashObject], - } = await savedObjectsRepository.bulkUpdate(objects); - - expect(firstUpdatedObject).toMatchObject({ - ..._.pick(objects[0], 'id', 'type', 'attributes', 'references'), - version: mockVersion, + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + migrator.runMigrations = jest.fn(async () => + expect(callAdminCluster).not.toHaveBeenCalled() + ); + await expect(findSuccess({ type })).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveBeenCalledTimes(1); }); + }); - expect(invalidType).toMatchObject({ - ..._.pick(objects[1], 'id', 'type'), - error: SavedObjectsErrorHelpers.createGenericNotFoundError('invalid-type', 'invalid').output - .payload, - }); + describe('returns', () => { + it(`formats the ES response when there is no namespace`, async () => { + const noNamespaceSearchResults = generateSearchResults(); + callAdminCluster.mockReturnValue(noNamespaceSearchResults); + const count = noNamespaceSearchResults.hits.hits.length; - expect(secondUpdatedObject).toMatchObject({ - ..._.pick(objects[2], 'id', 'type', 'attributes', 'references'), - version: mockVersion, - }); + const response = await savedObjectsRepository.find({ type }); + + expect(response.total).toBe(count); + expect(response.saved_objects).toHaveLength(count); - expect(versionClashObject).toMatchObject({ - ..._.pick(objects[3], 'id', 'type'), - error: { statusCode: 409, message: 'version conflict, document already exists' }, + noNamespaceSearchResults.hits.hits.forEach((doc, i) => { + expect(response.saved_objects[i]).toEqual({ + id: doc._id.replace(/(index-pattern|config|globalType)\:/, ''), + type: doc._source.type, + ...mockTimestampFields, + version: mockVersion, + attributes: doc._source[doc._source.type], + references: [], + }); + }); }); - }); - it('doesnt call Elasticsearch if there are no valid objects to update', async () => { - const objects = [ - { - type: 'invalid-type', - id: 'invalid', - attributes: { title: 'invalid' }, - }, - { - type: 'invalid-type', - id: 'invalid 2', - attributes: { title: 'invalid' }, - }, - ]; + it(`formats the ES response when there is a namespace`, async () => { + const namespacedSearchResults = generateSearchResults(namespace); + callAdminCluster.mockReturnValue(namespacedSearchResults); + const count = namespacedSearchResults.hits.hits.length; - const { - saved_objects: [invalidType, invalidType2], - } = await savedObjectsRepository.bulkUpdate(objects); + const response = await savedObjectsRepository.find({ type, namespace }); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(response.total).toBe(count); + expect(response.saved_objects).toHaveLength(count); - expect(invalidType).toMatchObject({ - ..._.pick(objects[0], 'id', 'type'), - error: SavedObjectsErrorHelpers.createGenericNotFoundError('invalid-type', 'invalid').output - .payload, + namespacedSearchResults.hits.hits.forEach((doc, i) => { + expect(response.saved_objects[i]).toEqual({ + id: doc._id.replace(/(foo-namespace\:)?(index-pattern|config|globalType)\:/, ''), + type: doc._source.type, + ...mockTimestampFields, + version: mockVersion, + attributes: doc._source[doc._source.type], + references: [], + }); + }); }); - expect(invalidType2).toMatchObject({ - ..._.pick(objects[1], 'id', 'type'), - error: SavedObjectsErrorHelpers.createGenericNotFoundError('invalid-type', 'invalid 2') - .output.payload, + it(`should return empty results when attempting to find only invalid or hidden types`, async () => { + const test = async types => { + const result = await savedObjectsRepository.find({ type: types }); + expect(result).toEqual(expect.objectContaining({ saved_objects: [] })); + }; + + await test('unknownType'); + await test(HIDDEN_TYPE); + await test(['unknownType', HIDDEN_TYPE]); }); }); - it('accepts version', async () => { - const objects = [ - generateSavedObject({ - version: encodeHitVersion({ - _seq_no: 100, - _primary_term: 200, - }), - }), - generateSavedObject({ - version: encodeHitVersion({ - _seq_no: 300, - _primary_term: 400, - }), - }), - ]; + describe('search dsl', () => { + it(`passes mappings, registry, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl`, async () => { + const relevantOpts = { + namespace, + search: 'foo*', + searchFields: ['foo'], + type: [type], + sortField: 'name', + sortOrder: 'desc', + defaultSearchOperator: 'AND', + hasReference: { + type: 'foo', + id: '1', + }, + kueryNode: undefined, + }; - mockValidResponse(objects); + await findSuccess(relevantOpts, namespace); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, relevantOpts); + }); + + it(`accepts KQL filter and passes kueryNode to getSearchDsl`, async () => { + const findOpts = { + namespace, + search: 'foo*', + searchFields: ['foo'], + type: ['dashboard'], + sortField: 'name', + sortOrder: 'desc', + defaultSearchOperator: 'AND', + hasReference: { + type: 'foo', + id: '1', + }, + indexPattern: undefined, + filter: 'dashboard.attributes.otherField: *', + }; + + await findSuccess(findOpts, namespace); + const { kueryNode } = getSearchDslNS.getSearchDsl.mock.calls[0][2]; + expect(kueryNode).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "dashboard.otherField", + }, + Object { + "type": "wildcard", + "value": "@kuery-wildcard@", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + } + `); + }); - await savedObjectsRepository.bulkUpdate(objects); + it(`supports multiple types`, async () => { + const types = ['config', 'index-pattern']; + await findSuccess({ type: types }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith( + mappings, + registry, + expect.objectContaining({ + type: types, + }) + ); + }); - const [ - , - { - body: [{ update: firstUpdate }, , { update: secondUpdate }], - }, - ] = callAdminCluster.mock.calls[0]; + it(`filters out invalid types`, async () => { + const types = ['config', 'unknownType', 'index-pattern']; + await findSuccess({ type: types }); - expect(firstUpdate).toMatchObject({ - if_seq_no: 100, - if_primary_term: 200, + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith( + mappings, + registry, + expect.objectContaining({ + type: ['config', 'index-pattern'], + }) + ); }); - expect(secondUpdate).toMatchObject({ - if_seq_no: 300, - if_primary_term: 400, + it(`filters out hidden types`, async () => { + const types = ['config', HIDDEN_TYPE, 'index-pattern']; + await findSuccess({ type: types }); + + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith( + mappings, + registry, + expect.objectContaining({ + type: ['config', 'index-pattern'], + }) + ); }); }); + }); - it('does not pass references if omitted', async () => { - const objects = [ - { - type: 'index-pattern', - id: `logstash-no-ref`, - attributes: { title: `Testing no-ref` }, - }, - ]; + describe('#get', () => { + const type = 'index-pattern'; + const id = 'logstash-*'; + const namespace = 'foo-namespace'; + + const getSuccess = async (type, id, options) => { + const response = getMockGetResponse({ type, id, namespace: options?.namespace }); + callAdminCluster.mockResolvedValue(response); + const result = await savedObjectsRepository.get(type, id, options); + expect(callAdminCluster).toHaveBeenCalledTimes(1); + return result; + }; - mockValidResponse(objects); + describe('cluster calls', () => { + it(`should use the ES get action`, async () => { + await getSuccess(type, id); + expectClusterCalls('get'); + }); - await savedObjectsRepository.bulkUpdate(objects); + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + await getSuccess(type, id, { namespace }); + expectClusterCallArgs({ id: `${namespace}:${type}:${id}` }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + await getSuccess(type, id); + expectClusterCallArgs({ id: `${type}:${id}` }); + }); - const [ - , - { - body: [, { doc: firstDoc }], - }, - ] = callAdminCluster.mock.calls[0]; + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + await getSuccess(NAMESPACE_AGNOSTIC_TYPE, id, { namespace }); + expectClusterCallArgs({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }); - expect(firstDoc).not.toMatchObject({ - references: [], + callAdminCluster.mockReset(); + await getSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); + expectClusterCallArgs({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }); }); }); - it('passes references if they are provided', async () => { - const objects = [ - generateSavedObject({ - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - }), - ]; + describe('errors', () => { + const expectNotFoundError = async (type, id, options) => { + await expect(savedObjectsRepository.get(type, id, options)).rejects.toThrowError( + createGenericNotFoundError(type, id) + ); + }; - mockValidResponse(objects); + it(`throws when type is invalid`, async () => { + await expectNotFoundError('unknownType', id); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); - await savedObjectsRepository.bulkUpdate(objects); + it(`throws when type is hidden`, async () => { + await expectNotFoundError(HIDDEN_TYPE, id); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`throws when ES is unable to find the document during get`, async () => { + callAdminCluster.mockResolvedValue({ found: false }); + await expectNotFoundError(type, id); + expectClusterCalls('get'); + }); - const [ - , - { - body: [, { doc }], - }, - ] = callAdminCluster.mock.calls[0]; + it(`throws when ES is unable to find the index during get`, async () => { + callAdminCluster.mockResolvedValue({ status: 404 }); + await expectNotFoundError(type, id); + expectClusterCalls('get'); + }); - expect(doc).toMatchObject({ - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], + it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { + const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + callAdminCluster.mockResolvedValue(response); + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + expectClusterCalls('get'); }); }); - it('passes empty references array if empty references array is provided', async () => { - const objects = [ - { - type: 'index-pattern', - id: `logstash-no-ref`, - attributes: { title: `Testing no-ref` }, - references: [], - }, - ]; - - mockValidResponse(objects); - - await savedObjectsRepository.bulkUpdate(objects); + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + migrator.runMigrations = jest.fn(async () => + expect(callAdminCluster).not.toHaveBeenCalled() + ); + await expect(getSuccess(type, id)).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + describe('returns', () => { + it(`formats the ES response`, async () => { + const result = await getSuccess(type, id); + expect(result).toEqual({ + id, + type, + updated_at: mockTimestamp, + version: mockVersion, + attributes: { + title: 'Testing', + }, + references: [], + }); + }); - const [ - , - { - body: [, { doc }], - }, - ] = callAdminCluster.mock.calls[0]; + it(`includes namespaces if type is multi-namespace`, async () => { + const result = await getSuccess(MULTI_NAMESPACE_TYPE, id); + expect(result).toMatchObject({ + namespaces: expect.any(Array), + }); + }); - expect(doc).toMatchObject({ - references: [], + it(`doesn't include namespaces if type is not multi-namespace`, async () => { + const result = await getSuccess(type, id); + expect(result).not.toMatchObject({ + namespaces: expect.anything(), + }); }); }); + }); - it('defaults to a refresh setting of `wait_for`', async () => { - const objects = [ - { - type: 'index-pattern', - id: `logstash-no-ref`, - attributes: { title: `Testing no-ref` }, - references: [], + describe('#incrementCounter', () => { + const type = 'config'; + const id = 'one'; + const field = 'buildNum'; + const namespace = 'foo-namespace'; + + const incrementCounterSuccess = async (type, id, field, options) => { + const isMultiNamespace = registry.isMultiNamespace(type); + if (isMultiNamespace) { + const response = getMockGetResponse({ type, id, namespace: options?.namespace }); + callAdminCluster.mockResolvedValueOnce(response); // this._callCluster('get', ...) + } + callAdminCluster.mockImplementation((method, params) => ({ + _id: params.id, + ...mockVersionProps, + _index: '.kibana', + get: { + found: true, + _source: { + type, + ...mockTimestampFields, + [type]: { + [field]: 8468, + defaultIndex: 'logstash-*', + }, + }, }, - ]; - - mockValidResponse(objects); + })); + const result = await savedObjectsRepository.incrementCounter(type, id, field, options); + expect(callAdminCluster).toHaveBeenCalledTimes(isMultiNamespace ? 2 : 1); + return result; + }; - await savedObjectsRepository.bulkUpdate(objects); + describe('cluster calls', () => { + it(`should use the ES update action if type is not multi-namespace`, async () => { + await incrementCounterSuccess(type, id, field, { namespace }); + expectClusterCalls('update'); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`should use the ES get action then update action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { + await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, field, { namespace }); + expectClusterCalls('get', 'update'); + }); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ refresh: 'wait_for' }); - }); + it(`defaults to a refresh setting of wait_for`, async () => { + await incrementCounterSuccess(type, id, field, { namespace }); + expectClusterCallArgs({ refresh: 'wait_for' }); + }); - it('accepts a custom refresh setting', async () => { - const objects = [ - { - type: 'index-pattern', - id: `logstash-no-ref`, - attributes: { title: `Testing no-ref` }, - references: [], - }, - ]; + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + await incrementCounterSuccess(type, id, field, { namespace, refresh }); + expectClusterCallArgs({ refresh }); + }); - mockValidResponse(objects); + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + await incrementCounterSuccess(type, id, field, { namespace }); + expectClusterCallArgs({ id: `${namespace}:${type}:${id}` }); + }); - await savedObjectsRepository.bulkUpdate(objects, { refresh: true }); + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + await incrementCounterSuccess(type, id, field); + expectClusterCallArgs({ id: `${type}:${id}` }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + await incrementCounterSuccess(NAMESPACE_AGNOSTIC_TYPE, id, field, { namespace }); + expectClusterCallArgs({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ refresh: true }); + callAdminCluster.mockReset(); + await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, field, { namespace }); + expectClusterCallArgs({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }); + }); }); - it(`prepends namespace to the id but doesn't add namespace to body when providing namespace for namespaced type`, async () => { - const objects = [generateSavedObject(), generateSavedObject()]; + describe('errors', () => { + const expectUnsupportedTypeError = async (type, id, field) => { + await expect(savedObjectsRepository.incrementCounter(type, id, field)).rejects.toThrowError( + createUnsupportedTypeError(type) + ); + }; - mockValidResponse(objects); + it(`throws when type is not a string`, async () => { + const test = async type => { + await expect( + savedObjectsRepository.incrementCounter(type, id, field) + ).rejects.toThrowError(`"type" argument must be a string`); + expect(callAdminCluster).not.toHaveBeenCalled(); + }; - await savedObjectsRepository.bulkUpdate(objects, { - namespace: 'foo-namespace', + await test(null); + await test(42); + await test(false); + await test({}); }); - const [ - , - { - body: [ - { update: firstUpdate }, - { doc: firstUpdateDoc }, - { update: secondUpdate }, - { doc: secondUpdateDoc }, - ], - }, - ] = callAdminCluster.mock.calls[0]; + it(`throws when counterFieldName is not a string`, async () => { + const test = async field => { + await expect( + savedObjectsRepository.incrementCounter(type, id, field) + ).rejects.toThrowError(`"counterFieldName" argument must be a string`); + expect(callAdminCluster).not.toHaveBeenCalled(); + }; - expect(firstUpdate).toMatchObject({ - _id: 'foo-namespace:index-pattern:logstash-1', - _index: '.kibana-test', + await test(null); + await test(42); + await test(false); + await test({}); }); - expect(firstUpdateDoc).toMatchObject({ - updated_at: mockTimestamp, - 'index-pattern': { title: 'Testing 1' }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], + it(`throws when type is invalid`, async () => { + await expectUnsupportedTypeError('unknownType', id, field); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - expect(secondUpdate).toMatchObject({ - _id: 'foo-namespace:index-pattern:logstash-2', - _index: '.kibana-test', + it(`throws when type is hidden`, async () => { + await expectUnsupportedTypeError(HIDDEN_TYPE, id, field); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - expect(secondUpdateDoc).toMatchObject({ - updated_at: mockTimestamp, - 'index-pattern': { title: 'Testing 2' }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], + it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { + const response = getMockGetResponse({ + type: MULTI_NAMESPACE_TYPE, + id, + namespace: 'bar-namespace', + }); + callAdminCluster.mockResolvedValue(response); // this._callCluster('get', ...) + await expect( + savedObjectsRepository.incrementCounter(MULTI_NAMESPACE_TYPE, id, field, { namespace }) + ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); + expectClusterCalls('get'); }); }); - it(`doesn't prepend namespace to the id or add namespace property when providing no namespace for namespaced type`, async () => { - const objects = [generateSavedObject(), generateSavedObject()]; - - mockValidResponse(objects); + describe('migration', () => { + beforeEach(() => { + migrator.migrateDocument.mockImplementation(mockMigrateDocument); + }); - await savedObjectsRepository.bulkUpdate(objects); + it(`waits until migrations are complete before proceeding`, async () => { + migrator.runMigrations = jest.fn(async () => + expect(callAdminCluster).not.toHaveBeenCalled() + ); + await expect( + incrementCounterSuccess(type, id, field, { namespace }) + ).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + }); - const [ - , - { - body: [ - { update: firstUpdate }, - { doc: firstUpdateDoc }, - { update: secondUpdate }, - { doc: secondUpdateDoc }, - ], - }, - ] = callAdminCluster.mock.calls[0]; + it(`migrates a document and serializes the migrated doc`, async () => { + const migrationVersion = mockMigrationVersion; + await incrementCounterSuccess(type, id, field, { migrationVersion }); + const attributes = { buildNum: 1 }; // this is added by the incrementCounter function + const doc = { type, id, attributes, migrationVersion, ...mockTimestampFields }; + expectMigrationArgs(doc); - expect(firstUpdate).toMatchObject({ - _id: 'index-pattern:logstash-1', - _index: '.kibana-test', + const migratedDoc = migrator.migrateDocument(doc); + expect(serializer.savedObjectToRaw).toHaveBeenLastCalledWith(migratedDoc); }); + }); - expect(firstUpdateDoc).toMatchObject({ - updated_at: mockTimestamp, - 'index-pattern': { title: 'Testing 1' }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', + describe('returns', () => { + it(`formats the ES response`, async () => { + callAdminCluster.mockImplementation((method, params) => ({ + _id: params.id, + ...mockVersionProps, + _index: '.kibana', + get: { + found: true, + _source: { + type: 'config', + ...mockTimestampFields, + config: { + buildNum: 8468, + defaultIndex: 'logstash-*', + }, + }, }, - ], - }); - - expect(secondUpdate).toMatchObject({ - _id: 'index-pattern:logstash-2', - _index: '.kibana-test', - }); + })); - expect(secondUpdateDoc).toMatchObject({ - updated_at: mockTimestamp, - 'index-pattern': { title: 'Testing 2' }, - references: [ + const response = await savedObjectsRepository.incrementCounter( + 'config', + '6.0.0-alpha1', + 'buildNum', { - name: 'ref_0', - type: 'test', - id: '1', + namespace: 'foo-namespace', + } + ); + + expect(response).toEqual({ + type: 'config', + id: '6.0.0-alpha1', + ...mockTimestampFields, + version: mockVersion, + attributes: { + buildNum: 8468, + defaultIndex: 'logstash-*', }, - ], + }); }); }); + }); - it(`doesn't prepend namespace to the id or add namespace property when providing namespace for namespace agnostic type`, async () => { - const objects = [ - generateSavedObject({ - type: 'globaltype', - id: 'foo', - namespace: 'foo-namespace', - }), - ]; + describe('#deleteFromNamespaces', () => { + const id = 'some-id'; + const type = MULTI_NAMESPACE_TYPE; + const namespace1 = 'default'; + const namespace2 = 'foo-namespace'; + const namespace3 = 'bar-namespace'; + + const mockGetResponse = (type, id, namespaces) => { + // mock a document that exists in two namespaces + const mockResponse = getMockGetResponse({ type, id }); + mockResponse._source.namespaces = namespaces; + callAdminCluster.mockResolvedValueOnce(mockResponse); // this._callCluster('get', ...) + }; + + const deleteFromNamespacesSuccess = async ( + type, + id, + namespaces, + currentNamespaces, + options + ) => { + mockGetResponse(type, id, currentNamespaces); // this._callCluster('get', ...) + const isDelete = currentNamespaces.every(namespace => namespaces.includes(namespace)); + callAdminCluster.mockResolvedValue({ + _id: `${type}:${id}`, + ...mockVersionProps, + result: isDelete ? 'deleted' : 'updated', + }); // this._writeToCluster('delete', ...) *or* this._writeToCluster('update', ...) + const result = await savedObjectsRepository.deleteFromNamespaces( + type, + id, + namespaces, + options + ); + expect(callAdminCluster).toHaveBeenCalledTimes(2); + return result; + }; - mockValidResponse(objects); + describe('cluster calls', () => { + describe('delete action', () => { + const deleteFromNamespacesSuccessDelete = async (expectFn, options, _type = type) => { + const test = async namespaces => { + await deleteFromNamespacesSuccess(_type, id, namespaces, namespaces, options); + expectFn(); + callAdminCluster.mockReset(); + }; + await test([namespace1]); + await test([namespace1, namespace2]); + }; + + it(`should use ES get action then delete action if the object has no namespaces remaining`, async () => { + const expectFn = () => expectClusterCalls('get', 'delete'); + await deleteFromNamespacesSuccessDelete(expectFn); + }); - await savedObjectsRepository.bulkUpdate(objects); + it(`formats the ES requests`, async () => { + const expectFn = () => { + expectClusterCallArgs({ id: `${type}:${id}` }, 1); + const versionProperties = { + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + expectClusterCallArgs({ id: `${type}:${id}`, ...versionProperties }, 2); + }; + await deleteFromNamespacesSuccessDelete(expectFn); + }); - const [ - , - { - body: [{ update }, { doc }], - }, - ] = callAdminCluster.mock.calls[0]; + it(`defaults to a refresh setting of wait_for`, async () => { + await deleteFromNamespacesSuccessDelete(() => + expectClusterCallArgs({ refresh: 'wait_for' }, 2) + ); + }); - expect(update).toMatchObject({ - _id: 'globaltype:foo', - _index: '.kibana-test', - }); + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + const expectFn = () => expectClusterCallArgs({ refresh }, 2); + await deleteFromNamespacesSuccessDelete(expectFn, { refresh }); + }); - expect(doc).toMatchObject({ - updated_at: mockTimestamp, - globaltype: { title: 'Testing 1' }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], + it(`should use default index`, async () => { + const expectFn = () => expectClusterCallArgs({ index: '.kibana-test' }, 2); + await deleteFromNamespacesSuccessDelete(expectFn); + }); + + it(`should use custom index`, async () => { + const expectFn = () => expectClusterCallArgs({ index: 'custom' }, 2); + await deleteFromNamespacesSuccessDelete(expectFn, {}, MULTI_NAMESPACE_CUSTOM_INDEX_TYPE); + }); }); - }); - }); - describe('#incrementCounter', () => { - beforeEach(() => { - callAdminCluster.mockImplementation((method, params) => ({ - _id: params.id, - ...mockVersionProps, - _index: '.kibana', - get: { - found: true, - _source: { - type: 'config', - ...mockTimestampFields, - config: { - buildNum: 8468, - defaultIndex: 'logstash-*', - }, - }, - }, - })); - }); + describe('update action', () => { + const deleteFromNamespacesSuccessUpdate = async (expectFn, options, _type = type) => { + const test = async remaining => { + const currentNamespaces = [namespace1].concat(remaining); + await deleteFromNamespacesSuccess(_type, id, [namespace1], currentNamespaces, options); + expectFn(); + callAdminCluster.mockReset(); + }; + await test([namespace2]); + await test([namespace2, namespace3]); + }; + + it(`should use ES get action then update action if the object has one or more namespaces remaining`, async () => { + await deleteFromNamespacesSuccessUpdate(() => expectClusterCalls('get', 'update')); + }); - it('formats Elasticsearch response', async () => { - callAdminCluster.mockImplementation((method, params) => ({ - _id: params.id, - ...mockVersionProps, - _index: '.kibana', - get: { - found: true, - _source: { - type: 'config', - ...mockTimestampFields, - config: { - buildNum: 8468, - defaultIndex: 'logstash-*', - }, - }, - }, - })); + it(`formats the ES requests`, async () => { + let ctr = 0; + const expectFn = () => { + expectClusterCallArgs({ id: `${type}:${id}` }, 1); + const namespaces = ctr++ === 0 ? [namespace2] : [namespace2, namespace3]; + const versionProperties = { + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + expectClusterCallArgs( + { + id: `${type}:${id}`, + ...versionProperties, + body: { doc: { ...mockTimestampFields, namespaces } }, + }, + 2 + ); + }; + await deleteFromNamespacesSuccessUpdate(expectFn); + }); - const response = await savedObjectsRepository.incrementCounter( - 'config', - '6.0.0-alpha1', - 'buildNum', - { - namespace: 'foo-namespace', - } - ); + it(`defaults to a refresh setting of wait_for`, async () => { + const expectFn = () => expectClusterCallArgs({ refresh: 'wait_for' }, 2); + await deleteFromNamespacesSuccessUpdate(expectFn); + }); - expect(response).toEqual({ - type: 'config', - id: '6.0.0-alpha1', - ...mockTimestampFields, - version: mockVersion, - attributes: { - buildNum: 8468, - defaultIndex: 'logstash-*', - }, + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + const expectFn = () => expectClusterCallArgs({ refresh }, 2); + await deleteFromNamespacesSuccessUpdate(expectFn, { refresh }); + }); + + it(`should use default index`, async () => { + const expectFn = () => expectClusterCallArgs({ index: '.kibana-test' }, 2); + await deleteFromNamespacesSuccessUpdate(expectFn); + }); + + it(`should use custom index`, async () => { + const expectFn = () => expectClusterCallArgs({ index: 'custom' }, 2); + await deleteFromNamespacesSuccessUpdate(expectFn, {}, MULTI_NAMESPACE_CUSTOM_INDEX_TYPE); + }); }); }); - it('migrates the doc if an upsert is required', async () => { - migrator.migrateDocument = doc => { - doc.attributes.buildNum = 42; - doc.migrationVersion = { foo: '2.3.4' }; - doc.references = [{ name: 'search_0', type: 'search', id: '123' }]; - return doc; + describe('errors', () => { + const expectNotFoundError = async (type, id, namespaces, options) => { + await expect( + savedObjectsRepository.deleteFromNamespaces(type, id, namespaces, options) + ).rejects.toThrowError(createGenericNotFoundError(type, id)); + }; + const expectBadRequestError = async (type, id, namespaces, message) => { + await expect( + savedObjectsRepository.deleteFromNamespaces(type, id, namespaces) + ).rejects.toThrowError(createBadRequestError(message)); }; - await savedObjectsRepository.incrementCounter('config', 'doesnotexist', 'buildNum', { - namespace: 'foo-namespace', + it(`throws when type is invalid`, async () => { + await expectNotFoundError('unknownType', id, [namespace1, namespace2]); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - body: { - upsert: { - config: { buildNum: 42 }, - migrationVersion: { foo: '2.3.4' }, - type: 'config', - ...mockTimestampFields, - references: [{ name: 'search_0', type: 'search', id: '123' }], - }, - }, + it(`throws when type is hidden`, async () => { + await expectNotFoundError(HIDDEN_TYPE, id, [namespace1, namespace2]); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - }); - it('defaults to a refresh setting of `wait_for`', async () => { - await savedObjectsRepository.incrementCounter('config', 'doesnotexist', 'buildNum', { - namespace: 'foo-namespace', + it(`throws when type is not namespace-agnostic`, async () => { + const test = async type => { + const message = `${type} doesn't support multiple namespaces`; + await expectBadRequestError(type, id, [namespace1, namespace2], message); + expect(callAdminCluster).not.toHaveBeenCalled(); + }; + await test('index-pattern'); + await test(NAMESPACE_AGNOSTIC_TYPE); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: 'wait_for', + it(`throws when namespaces is an empty array`, async () => { + const test = async namespaces => { + const message = 'namespaces must be a non-empty array of strings'; + await expectBadRequestError(type, id, namespaces, message); + expect(callAdminCluster).not.toHaveBeenCalled(); + }; + await test([]); }); - }); - it('accepts a custom refresh setting', async () => { - await savedObjectsRepository.incrementCounter('config', 'doesnotexist', 'buildNum', { - namespace: 'foo-namespace', - refresh: true, + it(`throws when ES is unable to find the document during get`, async () => { + callAdminCluster.mockResolvedValue({ found: false }); // this._callCluster('get', ...) + await expectNotFoundError(type, id, [namespace1, namespace2]); + expectClusterCalls('get'); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: true, + it(`throws when ES is unable to find the index during get`, async () => { + callAdminCluster.mockResolvedValue({ status: 404 }); // this._callCluster('get', ...) + await expectNotFoundError(type, id, [namespace1, namespace2]); + expectClusterCalls('get'); }); - }); - it(`prepends namespace to the id but doesn't add namespace to body when providing namespace for namespaced type`, async () => { - await savedObjectsRepository.incrementCounter('config', '6.0.0-alpha1', 'buildNum', { - namespace: 'foo-namespace', + it(`throws when the document exists, but not in this namespace`, async () => { + mockGetResponse(type, id, [namespace1]); // this._callCluster('get', ...) + await expectNotFoundError(type, id, [namespace1], { namespace: 'some-other-namespace' }); + expectClusterCalls('get'); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`throws when ES is unable to find the document during delete`, async () => { + mockGetResponse(type, id, [namespace1]); // this._callCluster('get', ...) + callAdminCluster.mockResolvedValue({ result: 'not_found' }); // this._writeToCluster('delete', ...) + await expectNotFoundError(type, id, [namespace1]); + expectClusterCalls('get', 'delete'); + }); + + it(`throws when ES is unable to find the index during delete`, async () => { + mockGetResponse(type, id, [namespace1]); // this._callCluster('get', ...) + callAdminCluster.mockResolvedValue({ error: { type: 'index_not_found_exception' } }); // this._writeToCluster('delete', ...) + await expectNotFoundError(type, id, [namespace1]); + expectClusterCalls('get', 'delete'); + }); + + it(`throws when ES returns an unexpected response`, async () => { + mockGetResponse(type, id, [namespace1]); // this._callCluster('get', ...) + callAdminCluster.mockResolvedValue({ result: 'something unexpected' }); // this._writeToCluster('delete', ...) + await expect( + savedObjectsRepository.deleteFromNamespaces(type, id, [namespace1]) + ).rejects.toThrowError('Unexpected Elasticsearch DELETE response'); + expectClusterCalls('get', 'delete'); + }); + + it(`throws when ES is unable to find the document during update`, async () => { + mockGetResponse(type, id, [namespace1, namespace2]); // this._callCluster('get', ...) + callAdminCluster.mockResolvedValue({ status: 404 }); // this._writeToCluster('update', ...) + await expectNotFoundError(type, id, [namespace1]); + expectClusterCalls('get', 'update'); + }); + }); - const requestDoc = callAdminCluster.mock.calls[0][1]; - expect(requestDoc.id).toBe('foo-namespace:config:6.0.0-alpha1'); - expect(requestDoc.body.script.params.type).toBe('config'); - expect(requestDoc.body.upsert.type).toBe('config'); - expect(requestDoc).toHaveProperty('body.upsert.config'); + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + let callAdminClusterCount = 0; + migrator.runMigrations = jest.fn(async () => + // runMigrations should resolve before callAdminCluster is initiated + expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) + ); + await expect( + deleteFromNamespacesSuccess(type, id, [namespace1], [namespace1]) + ).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveReturnedTimes(2); + }); }); - it(`doesn't prepend namespace to the id or add namespace property when providing no namespace for namespaced type`, async () => { - await savedObjectsRepository.incrementCounter('config', '6.0.0-alpha1', 'buildNum'); + describe('returns', () => { + it(`returns an empty object on success (delete)`, async () => { + const test = async namespaces => { + const result = await deleteFromNamespacesSuccess(type, id, namespaces, namespaces); + expect(result).toEqual({}); + callAdminCluster.mockReset(); + }; + await test([namespace1]); + await test([namespace1, namespace2]); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`returns an empty object on success (update)`, async () => { + const test = async remaining => { + const currentNamespaces = [namespace1].concat(remaining); + const result = await deleteFromNamespacesSuccess( + type, + id, + [namespace1], + currentNamespaces + ); + expect(result).toEqual({}); + callAdminCluster.mockReset(); + }; + await test([namespace2]); + await test([namespace2, namespace3]); + }); - const requestDoc = callAdminCluster.mock.calls[0][1]; - expect(requestDoc.id).toBe('config:6.0.0-alpha1'); - expect(requestDoc.body.script.params.type).toBe('config'); - expect(requestDoc.body.upsert.type).toBe('config'); - expect(requestDoc).toHaveProperty('body.upsert.config'); + it(`succeeds when the document doesn't exist in all of the targeted namespaces`, async () => { + const namespaces = [namespace2]; + const currentNamespaces = [namespace1]; + const result = await deleteFromNamespacesSuccess(type, id, namespaces, currentNamespaces); + expect(result).toEqual({}); + }); }); + }); - it(`doesn't prepend namespace to the id or add namespace property when providing namespace for namespace agnostic type`, async () => { - callAdminCluster.mockImplementation((method, params) => ({ - _id: params.id, + describe('#update', () => { + const id = 'logstash-*'; + const type = 'index-pattern'; + const attributes = { title: 'Testing' }; + const namespace = 'foo-namespace'; + const references = [ + { + name: 'ref_0', + type: 'test', + id: '1', + }, + ]; + + const updateSuccess = async (type, id, attributes, options) => { + if (registry.isMultiNamespace(type)) { + const mockGetResponse = getMockGetResponse({ type, id, namespace: options?.namespace }); + callAdminCluster.mockResolvedValueOnce(mockGetResponse); // this._callCluster('get', ...) + } + callAdminCluster.mockResolvedValue({ + _id: `${type}:${id}`, ...mockVersionProps, - _index: '.kibana', - get: { - found: true, - _source: { - type: 'globaltype', - ...mockTimestampFields, - globaltype: { - counter: 1, - }, - }, - }, - })); + result: 'updated', + ...(registry.isMultiNamespace(type) && { + // don't need the rest of the source for test purposes, just the namespaces attribute + get: { _source: { namespaces: [options?.namespace ?? 'default'] } }, + }), + }); // this._writeToCluster('update', ...) + const result = await savedObjectsRepository.update(type, id, attributes, options); + expect(callAdminCluster).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 2 : 1); + return result; + }; - await savedObjectsRepository.incrementCounter('globaltype', 'foo', 'counter', { - namespace: 'foo-namespace', + describe('cluster calls', () => { + it(`should use the ES get action then update action when type is multi-namespace`, async () => { + await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + expectClusterCalls('get', 'update'); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`should use the ES update action when type is not multi-namespace`, async () => { + await updateSuccess(type, id, attributes); + expectClusterCalls('update'); + }); - const requestDoc = callAdminCluster.mock.calls[0][1]; - expect(requestDoc.id).toBe('globaltype:foo'); - expect(requestDoc.body.script.params.type).toBe('globaltype'); - expect(requestDoc.body.upsert.type).toBe('globaltype'); - expect(requestDoc).toHaveProperty('body.upsert.globaltype'); - }); + it(`defaults to no references array`, async () => { + await updateSuccess(type, id, attributes); + expectClusterCallArgs({ + body: { doc: expect.not.objectContaining({ references: expect.anything() }) }, + }); + }); - it('should assert that the "type" and "counterFieldName" arguments are strings', () => { - expect.assertions(6); - - expect( - savedObjectsRepository.incrementCounter(null, '6.0.0-alpha1', 'buildNum', { - namespace: 'foo-namespace', - }) - ).rejects.toEqual(new Error('"type" argument must be a string')); - - expect( - savedObjectsRepository.incrementCounter(42, '6.0.0-alpha1', 'buildNum', { - namespace: 'foo-namespace', - }) - ).rejects.toEqual(new Error('"type" argument must be a string')); - - expect( - savedObjectsRepository.incrementCounter({}, '6.0.0-alpha1', 'buildNum', { - namespace: 'foo-namespace', - }) - ).rejects.toEqual(new Error('"type" argument must be a string')); - - expect( - savedObjectsRepository.incrementCounter('config', '6.0.0-alpha1', null, { - namespace: 'foo-namespace', - }) - ).rejects.toEqual(new Error('"counterFieldName" argument must be a string')); - - expect( - savedObjectsRepository.incrementCounter('config', '6.0.0-alpha1', 42, { - namespace: 'foo-namespace', - }) - ).rejects.toEqual(new Error('"counterFieldName" argument must be a string')); - - expect( - savedObjectsRepository.incrementCounter( - 'config', - '6.0.0-alpha1', - {}, - { - namespace: 'foo-namespace', - } - ) - ).rejects.toEqual(new Error('"counterFieldName" argument must be a string')); - }); - }); + it(`accepts custom references array`, async () => { + const test = async references => { + await updateSuccess(type, id, attributes, { references }); + expectClusterCallArgs({ + body: { doc: expect.objectContaining({ references }) }, + }); + callAdminCluster.mockReset(); + }; + await test(references); + await test(['string']); + await test([]); + }); + + it(`doesn't accept custom references if not an array`, async () => { + const test = async references => { + await updateSuccess(type, id, attributes, { references }); + expectClusterCallArgs({ + body: { doc: expect.not.objectContaining({ references: expect.anything() }) }, + }); + callAdminCluster.mockReset(); + }; + await test('string'); + await test(123); + await test(true); + await test(null); + }); + + it(`defaults to a refresh setting of wait_for`, async () => { + await updateSuccess(type, id, { foo: 'bar' }); + expectClusterCallArgs({ refresh: 'wait_for' }); + }); + + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + await updateSuccess(type, id, { foo: 'bar' }, { refresh }); + expectClusterCallArgs({ refresh }); + }); + + it(`defaults to the version of the existing document when type is multi-namespace`, async () => { + await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes, { references }); + const versionProperties = { + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + expectClusterCallArgs(versionProperties, 2); + }); + + it(`accepts version`, async () => { + await updateSuccess(type, id, attributes, { + version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }), + }); + expectClusterCallArgs({ if_seq_no: 100, if_primary_term: 200 }); + }); - describe('types on custom index', () => { - it("should error when attempting to 'update' an unsupported type", async () => { - await expect( - savedObjectsRepository.update('hiddenType', 'bogus', { title: 'some title' }) - ).rejects.toEqual(new Error('Saved object [hiddenType/bogus] not found')); - }); - }); + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + await updateSuccess(type, id, attributes, { namespace }); + expectClusterCallArgs({ id: expect.stringMatching(`${namespace}:${type}:${id}`) }); + }); - describe('unsupported types', () => { - it("should error when attempting to 'update' an unsupported type", async () => { - await expect( - savedObjectsRepository.update('hiddenType', 'bogus', { title: 'some title' }) - ).rejects.toEqual(new Error('Saved object [hiddenType/bogus] not found')); - }); + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + await updateSuccess(type, id, attributes, { references }); + expectClusterCallArgs({ id: expect.stringMatching(`${type}:${id}`) }); + }); - it("should error when attempting to 'get' an unsupported type", async () => { - await expect(savedObjectsRepository.get('hiddenType')).rejects.toEqual( - new Error('Not Found') - ); - }); + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + await updateSuccess(NAMESPACE_AGNOSTIC_TYPE, id, attributes, { namespace }); + expectClusterCallArgs({ id: expect.stringMatching(`${NAMESPACE_AGNOSTIC_TYPE}:${id}`) }); - it("should return an error object when attempting to 'create' an unsupported type", async () => { - await expect( - savedObjectsRepository.create('hiddenType', { title: 'some title' }) - ).rejects.toEqual(new Error("Unsupported saved object type: 'hiddenType': Bad Request")); - }); + callAdminCluster.mockReset(); + await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes, { namespace }); + expectClusterCallArgs({ id: expect.stringMatching(`${MULTI_NAMESPACE_TYPE}:${id}`) }, 2); + }); - it("should not return hidden saved ojects when attempting to 'find' support and unsupported types", async () => { - callAdminCluster.mockReturnValue({ - hits: { - total: 1, - hits: [ - { - _id: 'one', - _source: { - updated_at: mockTimestamp, - type: 'config', - }, - references: [], - }, - ], - }, + it(`includes _sourceIncludes when type is multi-namespace`, async () => { + await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + expectClusterCallArgs({ _sourceIncludes: ['namespaces'] }, 2); }); - const results = await savedObjectsRepository.find({ type: ['hiddenType', 'config'] }); - expect(results).toEqual({ - total: 1, - saved_objects: [ - { - id: 'one', - references: [], - type: 'config', - updated_at: mockTimestamp, - }, - ], - page: 1, - per_page: 20, + + it(`doesn't include _sourceIncludes when type is not multi-namespace`, async () => { + await updateSuccess(type, id, attributes); + expect(callAdminCluster).toHaveBeenLastCalledWith( + expect.any(String), + expect.not.objectContaining({ + _sourceIncludes: expect.anything(), + }) + ); }); }); - it("should return empty results when attempting to 'find' an unsupported type", async () => { - callAdminCluster.mockReturnValue({ - hits: { - total: 0, - hits: [], - }, + describe('errors', () => { + const expectNotFoundError = async (type, id) => { + await expect(savedObjectsRepository.update(type, id)).rejects.toThrowError( + createGenericNotFoundError(type, id) + ); + }; + + it(`throws when type is invalid`, async () => { + await expectNotFoundError('unknownType', id); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - const results = await savedObjectsRepository.find({ type: 'hiddenType' }); - expect(results).toEqual({ - total: 0, - saved_objects: [], - page: 1, - per_page: 20, + + it(`throws when type is hidden`, async () => { + await expectNotFoundError(HIDDEN_TYPE, id); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - }); - it("should return empty results when attempting to 'find' more than one unsupported types", async () => { - const findParams = { type: ['hiddenType', 'hiddenType2'] }; - callAdminCluster.mockReturnValue({ - status: 200, - hits: { - total: 0, - hits: [], - }, + it(`throws when ES is unable to find the document during get`, async () => { + callAdminCluster.mockResolvedValue({ found: false }); // this._callCluster('get', ...) + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + expectClusterCalls('get'); }); - const results = await savedObjectsRepository.find(findParams); - expect(results).toEqual({ - total: 0, - saved_objects: [], - page: 1, - per_page: 20, + + it(`throws when ES is unable to find the index during get`, async () => { + callAdminCluster.mockResolvedValue({ status: 404 }); // this._callCluster('get', ...) + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + expectClusterCalls('get'); }); - }); - it("should error when attempting to 'delete' hidden types", async () => { - await expect(savedObjectsRepository.delete('hiddenType')).rejects.toEqual( - new Error('Not Found') - ); + it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { + const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + callAdminCluster.mockResolvedValue(response); // this._callCluster('get', ...) + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + expectClusterCalls('get'); + }); + + it(`throws when ES is unable to find the document during update`, async () => { + callAdminCluster.mockResolvedValue({ status: 404 }); // this._writeToCluster('update', ...) + await expectNotFoundError(type, id); + expectClusterCalls('update'); + }); }); - it("should error when attempting to 'bulkCreate' an unsupported type", async () => { - callAdminCluster.mockReturnValue({ - items: [ - { - index: { - _id: 'one', - _seq_no: 1, - _primary_term: 1, - _type: 'config', - attributes: { - title: 'Test One', - }, - }, - }, - ], - }); - const results = await savedObjectsRepository.bulkCreate([ - { type: 'config', id: 'one', attributes: { title: 'Test One' } }, - { type: 'hiddenType', id: 'two', attributes: { title: 'Test Two' } }, - ]); - expect(results).toEqual({ - saved_objects: [ - { - type: 'config', - id: 'one', - attributes: { title: 'Test One' }, - references: [], - version: 'WzEsMV0=', - updated_at: mockTimestamp, - }, - { - error: { - error: 'Bad Request', - message: "Unsupported saved object type: 'hiddenType': Bad Request", - statusCode: 400, - }, - id: 'two', - type: 'hiddenType', - }, - ], + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + let callAdminClusterCount = 0; + migrator.runMigrations = jest.fn(async () => + // runMigrations should resolve before callAdminCluster is initiated + expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) + ); + await expect(updateSuccess(type, id, attributes)).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveReturnedTimes(1); }); }); - it("should error when attempting to 'incrementCounter' for an unsupported type", async () => { - await expect( - savedObjectsRepository.incrementCounter('hiddenType', 'doesntmatter', 'fieldArg') - ).rejects.toEqual(new Error("Unsupported saved object type: 'hiddenType': Bad Request")); + describe('returns', () => { + it(`returns _seq_no and _primary_term encoded as version`, async () => { + const result = await updateSuccess(type, id, attributes, { + namespace, + references, + }); + expect(result).toEqual({ + id, + type, + ...mockTimestampFields, + version: mockVersion, + attributes, + references, + }); + }); + + it(`includes namespaces if type is multi-namespace`, async () => { + const result = await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + expect(result).toMatchObject({ + namespaces: expect.any(Array), + }); + }); + + it(`doesn't include namespaces if type is not multi-namespace`, async () => { + const result = await updateSuccess(type, id, attributes); + expect(result).not.toMatchObject({ + namespaces: expect.anything(), + }); + }); }); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 72a7867854b60..5f17c11792763 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -45,6 +45,8 @@ import { SavedObjectsBulkUpdateObject, SavedObjectsBulkUpdateOptions, SavedObjectsDeleteOptions, + SavedObjectsAddToNamespacesOptions, + SavedObjectsDeleteFromNamespacesOptions, } from '../saved_objects_client'; import { SavedObject, @@ -60,20 +62,12 @@ import { validateConvertFilterToKueryNode } from './filter_utils'; // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -type Left = { - tag: 'Left'; - error: T; -}; +type Left = { tag: 'Left'; error: Record }; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -type Right = { - tag: 'Right'; - value: T; -}; - -type Either = Left | Right; -const isLeft = (either: Either): either is Left => { - return either.tag === 'Left'; -}; +type Right = { tag: 'Right'; value: Record }; +type Either = Left | Right; +const isLeft = (either: Either): either is Left => either.tag === 'Left'; +const isRight = (either: Either): either is Right => either.tag === 'Right'; export interface SavedObjectsRepositoryOptions { index: string; @@ -220,8 +214,8 @@ export class SavedObjectsRepository { const { id, migrationVersion, - overwrite = false, namespace, + overwrite = false, references = [], refresh = DEFAULT_REFRESH_SETTING, } = options; @@ -230,22 +224,36 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } - const method = id && !overwrite ? 'create' : 'index'; const time = this._getCurrentTime(); + let savedObjectNamespace; + let savedObjectNamespaces: string[] | undefined; + + if (this._registry.isSingleNamespace(type) && namespace) { + savedObjectNamespace = namespace; + } else if (this._registry.isMultiNamespace(type)) { + if (id && overwrite) { + // we will overwrite a multi-namespace saved object if it exists; if that happens, ensure we preserve its included namespaces + savedObjectNamespaces = await this.preflightGetNamespaces(type, id, namespace); + } else { + savedObjectNamespaces = getSavedObjectNamespaces(namespace); + } + } try { const migrated = this._migrator.migrateDocument({ id, type, - namespace, + ...(savedObjectNamespace && { namespace: savedObjectNamespace }), + ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), attributes, migrationVersion, updated_at: time, - references, + ...(Array.isArray(references) && { references }), }); const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); + const method = id && overwrite ? 'index' : 'create'; const response = await this._writeToCluster(method, { id: raw._id, index: this.getIndexForType(type), @@ -282,10 +290,9 @@ export class SavedObjectsRepository { ): Promise> { const { namespace, overwrite = false, refresh = DEFAULT_REFRESH_SETTING } = options; const time = this._getCurrentTime(); - const bulkCreateParams: object[] = []; - let requestIndexCounter = 0; - const expectedResults: Array> = objects.map(object => { + let bulkGetRequestIndexCounter = 0; + const expectedResults: Either[] = objects.map(object => { if (!this._allowedTypes.includes(object.type)) { return { tag: 'Left' as 'Left', @@ -297,9 +304,73 @@ export class SavedObjectsRepository { }; } - const method = object.id && !overwrite ? 'create' : 'index'; + const method = object.id && overwrite ? 'index' : 'create'; + const requiresNamespacesCheck = + method === 'index' && this._registry.isMultiNamespace(object.type); + + return { + tag: 'Right' as 'Right', + value: { + method, + object, + ...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), + }, + }; + }); + + const bulkGetDocs = expectedResults + .filter(isRight) + .filter(({ value }) => value.esRequestIndex !== undefined) + .map(({ value: { object: { type, id } } }) => ({ + _id: this._serializer.generateRawId(namespace, type, id), + _index: this.getIndexForType(type), + _source: ['type', 'namespaces'], + })); + const bulkGetResponse = bulkGetDocs.length + ? await this._callCluster('mget', { + body: { + docs: bulkGetDocs, + }, + ignore: [404], + }) + : undefined; + + let bulkRequestIndexCounter = 0; + const bulkCreateParams: object[] = []; + const expectedBulkResults: Either[] = expectedResults.map(expectedBulkGetResult => { + if (isLeft(expectedBulkGetResult)) { + return expectedBulkGetResult; + } + + let savedObjectNamespace; + let savedObjectNamespaces; + const { esRequestIndex, object, method } = expectedBulkGetResult.value; + if (esRequestIndex !== undefined) { + const indexFound = bulkGetResponse.status !== 404; + const actualResult = indexFound ? bulkGetResponse.docs[esRequestIndex] : undefined; + const docFound = indexFound && actualResult.found === true; + if (docFound && !this.rawDocExistsInNamespace(actualResult, namespace)) { + const { id, type } = object; + return { + tag: 'Left' as 'Left', + error: { + id, + type, + error: SavedObjectsErrorHelpers.createConflictError(type, id).output.payload, + }, + }; + } + savedObjectNamespaces = getSavedObjectNamespaces(namespace, docFound && actualResult); + } else { + if (this._registry.isSingleNamespace(object.type)) { + savedObjectNamespace = namespace; + } else if (this._registry.isMultiNamespace(object.type)) { + savedObjectNamespaces = getSavedObjectNamespaces(namespace); + } + } + const expectedResult = { - esRequestIndex: requestIndexCounter++, + esRequestIndex: bulkRequestIndexCounter++, requestedId: object.id, rawMigratedDoc: this._serializer.savedObjectToRaw( this._migrator.migrateDocument({ @@ -307,7 +378,8 @@ export class SavedObjectsRepository { type: object.type, attributes: object.attributes, migrationVersion: object.migrationVersion, - namespace, + ...(savedObjectNamespace && { namespace: savedObjectNamespace }), + ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), updated_at: time, references: object.references || [], }) as SavedObjectSanitizedDoc @@ -327,19 +399,21 @@ export class SavedObjectsRepository { return { tag: 'Right' as 'Right', value: expectedResult }; }); - const esResponse = await this._writeToCluster('bulk', { - refresh, - body: bulkCreateParams, - }); + const bulkResponse = bulkCreateParams.length + ? await this._writeToCluster('bulk', { + refresh, + body: bulkCreateParams, + }) + : undefined; return { - saved_objects: expectedResults.map(expectedResult => { + saved_objects: expectedBulkResults.map(expectedResult => { if (isLeft(expectedResult)) { - return expectedResult.error; + return expectedResult.error as any; } const { requestedId, rawMigratedDoc, esRequestIndex } = expectedResult.value; - const response = esResponse.items[esRequestIndex]; + const response = bulkResponse.items[esRequestIndex]; const { error, _id: responseId, @@ -348,7 +422,7 @@ export class SavedObjectsRepository { } = Object.values(response)[0] as any; const { - _source: { type, [type]: attributes, references = [] }, + _source: { type, [type]: attributes, references = [], namespaces }, } = rawMigratedDoc; const id = requestedId || responseId; @@ -362,6 +436,7 @@ export class SavedObjectsRepository { return { id, type, + ...(namespaces && { namespaces }), updated_at: time, version: encodeVersion(seqNo, primaryTerm), attributes, @@ -382,32 +457,76 @@ export class SavedObjectsRepository { */ async delete(type: string, id: string, options: SavedObjectsDeleteOptions = {}): Promise<{}> { if (!this._allowedTypes.includes(type)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(); + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } const { namespace, refresh = DEFAULT_REFRESH_SETTING } = options; - const response = await this._writeToCluster('delete', { - id: this._serializer.generateRawId(namespace, type, id), + const rawId = this._serializer.generateRawId(namespace, type, id); + let preflightResult: SavedObjectsRawDoc | undefined; + + if (this._registry.isMultiNamespace(type)) { + preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace); + const existingNamespaces = getSavedObjectNamespaces(undefined, preflightResult); + const remainingNamespaces = existingNamespaces?.filter( + x => x !== getNamespaceString(namespace) + ); + + if (remainingNamespaces?.length) { + // if there is 1 or more namespace remaining, update the saved object + const time = this._getCurrentTime(); + + const doc = { + updated_at: time, + namespaces: remainingNamespaces, + }; + + const updateResponse = await this._writeToCluster('update', { + id: rawId, + index: this.getIndexForType(type), + ...getExpectedVersionProperties(undefined, preflightResult), + refresh, + ignore: [404], + body: { + doc, + }, + }); + + if (updateResponse.status === 404) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return {}; + } + } + + const deleteResponse = await this._writeToCluster('delete', { + id: rawId, index: this.getIndexForType(type), + ...getExpectedVersionProperties(undefined, preflightResult), refresh, ignore: [404], }); - const deleted = response.result === 'deleted'; + const deleted = deleteResponse.result === 'deleted'; if (deleted) { return {}; } - const docNotFound = response.result === 'not_found'; - const indexNotFound = response.error && response.error.type === 'index_not_found_exception'; - if (docNotFound || indexNotFound) { + const deleteDocNotFound = deleteResponse.result === 'not_found'; + const deleteIndexNotFound = + deleteResponse.error && deleteResponse.error.type === 'index_not_found_exception'; + if (deleteDocNotFound || deleteIndexNotFound) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } throw new Error( - `Unexpected Elasticsearch DELETE response: ${JSON.stringify({ type, id, response })}` + `Unexpected Elasticsearch DELETE response: ${JSON.stringify({ + type, + id, + response: deleteResponse, + })}` ); } @@ -426,25 +545,37 @@ export class SavedObjectsRepository { } const { refresh = DEFAULT_REFRESH_SETTING } = options; - const allTypes = Object.keys(getRootPropertiesObjects(this._mappings)); + const typesToUpdate = allTypes.filter(type => !this._registry.isNamespaceAgnostic(type)); - const typesToDelete = allTypes.filter(type => !this._registry.isNamespaceAgnostic(type)); - - const esOptions = { - index: this.getIndicesForTypes(typesToDelete), + const updateOptions = { + index: this.getIndicesForTypes(typesToUpdate), ignore: [404], refresh, body: { + script: { + source: ` + if (!ctx._source.containsKey('namespaces')) { + ctx.op = "delete"; + } else { + ctx._source['namespaces'].removeAll(Collections.singleton(params['namespace'])); + if (ctx._source['namespaces'].empty) { + ctx.op = "delete"; + } + } + `, + lang: 'painless', + params: { namespace: getNamespaceString(namespace) }, + }, conflicts: 'proceed', ...getSearchDsl(this._mappings, this._registry, { namespace, - type: typesToDelete, + type: typesToUpdate, }), }, }; - return await this._writeToCluster('deleteByQuery', esOptions); + return await this._writeToCluster('updateByQuery', updateOptions); } /** @@ -586,55 +717,77 @@ export class SavedObjectsRepository { return { saved_objects: [] }; } - const unsupportedTypeObjects = objects - .filter(o => !this._allowedTypes.includes(o.type)) - .map(({ type, id }) => { - return ({ - id, - type, - error: SavedObjectsErrorHelpers.createUnsupportedTypeError(type).output.payload, - } as any) as SavedObject; - }); + let bulkGetRequestIndexCounter = 0; + const expectedBulkGetResults: Either[] = objects.map(object => { + const { type, id, fields } = object; - const supportedTypeObjects = objects.filter(o => this._allowedTypes.includes(o.type)); + if (!this._allowedTypes.includes(type)) { + return { + tag: 'Left' as 'Left', + error: { + id, + type, + error: SavedObjectsErrorHelpers.createUnsupportedTypeError(type).output.payload, + }, + }; + } - const response = await this._callCluster('mget', { - body: { - docs: supportedTypeObjects.map(({ type, id, fields }) => { - return { - _id: this._serializer.generateRawId(namespace, type, id), - _index: this.getIndexForType(type), - _source: includedFields(type, fields), - }; - }), - }, + return { + tag: 'Right' as 'Right', + value: { + type, + id, + fields, + esRequestIndex: bulkGetRequestIndexCounter++, + }, + }; }); + const bulkGetDocs = expectedBulkGetResults + .filter(isRight) + .map(({ value: { type, id, fields } }) => ({ + _id: this._serializer.generateRawId(namespace, type, id), + _index: this.getIndexForType(type), + _source: includedFields(type, fields), + })); + const bulkGetResponse = bulkGetDocs.length + ? await this._callCluster('mget', { + body: { + docs: bulkGetDocs, + }, + ignore: [404], + }) + : undefined; + return { - saved_objects: (response.docs as any[]) - .map((doc, i) => { - const { id, type } = supportedTypeObjects[i]; + saved_objects: expectedBulkGetResults.map(expectedResult => { + if (isLeft(expectedResult)) { + return expectedResult.error as any; + } - if (!doc.found) { - return ({ - id, - type, - error: { statusCode: 404, message: 'Not found' }, - } as any) as SavedObject; - } + const { type, id, esRequestIndex } = expectedResult.value; + const doc = bulkGetResponse.docs[esRequestIndex]; - const time = doc._source.updated_at; - return { + if (!doc.found || !this.rawDocExistsInNamespace(doc, namespace)) { + return ({ id, type, - ...(time && { updated_at: time }), - version: encodeHitVersion(doc), - attributes: doc._source[type], - references: doc._source.references || [], - migrationVersion: doc._source.migrationVersion, - }; - }) - .concat(unsupportedTypeObjects), + error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload, + } as any) as SavedObject; + } + + const time = doc._source.updated_at; + return { + id, + type, + ...(doc._source.namespaces && { namespaces: doc._source.namespaces }), + ...(time && { updated_at: time }), + version: encodeHitVersion(doc), + attributes: doc._source[type], + references: doc._source.references || [], + migrationVersion: doc._source.migrationVersion, + }; + }), }; } @@ -666,7 +819,7 @@ export class SavedObjectsRepository { const docNotFound = response.found === false; const indexNotFound = response.status === 404; - if (docNotFound || indexNotFound) { + if (docNotFound || indexNotFound || !this.rawDocExistsInNamespace(response, namespace)) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } @@ -676,6 +829,7 @@ export class SavedObjectsRepository { return { id, type, + ...(response._source.namespaces && { namespaces: response._source.namespaces }), ...(updatedAt && { updated_at: updatedAt }), version: encodeHitVersion(response), attributes: response._source[type], @@ -707,29 +861,32 @@ export class SavedObjectsRepository { const { version, namespace, references, refresh = DEFAULT_REFRESH_SETTING } = options; + let preflightResult: SavedObjectsRawDoc | undefined; + if (this._registry.isMultiNamespace(type)) { + preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace); + } + const time = this._getCurrentTime(); const doc = { [type]: attributes, updated_at: time, - references, + ...(Array.isArray(references) && { references }), }; - if (!Array.isArray(doc.references)) { - delete doc.references; - } - const response = await this._writeToCluster('update', { + const updateResponse = await this._writeToCluster('update', { id: this._serializer.generateRawId(namespace, type, id), index: this.getIndexForType(type), - ...(version && decodeRequestVersion(version)), + ...getExpectedVersionProperties(version, preflightResult), refresh, ignore: [404], body: { doc, }, + ...(this._registry.isMultiNamespace(type) && { _sourceIncludes: ['namespaces'] }), }); - if (response.status === 404) { + if (updateResponse.status === 404) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } @@ -738,12 +895,168 @@ export class SavedObjectsRepository { id, type, updated_at: time, - version: encodeHitVersion(response), + version: encodeHitVersion(updateResponse), + ...(this._registry.isMultiNamespace(type) && { + namespaces: updateResponse.get._source.namespaces, + }), references, attributes, }; } + /** + * Adds one or more namespaces to a given multi-namespace saved object. This method and + * [`deleteFromNamespaces`]{@link SavedObjectsRepository.deleteFromNamespaces} are the only ways to change which Spaces a multi-namespace + * saved object is shared to. + */ + async addToNamespaces( + type: string, + id: string, + namespaces: string[], + options: SavedObjectsAddToNamespacesOptions = {} + ): Promise<{}> { + if (!this._allowedTypes.includes(type)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + + if (!this._registry.isMultiNamespace(type)) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `${type} doesn't support multiple namespaces` + ); + } + + if (!namespaces.length) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'namespaces must be a non-empty array of strings' + ); + } + + const { version, namespace, refresh = DEFAULT_REFRESH_SETTING } = options; + + const rawId = this._serializer.generateRawId(undefined, type, id); + const preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace); + const existingNamespaces = getSavedObjectNamespaces(undefined, preflightResult); + // there should never be a case where a multi-namespace object does not have any existing namespaces + // however, it is a possibility if someone manually modifies the document in Elasticsearch + const time = this._getCurrentTime(); + + const doc = { + updated_at: time, + namespaces: existingNamespaces ? unique(existingNamespaces.concat(namespaces)) : namespaces, + }; + + const updateResponse = await this._writeToCluster('update', { + id: rawId, + index: this.getIndexForType(type), + ...getExpectedVersionProperties(version, preflightResult), + refresh, + ignore: [404], + body: { + doc, + }, + }); + + if (updateResponse.status === 404) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + + return {}; + } + + /** + * Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted + * entirely. This method and [`addToNamespaces`]{@link SavedObjectsRepository.addToNamespaces} are the only ways to change which Spaces a + * multi-namespace saved object is shared to. + */ + async deleteFromNamespaces( + type: string, + id: string, + namespaces: string[], + options: SavedObjectsDeleteFromNamespacesOptions = {} + ): Promise<{}> { + if (!this._allowedTypes.includes(type)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + + if (!this._registry.isMultiNamespace(type)) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `${type} doesn't support multiple namespaces` + ); + } + + if (!namespaces.length) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'namespaces must be a non-empty array of strings' + ); + } + + const { namespace, refresh = DEFAULT_REFRESH_SETTING } = options; + + const rawId = this._serializer.generateRawId(undefined, type, id); + const preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace); + const existingNamespaces = getSavedObjectNamespaces(undefined, preflightResult); + // if there are somehow no existing namespaces, allow the operation to proceed and delete this saved object + const remainingNamespaces = existingNamespaces?.filter(x => !namespaces.includes(x)); + + if (remainingNamespaces?.length) { + // if there is 1 or more namespace remaining, update the saved object + const time = this._getCurrentTime(); + + const doc = { + updated_at: time, + namespaces: remainingNamespaces, + }; + + const updateResponse = await this._writeToCluster('update', { + id: rawId, + index: this.getIndexForType(type), + ...getExpectedVersionProperties(undefined, preflightResult), + refresh, + ignore: [404], + body: { + doc, + }, + }); + + if (updateResponse.status === 404) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return {}; + } else { + // if there are no namespaces remaining, delete the saved object + const deleteResponse = await this._writeToCluster('delete', { + id: this._serializer.generateRawId(undefined, type, id), + index: this.getIndexForType(type), + ...getExpectedVersionProperties(undefined, preflightResult), + refresh, + ignore: [404], + }); + + const deleted = deleteResponse.result === 'deleted'; + if (deleted) { + return {}; + } + + const deleteDocNotFound = deleteResponse.result === 'not_found'; + const deleteIndexNotFound = + deleteResponse.error && deleteResponse.error.type === 'index_not_found_exception'; + if (deleteDocNotFound || deleteIndexNotFound) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + + throw new Error( + `Unexpected Elasticsearch DELETE response: ${JSON.stringify({ + type, + id, + response: deleteResponse, + })}` + ); + } + } + /** * Updates multiple objects in bulk * @@ -757,10 +1070,10 @@ export class SavedObjectsRepository { options: SavedObjectsBulkUpdateOptions = {} ): Promise> { const time = this._getCurrentTime(); - const bulkUpdateParams: object[] = []; + const { namespace } = options; - let requestIndexCounter = 0; - const expectedResults: Array> = objects.map(object => { + let bulkGetRequestIndexCounter = 0; + const expectedBulkGetResults: Either[] = objects.map(object => { const { type, id } = object; if (!this._allowedTypes.includes(type)) { @@ -775,41 +1088,100 @@ export class SavedObjectsRepository { } const { attributes, references, version } = object; - const { namespace } = options; const documentToSave = { [type]: attributes, updated_at: time, - references, + ...(Array.isArray(references) && { references }), }; - if (!Array.isArray(documentToSave.references)) { - delete documentToSave.references; - } + const requiresNamespacesCheck = this._registry.isMultiNamespace(object.type); - const expectedResult = { - type, - id, - esRequestIndex: requestIndexCounter++, - documentToSave, + return { + tag: 'Right' as 'Right', + value: { + type, + id, + version, + documentToSave, + ...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), + }, }; + }); - bulkUpdateParams.push( - { - update: { - _id: this._serializer.generateRawId(namespace, type, id), - _index: this.getIndexForType(type), - ...(version && decodeRequestVersion(version)), + const bulkGetDocs = expectedBulkGetResults + .filter(isRight) + .filter(({ value }) => value.esRequestIndex !== undefined) + .map(({ value: { type, id } }) => ({ + _id: this._serializer.generateRawId(namespace, type, id), + _index: this.getIndexForType(type), + _source: ['type', 'namespaces'], + })); + const bulkGetResponse = bulkGetDocs.length + ? await this._callCluster('mget', { + body: { + docs: bulkGetDocs, }, - }, - { doc: documentToSave } - ); + ignore: [404], + }) + : undefined; - return { tag: 'Right' as 'Right', value: expectedResult }; - }); + let bulkUpdateRequestIndexCounter = 0; + const bulkUpdateParams: object[] = []; + const expectedBulkUpdateResults: Either[] = expectedBulkGetResults.map( + expectedBulkGetResult => { + if (isLeft(expectedBulkGetResult)) { + return expectedBulkGetResult; + } + + const { esRequestIndex, id, type, version, documentToSave } = expectedBulkGetResult.value; + let namespaces; + let versionProperties; + if (esRequestIndex !== undefined) { + const indexFound = bulkGetResponse.status !== 404; + const actualResult = indexFound ? bulkGetResponse.docs[esRequestIndex] : undefined; + const docFound = indexFound && actualResult.found === true; + if (!docFound || !this.rawDocExistsInNamespace(actualResult, namespace)) { + return { + tag: 'Left' as 'Left', + error: { + id, + type, + error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload, + }, + }; + } + namespaces = actualResult._source.namespaces; + versionProperties = getExpectedVersionProperties(version, actualResult); + } else { + versionProperties = getExpectedVersionProperties(version); + } + + const expectedResult = { + type, + id, + namespaces, + esRequestIndex: bulkUpdateRequestIndexCounter++, + documentToSave: expectedBulkGetResult.value.documentToSave, + }; + + bulkUpdateParams.push( + { + update: { + _id: this._serializer.generateRawId(namespace, type, id), + _index: this.getIndexForType(type), + ...versionProperties, + }, + }, + { doc: documentToSave } + ); + + return { tag: 'Right' as 'Right', value: expectedResult }; + } + ); const { refresh = DEFAULT_REFRESH_SETTING } = options; - const esResponse = bulkUpdateParams.length + const bulkUpdateResponse = bulkUpdateParams.length ? await this._writeToCluster('bulk', { refresh, body: bulkUpdateParams, @@ -817,13 +1189,13 @@ export class SavedObjectsRepository { : {}; return { - saved_objects: expectedResults.map(expectedResult => { + saved_objects: expectedBulkUpdateResults.map(expectedResult => { if (isLeft(expectedResult)) { - return expectedResult.error; + return expectedResult.error as any; } - const { type, id, documentToSave, esRequestIndex } = expectedResult.value; - const response = esResponse.items[esRequestIndex]; + const { type, id, namespaces, documentToSave, esRequestIndex } = expectedResult.value; + const response = bulkUpdateResponse.items[esRequestIndex]; const { error, _seq_no: seqNo, _primary_term: primaryTerm } = Object.values( response )[0] as any; @@ -839,6 +1211,7 @@ export class SavedObjectsRepository { return { id, type, + ...(namespaces && { namespaces }), updated_at, version: encodeVersion(seqNo, primaryTerm), attributes, @@ -877,10 +1250,20 @@ export class SavedObjectsRepository { const { migrationVersion, namespace, refresh = DEFAULT_REFRESH_SETTING } = options; const time = this._getCurrentTime(); + let savedObjectNamespace; + let savedObjectNamespaces: string[] | undefined; + + if (this._registry.isSingleNamespace(type) && namespace) { + savedObjectNamespace = namespace; + } else if (this._registry.isMultiNamespace(type)) { + savedObjectNamespaces = await this.preflightGetNamespaces(type, id, namespace); + } const migrated = this._migrator.migrateDocument({ id, type, + ...(savedObjectNamespace && { namespace: savedObjectNamespace }), + ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), attributes: { [counterFieldName]: 1 }, migrationVersion, updated_at: time, @@ -889,7 +1272,7 @@ export class SavedObjectsRepository { const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); const response = await this._writeToCluster('update', { - id: this._serializer.generateRawId(namespace, type, id), + id: raw._id, index: this.getIndexForType(type), refresh, _source: true, @@ -952,14 +1335,13 @@ export class SavedObjectsRepository { } /** - * Returns an array of indices as specified in `this._schema` for each of the + * Returns an array of indices as specified in `this._registry` for each of the * given `types`. If any of the types don't have an associated index, the * default index `this._index` will be included. * * @param types The types whose indices should be retrieved */ private getIndicesForTypes(types: string[]) { - const unique = (array: string[]) => [...new Set(array)]; return unique(types.map(t => this.getIndexForType(t))); } @@ -975,12 +1357,97 @@ export class SavedObjectsRepository { const savedObject = this._serializer.rawToSavedObject(raw); return omit(savedObject, 'namespace'); } + + /** + * Check to ensure that a raw document exists in a namespace. If the document is not a multi-namespace type, then this returns `true` as + * we rely on the guarantees of the document ID format. If the document is a multi-namespace type, this checks to ensure that the + * document's `namespaces` value includes the string representation of the given namespace. + * + * WARNING: This should only be used for documents that were retrieved from Elasticsearch. Otherwise, the guarantees of the document ID + * format mentioned above do not apply. + */ + private rawDocExistsInNamespace(raw: SavedObjectsRawDoc, namespace?: string) { + const rawDocType = raw._source.type; + + // if the type is namespace isolated, or namespace agnostic, we can continue to rely on the guarantees + // of the document ID format and don't need to check this + if (!this._registry.isMultiNamespace(rawDocType)) { + return true; + } + + const namespaces = raw._source.namespaces; + return namespaces?.includes(getNamespaceString(namespace)) ?? false; + } + + /** + * Pre-flight check to get a multi-namespace saved object's included namespaces. This ensures that, if the saved object exists, it + * includes the target namespace. + * + * @param type The type of the saved object. + * @param id The ID of the saved object. + * @param namespace The target namespace. + * @returns Array of namespaces that this saved object currently includes, or (if the object does not exist yet) the namespaces that a + * newly-created object will include. Value may be undefined if an existing saved object has no namespaces attribute; this should not + * happen in normal operations, but it is possible if the Elasticsearch document is manually modified. + * @throws Will throw an error if the saved object exists and it does not include the target namespace. + */ + private async preflightGetNamespaces(type: string, id: string, namespace?: string) { + if (!this._registry.isMultiNamespace(type)) { + throw new Error(`Cannot make preflight get request for non-multi-namespace type '${type}'.`); + } + + const response = await this._callCluster('get', { + id: this._serializer.generateRawId(undefined, type, id), + index: this.getIndexForType(type), + ignore: [404], + }); + + const indexFound = response.status !== 404; + const docFound = indexFound && response.found === true; + if (docFound) { + if (!this.rawDocExistsInNamespace(response, namespace)) { + throw SavedObjectsErrorHelpers.createConflictError(type, id); + } + return getSavedObjectNamespaces(namespace, response); + } + return getSavedObjectNamespaces(namespace); + } + + /** + * Pre-flight check for a multi-namespace saved object's namespaces. This ensures that, if the saved object exists, it includes the target + * namespace. + * + * @param type The type of the saved object. + * @param id The ID of the saved object. + * @param namespace The target namespace. + * @returns Raw document from Elasticsearch. + * @throws Will throw an error if the saved object is not found, or if it doesn't include the target namespace. + */ + private async preflightCheckIncludesNamespace(type: string, id: string, namespace?: string) { + if (!this._registry.isMultiNamespace(type)) { + throw new Error(`Cannot make preflight get request for non-multi-namespace type '${type}'.`); + } + + const rawId = this._serializer.generateRawId(undefined, type, id); + const response = await this._callCluster('get', { + id: rawId, + index: this.getIndexForType(type), + ignore: [404], + }); + + const indexFound = response.status !== 404; + const docFound = indexFound && response.found === true; + if (!docFound || !this.rawDocExistsInNamespace(response, namespace)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return response as SavedObjectsRawDoc; + } } function getBulkOperationError(error: { type: string; reason?: string }, type: string, id: string) { switch (error.type) { case 'version_conflict_engine_exception': - return { statusCode: 409, message: 'version conflict, document already exists' }; + return SavedObjectsErrorHelpers.createConflictError(type, id).output.payload; case 'document_missing_exception': return SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload; default: @@ -989,3 +1456,49 @@ function getBulkOperationError(error: { type: string; reason?: string }, type: s }; } } + +/** + * Returns an object with the expected version properties. This facilitates Elasticsearch's Optimistic Concurrency Control. + * + * @param version Optional version specified by the consumer. + * @param document Optional existing document that was obtained in a preflight operation. + */ +function getExpectedVersionProperties(version?: string, document?: SavedObjectsRawDoc) { + if (version) { + return decodeRequestVersion(version); + } else if (document) { + return { + if_seq_no: document._seq_no, + if_primary_term: document._primary_term, + }; + } + return {}; +} + +/** + * Returns the string representation of a namespace. + * The default namespace is undefined, and is represented by the string 'default'. + */ +function getNamespaceString(namespace?: string) { + return namespace ?? 'default'; +} + +/** + * Returns a string array of namespaces for a given saved object. If the saved object is undefined, the result is an array that contains the + * current namespace. Value may be undefined if an existing saved object has no namespaces attribute; this should not happen in normal + * operations, but it is possible if the Elasticsearch document is manually modified. + * + * @param namespace The current namespace. + * @param document Optional existing saved object that was obtained in a preflight operation. + */ +function getSavedObjectNamespaces( + namespace?: string, + document?: SavedObjectsRawDoc +): string[] | undefined { + if (document) { + return document._source?.namespaces; + } + return [getNamespaceString(namespace)]; +} + +const unique = (array: string[]) => [...new Set(array)]; diff --git a/src/core/server/saved_objects/service/lib/repository_create_repository.test.ts b/src/core/server/saved_objects/service/lib/repository_create_repository.test.ts index a6b580e9b3461..ea881805e1ae6 100644 --- a/src/core/server/saved_objects/service/lib/repository_create_repository.test.ts +++ b/src/core/server/saved_objects/service/lib/repository_create_repository.test.ts @@ -32,7 +32,7 @@ describe('SavedObjectsRepository#createRepository', () => { typeRegistry.registerType({ name: 'nsAgnosticType', hidden: false, - namespaceAgnostic: true, + namespaceType: 'agnostic', mappings: { properties: { name: { type: 'keyword' }, @@ -44,7 +44,7 @@ describe('SavedObjectsRepository#createRepository', () => { typeRegistry.registerType({ name: 'nsType', hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', indexPattern: 'beats', mappings: { properties: { @@ -56,7 +56,7 @@ describe('SavedObjectsRepository#createRepository', () => { typeRegistry.registerType({ name: 'hiddenType', hidden: true, - namespaceAgnostic: true, + namespaceType: 'agnostic', mappings: { properties: { name: { type: 'keyword' }, diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index b2129765ee426..a72c21dd5eee4 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -17,6 +17,9 @@ * under the License. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { esKuery, KueryNode } from '../../../../../../plugins/data/server'; + import { typeRegistryMock } from '../../../saved_objects_type_registry.mock'; import { getQueryParams } from './query_params'; @@ -24,1268 +27,329 @@ const registry = typeRegistryMock.create(); const MAPPINGS = { properties: { - type: { - type: 'keyword', - }, - pending: { - properties: { - title: { - type: 'text', - }, - }, - }, + pending: { properties: { title: { type: 'text' } } }, saved: { properties: { - title: { - type: 'text', - fields: { - raw: { - type: 'keyword', - }, - }, - }, - obj: { - properties: { - key1: { - type: 'text', - }, - }, - }, - }, - }, - global: { - properties: { - name: { - type: 'keyword', - }, + title: { type: 'text', fields: { raw: { type: 'keyword' } } }, + obj: { properties: { key1: { type: 'text' } } }, }, }, + // mock registry returns isMultiNamespace=true for 'shared' type + shared: { properties: { name: { type: 'keyword' } } }, + // mock registry returns isNamespaceAgnostic=true for 'global' type + global: { properties: { name: { type: 'keyword' } } }, }, }; +const ALL_TYPES = Object.keys(MAPPINGS.properties); +// get all possible subsets (combination) of all types +const ALL_TYPE_SUBSETS = ALL_TYPES.reduce( + (subsets, value) => subsets.concat(subsets.map(set => [...set, value])), + [[] as string[]] +) + .filter(x => x.length) // exclude empty set + .map(x => (x.length === 1 ? x[0] : x)); // if a subset is a single string, destructure it -// create a type clause to be used within the "should", if a namespace is specified -// the clause will ensure the namespace matches; otherwise, the clause will ensure -// that there isn't a namespace field. -const createTypeClause = (type: string, namespace?: string) => { - if (namespace) { - return { - bool: { - must: [{ term: { type } }, { term: { namespace } }], - }, - }; - } +/** + * Note: these tests cases are defined in the order they appear in the source code, for readability's sake + */ +describe('#getQueryParams', () => { + const mappings = MAPPINGS; + type Result = ReturnType; - return { - bool: { - must: [{ term: { type } }], - must_not: [{ exists: { field: 'namespace' } }], - }, - }; -}; + describe('kueryNode filter clause (query.bool.filter[...]', () => { + const expectResult = (result: Result, expected: any) => { + expect(result.query.bool.filter).toEqual(expect.arrayContaining([expected])); + }; -describe('searchDsl/queryParams', () => { - describe('no parameters', () => { - it('searches for all known types without a namespace specified', () => { - expect(getQueryParams({ mappings: MAPPINGS, registry })).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending'), - createTypeClause('saved'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - }, - }, + describe('`kueryNode` parameter', () => { + it('does not include the clause when `kueryNode` is not specified', () => { + const result = getQueryParams({ mappings, registry, kueryNode: undefined }); + expect(result.query.bool.filter).toHaveLength(1); }); - }); - }); - describe('namespace', () => { - it('filters namespaced types for namespace, and ensures namespace agnostic types have no namespace', () => { - expect(getQueryParams({ mappings: MAPPINGS, registry, namespace: 'foo-namespace' })).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending', 'foo-namespace'), - createTypeClause('saved', 'foo-namespace'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - }, - }, - }); - }); - }); + it('includes the specified Kuery clause', () => { + const test = (kueryNode: KueryNode) => { + const result = getQueryParams({ mappings, registry, kueryNode }); + const expected = esKuery.toElasticsearchQuery(kueryNode); + expect(result.query.bool.filter).toHaveLength(2); + expectResult(result, expected); + }; - describe('type (singular, namespaced)', () => { - it('includes a terms filter for type and namespace not being specified', () => { - expect( - getQueryParams({ mappings: MAPPINGS, registry, namespace: undefined, type: 'saved' }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved')], - minimum_should_match: 1, - }, - }, - ], - }, - }, - }); - }); - }); + const simpleClause = { + type: 'function', + function: 'is', + arguments: [ + { type: 'literal', value: 'global.name' }, + { type: 'literal', value: 'GLOBAL' }, + { type: 'literal', value: false }, + ], + } as KueryNode; + test(simpleClause); - describe('type (singular, global)', () => { - it('includes a terms filter for type and namespace not being specified', () => { - expect( - getQueryParams({ mappings: MAPPINGS, registry, namespace: undefined, type: 'global' }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('global')], - minimum_should_match: 1, + const complexClause = { + type: 'function', + function: 'and', + arguments: [ + simpleClause, + { + type: 'function', + function: 'not', + arguments: [ + { + type: 'function', + function: 'is', + arguments: [ + { type: 'literal', value: 'saved.obj.key1' }, + { type: 'literal', value: 'key' }, + { type: 'literal', value: true }, + ], }, - }, - ], - }, - }, + ], + }, + ], + } as KueryNode; + test(complexClause); }); }); }); - describe('type (plural, namespaced and global)', () => { - it('includes term filters for types and namespace not being specified', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: undefined, - type: ['saved', 'global'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - }, - }, - }); - }); - }); + describe('reference filter clause (query.bool.filter[bool.must])', () => { + describe('`hasReference` parameter', () => { + const expectResult = (result: Result, expected: any) => { + expect(result.query.bool.filter).toEqual( + expect.arrayContaining([{ bool: expect.objectContaining({ must: expected }) }]) + ); + }; - describe('namespace, type (plural, namespaced and global)', () => { - it('includes a terms filter for type and namespace not being specified', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, + it('does not include the clause when `hasReference` is not specified', () => { + const result = getQueryParams({ + mappings, registry, - namespace: 'foo-namespace', - type: ['saved', 'global'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved', 'foo-namespace'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - }, - }, + hasReference: undefined, + }); + expectResult(result, undefined); }); - }); - }); - describe('search', () => { - it('includes a sqs query and all known types without a namespace specified', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, + it('creates a clause with query for specified reference', () => { + const hasReference = { id: 'foo', type: 'bar' }; + const result = getQueryParams({ + mappings, registry, - namespace: undefined, - type: undefined, - search: 'us*', - }) - ).toEqual({ - query: { - bool: { - filter: [ - { + hasReference, + }); + expectResult(result, [ + { + nested: { + path: 'references', + query: { bool: { - should: [ - createTypeClause('pending'), - createTypeClause('saved'), - createTypeClause('global'), + must: [ + { term: { 'references.id': hasReference.id } }, + { term: { 'references.type': hasReference.type } }, ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'us*', - lenient: true, - fields: ['*'], }, }, - ], + }, }, - }, + ]); }); }); }); - describe('namespace, search', () => { - it('includes a sqs query and namespaced types with the namespace and global types without a namespace', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: 'foo-namespace', - type: undefined, - search: 'us*', - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending', 'foo-namespace'), - createTypeClause('saved', 'foo-namespace'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'us*', - lenient: true, - fields: ['*'], - }, - }, - ], - }, - }, - }); - }); - }); + describe('type filter clauses (query.bool.filter[bool.should])', () => { + describe('`type` parameter', () => { + const expectResult = (result: Result, ...types: string[]) => { + expect(result.query.bool.filter).toEqual( + expect.arrayContaining([ + { + bool: expect.objectContaining({ + should: types.map(type => ({ + bool: expect.objectContaining({ + must: expect.arrayContaining([{ term: { type } }]), + }), + })), + minimum_should_match: 1, + }), + }, + ]) + ); + }; - describe('type (plural, namespaced and global), search', () => { - it('includes a sqs query and types without a namespace', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: undefined, - type: ['saved', 'global'], - search: 'us*', - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'us*', - lenient: true, - fields: ['*'], - }, - }, - ], - }, - }, + it('searches for all known types when `type` is not specified', () => { + const result = getQueryParams({ mappings, registry, type: undefined }); + expectResult(result, ...ALL_TYPES); }); - }); - }); - describe('namespace, type (plural, namespaced and global), search', () => { - it('includes a sqs query and namespace type with a namespace and global type without a namespace', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: 'foo-namespace', - type: ['saved', 'global'], - search: 'us*', - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved', 'foo-namespace'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'us*', - lenient: true, - fields: ['*'], - }, - }, - ], - }, - }, + it('searches for specified type/s', () => { + const test = (typeOrTypes: string | string[]) => { + const result = getQueryParams({ + mappings, + registry, + type: typeOrTypes, + }); + const type = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; + expectResult(result, ...type); + }; + for (const typeOrTypes of ALL_TYPE_SUBSETS) { + test(typeOrTypes); + } }); }); - }); - describe('search, searchFields', () => { - it('includes all types for field', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: undefined, - type: undefined, - search: 'y*', - searchFields: ['title'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending'), - createTypeClause('saved'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['pending.title', 'saved.title', 'global.title'], - }, - }, - ], - }, - }, - }); - }); - it('supports field boosting', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: undefined, - type: undefined, - search: 'y*', - searchFields: ['title^3'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending'), - createTypeClause('saved'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['pending.title^3', 'saved.title^3', 'global.title^3'], - }, - }, - ], - }, - }, + describe('`namespace` parameter', () => { + const createTypeClause = (type: string, namespace?: string) => { + if (registry.isMultiNamespace(type)) { + return { + bool: { + must: expect.arrayContaining([{ term: { namespaces: namespace ?? 'default' } }]), + must_not: [{ exists: { field: 'namespace' } }], + }, + }; + } else if (namespace && registry.isSingleNamespace(type)) { + return { + bool: { + must: expect.arrayContaining([{ term: { namespace } }]), + must_not: [{ exists: { field: 'namespaces' } }], + }, + }; + } + // isNamespaceAgnostic + return { + bool: expect.objectContaining({ + must_not: [{ exists: { field: 'namespace' } }, { exists: { field: 'namespaces' } }], + }), + }; + }; + + const expectResult = (result: Result, ...typeClauses: any) => { + expect(result.query.bool.filter).toEqual( + expect.arrayContaining([ + { bool: expect.objectContaining({ should: typeClauses, minimum_should_match: 1 }) }, + ]) + ); + }; + + const test = (namespace?: string) => { + for (const typeOrTypes of ALL_TYPE_SUBSETS) { + const result = getQueryParams({ mappings, registry, type: typeOrTypes, namespace }); + const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; + expectResult(result, ...types.map(x => createTypeClause(x, namespace))); + } + // also test with no specified type/s + const result = getQueryParams({ mappings, registry, type: undefined, namespace }); + expectResult(result, ...ALL_TYPES.map(x => createTypeClause(x, namespace))); + }; + + it('filters results with "namespace" field when `namespace` is not specified', () => { + test(undefined); }); - }); - it('supports field and multi-field', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: undefined, - type: undefined, - search: 'y*', - searchFields: ['title', 'title.raw'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending'), - createTypeClause('saved'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: [ - 'pending.title', - 'saved.title', - 'global.title', - 'pending.title.raw', - 'saved.title.raw', - 'global.title.raw', - ], - }, - }, - ], - }, - }, + + it('filters results for specified namespace for appropriate type/s', () => { + test('foo-namespace'); }); }); }); - describe('namespace, search, searchFields', () => { - it('includes all types for field', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, + describe('search clause (query.bool.must.simple_query_string)', () => { + const search = 'foo*'; + + const expectResult = (result: Result, sqsClause: any) => { + expect(result.query.bool.must).toEqual([{ simple_query_string: sqsClause }]); + }; + + describe('`search` parameter', () => { + it('does not include clause when `search` is not specified', () => { + const result = getQueryParams({ + mappings, registry, - namespace: 'foo-namespace', - type: undefined, - search: 'y*', - searchFields: ['title'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending', 'foo-namespace'), - createTypeClause('saved', 'foo-namespace'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['pending.title', 'saved.title', 'global.title'], - }, - }, - ], - }, - }, + search: undefined, + }); + expect(result.query.bool.must).toBeUndefined(); }); - }); - it('supports field boosting', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, + + it('creates a clause with query for specified search', () => { + const result = getQueryParams({ + mappings, registry, - namespace: 'foo-namespace', - type: undefined, - search: 'y*', - searchFields: ['title^3'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending', 'foo-namespace'), - createTypeClause('saved', 'foo-namespace'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['pending.title^3', 'saved.title^3', 'global.title^3'], - }, - }, - ], - }, - }, + search, + }); + expectResult(result, expect.objectContaining({ query: search })); }); }); - it('supports field and multi-field', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, + + describe('`searchFields` parameter', () => { + const getExpectedFields = (searchFields: string[], typeOrTypes: string | string[]) => { + const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; + return searchFields.map(x => types.map(y => `${y}.${x}`)).flat(); + }; + + const test = (searchFields: string[]) => { + for (const typeOrTypes of ALL_TYPE_SUBSETS) { + const result = getQueryParams({ + mappings, + registry, + type: typeOrTypes, + search, + searchFields, + }); + const fields = getExpectedFields(searchFields, typeOrTypes); + expectResult(result, expect.objectContaining({ fields })); + } + // also test with no specified type/s + const result = getQueryParams({ + mappings, registry, - namespace: 'foo-namespace', type: undefined, - search: 'y*', - searchFields: ['title', 'title.raw'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending', 'foo-namespace'), - createTypeClause('saved', 'foo-namespace'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: [ - 'pending.title', - 'saved.title', - 'global.title', - 'pending.title.raw', - 'saved.title.raw', - 'global.title.raw', - ], - }, - }, - ], - }, - }, - }); - }); - }); + search, + searchFields, + }); + const fields = getExpectedFields(searchFields, ALL_TYPES); + expectResult(result, expect.objectContaining({ fields })); + }; - describe('type (plural, namespaced and global), search, searchFields', () => { - it('includes all types for field', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: undefined, - type: ['saved', 'global'], - search: 'y*', - searchFields: ['title'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['saved.title', 'global.title'], - }, - }, - ], - }, - }, - }); - }); - it('supports field boosting', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: undefined, - type: ['saved', 'global'], - search: 'y*', - searchFields: ['title^3'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['saved.title^3', 'global.title^3'], - }, - }, - ], - }, - }, - }); - }); - it('supports field and multi-field', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, + it('includes lenient flag and all fields when `searchFields` is not specified', () => { + const result = getQueryParams({ + mappings, registry, - namespace: undefined, - type: ['saved', 'global'], - search: 'y*', - searchFields: ['title', 'title.raw'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['saved.title', 'global.title', 'saved.title.raw', 'global.title.raw'], - }, - }, - ], - }, - }, + search, + searchFields: undefined, + }); + expectResult(result, expect.objectContaining({ lenient: true, fields: ['*'] })); }); - }); - }); - describe('namespace, type (plural, namespaced and global), search, searchFields', () => { - it('includes all types for field', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: 'foo-namespace', - type: ['saved', 'global'], - search: 'y*', - searchFields: ['title'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved', 'foo-namespace'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['saved.title', 'global.title'], - }, - }, - ], - }, - }, - }); - }); - it('supports field boosting', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: 'foo-namespace', - type: ['saved', 'global'], - search: 'y*', - searchFields: ['title^3'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved', 'foo-namespace'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['saved.title^3', 'global.title^3'], - }, - }, - ], - }, - }, - }); - }); - it('supports field and multi-field', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: 'foo-namespace', - type: ['saved', 'global'], - search: 'y*', - searchFields: ['title', 'title.raw'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved', 'foo-namespace'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['saved.title', 'global.title', 'saved.title.raw', 'global.title.raw'], - }, - }, - ], - }, - }, + it('includes specified search fields for appropriate type/s', () => { + test(['title']); }); - }); - }); - describe('type (plural, namespaced and global), search, defaultSearchOperator', () => { - it('supports defaultSearchOperator', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: 'foo-namespace', - type: ['saved', 'global'], - search: 'foo', - searchFields: undefined, - defaultSearchOperator: 'AND', - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - bool: { - must: [ - { - term: { - type: 'saved', - }, - }, - { - term: { - namespace: 'foo-namespace', - }, - }, - ], - }, - }, - { - bool: { - must: [ - { - term: { - type: 'global', - }, - }, - ], - must_not: [ - { - exists: { - field: 'namespace', - }, - }, - ], - }, - }, - ], - }, - }, - ], - must: [ - { - simple_query_string: { - lenient: true, - fields: ['*'], - default_operator: 'AND', - query: 'foo', - }, - }, - ], - }, - }, + it('supports boosting', () => { + test(['title^3']); }); - }); - }); - describe('type (plural, namespaced and global), hasReference', () => { - it('supports hasReference', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: 'foo-namespace', - type: ['saved', 'global'], - search: undefined, - searchFields: undefined, - defaultSearchOperator: 'OR', - hasReference: { - type: 'bar', - id: '1', - }, - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - must: [ - { - nested: { - path: 'references', - query: { - bool: { - must: [ - { - term: { - 'references.id': '1', - }, - }, - { - term: { - 'references.type': 'bar', - }, - }, - ], - }, - }, - }, - }, - ], - should: [createTypeClause('saved', 'foo-namespace'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - }, - }, + it('supports multiple fields', () => { + test(['title, title.raw']); }); }); - }); - describe('type filter', () => { - it(' with namespace', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: 'foo-namespace', - kueryNode: { - type: 'function', - function: 'is', - arguments: [ - { type: 'literal', value: 'global.name' }, - { type: 'literal', value: 'GLOBAL' }, - { type: 'literal', value: false }, - ], - }, - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - { - match: { - 'global.name': 'GLOBAL', - }, - }, - ], - minimum_should_match: 1, - }, - }, - { - bool: { - should: [ - { - bool: { - must: [ - { - term: { - type: 'pending', - }, - }, - { - term: { - namespace: 'foo-namespace', - }, - }, - ], - }, - }, - { - bool: { - must: [ - { - term: { - type: 'saved', - }, - }, - { - term: { - namespace: 'foo-namespace', - }, - }, - ], - }, - }, - { - bool: { - must: [ - { - term: { - type: 'global', - }, - }, - ], - must_not: [ - { - exists: { - field: 'namespace', - }, - }, - ], - }, - }, - ], - minimum_should_match: 1, - }, - }, - ], - }, - }, - }); - }); - it(' with namespace and more complex filter', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, + describe('`defaultSearchOperator` parameter', () => { + it('does not include default_operator when `defaultSearchOperator` is not specified', () => { + const result = getQueryParams({ + mappings, registry, - namespace: 'foo-namespace', - kueryNode: { - type: 'function', - function: 'and', - arguments: [ - { - type: 'function', - function: 'is', - arguments: [ - { type: 'literal', value: 'global.name' }, - { type: 'literal', value: 'GLOBAL' }, - { type: 'literal', value: false }, - ], - }, - { - type: 'function', - function: 'not', - arguments: [ - { - type: 'function', - function: 'is', - arguments: [ - { type: 'literal', value: 'saved.obj.key1' }, - { type: 'literal', value: 'key' }, - { type: 'literal', value: true }, - ], - }, - ], - }, - ], - }, - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - filter: [ - { - bool: { - should: [ - { - match: { - 'global.name': 'GLOBAL', - }, - }, - ], - minimum_should_match: 1, - }, - }, - { - bool: { - must_not: { - bool: { - should: [ - { - match_phrase: { - 'saved.obj.key1': 'key', - }, - }, - ], - minimum_should_match: 1, - }, - }, - }, - }, - ], - }, - }, - { - bool: { - should: [ - { - bool: { - must: [ - { - term: { - type: 'pending', - }, - }, - { - term: { - namespace: 'foo-namespace', - }, - }, - ], - }, - }, - { - bool: { - must: [ - { - term: { - type: 'saved', - }, - }, - { - term: { - namespace: 'foo-namespace', - }, - }, - ], - }, - }, - { - bool: { - must: [ - { - term: { - type: 'global', - }, - }, - ], - must_not: [ - { - exists: { - field: 'namespace', - }, - }, - ], - }, - }, - ], - minimum_should_match: 1, - }, - }, - ], - }, - }, + search, + defaultSearchOperator: undefined, + }); + expectResult(result, expect.not.objectContaining({ default_operator: expect.anything() })); }); - }); - it(' with search and searchFields', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, + + it('includes specified default operator', () => { + const defaultSearchOperator = 'AND'; + const result = getQueryParams({ + mappings, registry, - namespace: 'foo-namespace', - search: 'y*', - searchFields: ['title'], - kueryNode: { - type: 'function', - function: 'is', - arguments: [ - { type: 'literal', value: 'global.name' }, - { type: 'literal', value: 'GLOBAL' }, - { type: 'literal', value: false }, - ], - }, - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - { - match: { - 'global.name': 'GLOBAL', - }, - }, - ], - minimum_should_match: 1, - }, - }, - { - bool: { - should: [ - { - bool: { - must: [ - { - term: { - type: 'pending', - }, - }, - { - term: { - namespace: 'foo-namespace', - }, - }, - ], - }, - }, - { - bool: { - must: [ - { - term: { - type: 'saved', - }, - }, - { - term: { - namespace: 'foo-namespace', - }, - }, - ], - }, - }, - { - bool: { - must: [ - { - term: { - type: 'global', - }, - }, - ], - must_not: [ - { - exists: { - field: 'namespace', - }, - }, - ], - }, - }, - ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['pending.title', 'saved.title', 'global.title'], - }, - }, - ], - }, - }, + search, + defaultSearchOperator, + }); + expectResult(result, expect.objectContaining({ default_operator: defaultSearchOperator })); }); }); }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index e6c06208ca1a5..967ce8bceaf84 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -66,18 +66,26 @@ function getClauseForType( namespace: string | undefined, type: string ) { - if (namespace && !registry.isNamespaceAgnostic(type)) { + if (registry.isMultiNamespace(type)) { + return { + bool: { + must: [{ term: { type } }, { term: { namespaces: namespace ?? 'default' } }], + must_not: [{ exists: { field: 'namespace' } }], + }, + }; + } else if (namespace && registry.isSingleNamespace(type)) { return { bool: { must: [{ term: { type } }, { term: { namespace } }], + must_not: [{ exists: { field: 'namespaces' } }], }, }; } - + // isSingleNamespace in the default namespace, or isNamespaceAgnostic return { bool: { must: [{ term: { type } }], - must_not: [{ exists: { field: 'namespace' } }], + must_not: [{ exists: { field: 'namespace' } }, { exists: { field: 'namespaces' } }], }, }; } diff --git a/src/core/server/saved_objects/service/saved_objects_client.mock.ts b/src/core/server/saved_objects/service/saved_objects_client.mock.ts index c6de9fa94291c..b209c9ca54f63 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.mock.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.mock.ts @@ -31,6 +31,8 @@ const create = () => find: jest.fn(), get: jest.fn(), update: jest.fn(), + addToNamespaces: jest.fn(), + deleteFromNamespaces: jest.fn(), } as unknown) as jest.Mocked); export const savedObjectsClientMock = { create }; diff --git a/src/core/server/saved_objects/service/saved_objects_client.test.js b/src/core/server/saved_objects/service/saved_objects_client.test.js index 1794d9ae86c17..53bb31369adbf 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.test.js +++ b/src/core/server/saved_objects/service/saved_objects_client.test.js @@ -147,3 +147,37 @@ test(`#bulkUpdate`, async () => { }); expect(result).toBe(returnValue); }); + +test(`#addToNamespaces`, async () => { + const returnValue = Symbol(); + const mockRepository = { + addToNamespaces: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const type = Symbol(); + const id = Symbol(); + const namespaces = Symbol(); + const options = Symbol(); + const result = await client.addToNamespaces(type, id, namespaces, options); + + expect(mockRepository.addToNamespaces).toHaveBeenCalledWith(type, id, namespaces, options); + expect(result).toBe(returnValue); +}); + +test(`#deleteFromNamespaces`, async () => { + const returnValue = Symbol(); + const mockRepository = { + deleteFromNamespaces: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const type = Symbol(); + const id = Symbol(); + const namespaces = Symbol(); + const options = Symbol(); + const result = await client.deleteFromNamespaces(type, id, namespaces, options); + + expect(mockRepository.deleteFromNamespaces).toHaveBeenCalledWith(type, id, namespaces, options); + expect(result).toBe(returnValue); +}); diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 70d69374ba8fe..8780f07cc3091 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -107,6 +107,26 @@ export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions { refresh?: MutatingOperationRefreshSetting; } +/** + * + * @public + */ +export interface SavedObjectsAddToNamespacesOptions extends SavedObjectsBaseOptions { + /** An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. */ + version?: string; + /** The Elasticsearch Refresh setting for this operation */ + refresh?: MutatingOperationRefreshSetting; +} + +/** + * + * @public + */ +export interface SavedObjectsDeleteFromNamespacesOptions extends SavedObjectsBaseOptions { + /** The Elasticsearch Refresh setting for this operation */ + refresh?: MutatingOperationRefreshSetting; +} + /** * * @public @@ -270,6 +290,40 @@ export class SavedObjectsClient { return await this._repository.update(type, id, attributes, options); } + /** + * Adds namespaces to a SavedObject + * + * @param type + * @param id + * @param namespaces + * @param options + */ + async addToNamespaces( + type: string, + id: string, + namespaces: string[], + options: SavedObjectsAddToNamespacesOptions = {} + ): Promise<{}> { + return await this._repository.addToNamespaces(type, id, namespaces, options); + } + + /** + * Removes namespaces from a SavedObject + * + * @param type + * @param id + * @param namespaces + * @param options + */ + async deleteFromNamespaces( + type: string, + id: string, + namespaces: string[], + options: SavedObjectsDeleteFromNamespacesOptions = {} + ): Promise<{}> { + return await this._repository.deleteFromNamespaces(type, id, namespaces, options); + } + /** * Bulk Updates multiple SavedObject at once * diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index f14e9d9efb5e3..b50c6dc9a1abf 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -172,6 +172,19 @@ export type MutatingOperationRefreshSetting = boolean | 'wait_for'; */ export type SavedObjectsClientContract = Pick; +/** + * The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: + * * single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. + * * multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. + * * agnostic: this type of saved object is global. + * + * Note: do not write logic that uses this value directly; instead, use the appropriate accessors in the + * {@link SavedObjectTypeRegistry | type registry}. + * + * @public + */ +export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; + /** * @remarks This is only internal for now, and will only be public when we expose the registerType API * @@ -190,9 +203,9 @@ export interface SavedObjectsType { */ hidden: boolean; /** - * Is the type global (true), or namespaced (false). + * The {@link SavedObjectsNamespaceType | namespace type} for the type. */ - namespaceAgnostic: boolean; + namespaceType: SavedObjectsNamespaceType; /** * If defined, the type instances will be stored in the given index instead of the default one. */ @@ -329,6 +342,8 @@ export type SavedObjectLegacyMigrationFn = ( */ interface SavedObjectsLegacyTypeSchema { isNamespaceAgnostic?: boolean; + /** Cannot be used in conjunction with `isNamespaceAgnostic` */ + multiNamespace?: boolean; hidden?: boolean; indexPattern?: ((config: LegacyConfig) => string) | string; convertToAliasScript?: string; diff --git a/src/core/server/saved_objects/utils.test.ts b/src/core/server/saved_objects/utils.test.ts index 0719fe7138e8a..033aeea7c018d 100644 --- a/src/core/server/saved_objects/utils.test.ts +++ b/src/core/server/saved_objects/utils.test.ts @@ -84,6 +84,16 @@ describe('convertLegacyTypes', () => { }, { pluginId: 'pluginB', + properties: { + typeB: { + properties: { + fieldB: { type: 'text' }, + }, + }, + }, + }, + { + pluginId: 'pluginC', properties: { typeC: { properties: { @@ -92,6 +102,16 @@ describe('convertLegacyTypes', () => { }, }, }, + { + pluginId: 'pluginD', + properties: { + typeD: { + properties: { + fieldD: { type: 'text' }, + }, + }, + }, + }, ], savedObjectMigrations: {}, savedObjectSchemas: { @@ -100,6 +120,18 @@ describe('convertLegacyTypes', () => { hidden: true, isNamespaceAgnostic: true, }, + typeB: { + indexPattern: 'barBaz', + hidden: false, + multiNamespace: true, + }, + typeD: { + indexPattern: 'bazQux', + hidden: false, + // if both isNamespaceAgnostic and multiNamespace are true, the resulting namespaceType is 'agnostic' + isNamespaceAgnostic: true, + multiNamespace: true, + }, }, savedObjectValidations: {}, savedObjectsManagement: {}, @@ -372,29 +404,42 @@ describe('convertTypesToLegacySchema', () => { { name: 'typeA', hidden: false, - namespaceAgnostic: true, + namespaceType: 'agnostic', mappings: { properties: {} }, convertToAliasScript: 'some script', }, { name: 'typeB', hidden: true, - namespaceAgnostic: false, + namespaceType: 'single', indexPattern: 'myIndex', mappings: { properties: {} }, }, + { + name: 'typeC', + hidden: false, + namespaceType: 'multiple', + mappings: { properties: {} }, + }, ]; expect(convertTypesToLegacySchema(types)).toEqual({ typeA: { hidden: false, isNamespaceAgnostic: true, + multiNamespace: false, convertToAliasScript: 'some script', }, typeB: { hidden: true, isNamespaceAgnostic: false, + multiNamespace: false, indexPattern: 'myIndex', }, + typeC: { + hidden: false, + isNamespaceAgnostic: false, + multiNamespace: true, + }, }); }); }); diff --git a/src/core/server/saved_objects/utils.ts b/src/core/server/saved_objects/utils.ts index ea90efd8b9fbd..af7c08d1fbfcc 100644 --- a/src/core/server/saved_objects/utils.ts +++ b/src/core/server/saved_objects/utils.ts @@ -20,6 +20,7 @@ import { LegacyConfig } from '../legacy'; import { SavedObjectMigrationMap } from './migrations'; import { + SavedObjectsNamespaceType, SavedObjectsType, SavedObjectsLegacyUiExports, SavedObjectLegacyMigrationMap, @@ -48,10 +49,15 @@ export const convertLegacyTypes = ( const schema = savedObjectSchemas[type]; const migrations = savedObjectMigrations[type]; const management = savedObjectsManagement[type]; + const namespaceType = (schema?.isNamespaceAgnostic + ? 'agnostic' + : schema?.multiNamespace + ? 'multiple' + : 'single') as SavedObjectsNamespaceType; return { name: type, hidden: schema?.hidden ?? false, - namespaceAgnostic: schema?.isNamespaceAgnostic ?? false, + namespaceType, mappings, indexPattern: typeof schema?.indexPattern === 'function' @@ -76,7 +82,8 @@ export const convertTypesToLegacySchema = ( return { ...schema, [type.name]: { - isNamespaceAgnostic: type.namespaceAgnostic, + isNamespaceAgnostic: type.namespaceType === 'agnostic', + multiNamespace: type.namespaceType === 'multiple', hidden: type.hidden, indexPattern: type.indexPattern, convertToAliasScript: type.convertToAliasScript, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index f3e3b7736d8d3..dd08df06c73d2 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -632,7 +632,9 @@ export interface CoreSetup; // (undocumented) - http: HttpServiceSetup; + http: HttpServiceSetup & { + resources: HttpResources; + }; // (undocumented) metrics: MetricsServiceSetup; // (undocumented) @@ -861,6 +863,30 @@ export type Headers = { [header: string]: string | string[] | undefined; }; +// @public +export interface HttpResources { + register: (route: RouteConfig, handler: HttpResourcesRequestHandler) => void; +} + +// @public +export interface HttpResourcesRenderOptions { + headers?: ResponseHeaders; +} + +// @public +export type HttpResourcesRequestHandler

= RequestHandler; + +// @public +export type HttpResourcesResponseOptions = HttpResponseOptions; + +// @public +export interface HttpResourcesServiceToolkit { + renderAnonymousCoreApp: (options?: HttpResourcesRenderOptions) => Promise; + renderCoreApp: (options?: HttpResourcesRenderOptions) => Promise; + renderHtml: (options: HttpResourcesResponseOptions) => IKibanaResponse; + renderJs: (options: HttpResourcesResponseOptions) => IKibanaResponse; +} + // @public export interface HttpResponseOptions { body?: HttpResponsePayload; @@ -989,7 +1015,7 @@ export interface IRouter { // // @internal getRoutes: () => RouterRoute[]; - handleLegacyErrors: (handler: RequestHandler) => RequestHandler; + handleLegacyErrors: RequestHandlerWrapper; patch: RouteRegistrar<'patch'>; post: RouteRegistrar<'post'>; put: RouteRegistrar<'put'>; @@ -1003,16 +1029,11 @@ export type IsAuthenticated = (request: KibanaRequest | LegacyRequest) => boolea export type ISavedObjectsRepository = Pick; // @public -export type ISavedObjectTypeRegistry = Pick; +export type ISavedObjectTypeRegistry = Omit; // @public export type IScopedClusterClient = Pick; -// @public (undocumented) -export interface IScopedRenderingClient { - render(options?: Pick): Promise; -} - // @public export interface IUiSettingsClient { get: (key: string) => Promise; @@ -1150,6 +1171,10 @@ export interface LegacyServiceSetupDeps { core: LegacyCoreSetup; // (undocumented) plugins: Record; + // Warning: (ae-forgotten-export) The symbol "UiPlugins" needs to be exported by the entry point index.d.ts + // + // (undocumented) + uiPlugins: UiPlugins; } // @public @deprecated (undocumented) @@ -1466,12 +1491,6 @@ export type PluginOpaqueId = symbol; export interface PluginsServiceSetup { contracts: Map; initialized: boolean; - // (undocumented) - uiPlugins: { - internal: Map; - public: Map; - browserConfigs: Map>; - }; } // @internal (undocumented) @@ -1496,19 +1515,13 @@ export type RedirectResponseOptions = HttpResponseOptions & { }; }; -// @internal (undocumented) -export interface RenderingServiceSetup { - render(request: R, uiSettings: IUiSettingsClient, options?: IRenderOptions): Promise; -} - // @public -export type RequestHandler

= (context: RequestHandlerContext, request: KibanaRequest, response: KibanaResponseFactory) => IKibanaResponse | Promise>; +export type RequestHandler

= (context: RequestHandlerContext, request: KibanaRequest, response: ResponseFactory) => IKibanaResponse | Promise>; // @public export interface RequestHandlerContext { // (undocumented) core: { - rendering: IScopedRenderingClient; savedObjects: { client: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; @@ -1529,6 +1542,9 @@ export type RequestHandlerContextContainer = IContextContainer = IContextProvider, TContextName>; +// @public +export type RequestHandlerWrapper = (handler: RequestHandler) => RequestHandler; + // @public export function resolveSavedObjectsImportErrors({ readStream, objectLimit, retries, savedObjectsClient, supportedTypes, namespace, }: SavedObjectsResolveImportErrorsOptions): Promise; @@ -1542,11 +1558,7 @@ export type ResponseError = string | Error | { export type ResponseErrorAttributes = Record; // @public -export type ResponseHeaders = { - [header in KnownHeaders]?: string | string[]; -} & { - [header: string]: string | string[]; -}; +export type ResponseHeaders = Record | Record; // @public export interface RouteConfig { @@ -1643,6 +1655,7 @@ export interface SavedObject { }; id: string; migrationVersion?: SavedObjectsMigrationVersion; + namespaces?: string[]; references: SavedObjectReference[]; type: string; updated_at?: string; @@ -1687,6 +1700,12 @@ export interface SavedObjectReference { type: string; } +// @public (undocumented) +export interface SavedObjectsAddToNamespacesOptions extends SavedObjectsBaseOptions { + refresh?: MutatingOperationRefreshSetting; + version?: string; +} + // Warning: (ae-forgotten-export) The symbol "SavedObjectDoc" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "Referencable" needs to be exported by the entry point index.d.ts // @@ -1754,11 +1773,13 @@ export interface SavedObjectsBulkUpdateResponse { export class SavedObjectsClient { // @internal constructor(repository: ISavedObjectsRepository); + addToNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsAddToNamespacesOptions): Promise<{}>; bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; + deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise<{}>; // (undocumented) static errors: typeof SavedObjectsErrorHelpers; // (undocumented) @@ -1839,6 +1860,11 @@ export interface SavedObjectsDeleteByNamespaceOptions extends SavedObjectsBaseOp refresh?: MutatingOperationRefreshSetting; } +// @public (undocumented) +export interface SavedObjectsDeleteFromNamespacesOptions extends SavedObjectsBaseOptions { + refresh?: MutatingOperationRefreshSetting; +} + // @public (undocumented) export interface SavedObjectsDeleteOptions extends SavedObjectsBaseOptions { refresh?: MutatingOperationRefreshSetting; @@ -1849,6 +1875,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static createBadRequestError(reason?: string): DecoratedError; // (undocumented) + static createConflictError(type: string, id: string): DecoratedError; + // (undocumented) static createEsAutoCreateIndexError(): DecoratedError; // (undocumented) static createGenericNotFoundError(type?: string | null, id?: string | null): DecoratedError; @@ -1861,6 +1889,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static decorateConflictError(error: Error, reason?: string): DecoratedError; // (undocumented) + static decorateEsCannotExecuteScriptError(error: Error, reason?: string): DecoratedError; + // (undocumented) static decorateEsUnavailableError(error: Error, reason?: string): DecoratedError; // (undocumented) static decorateForbiddenError(error: Error, reason?: string): DecoratedError; @@ -1877,6 +1907,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static isEsAutoCreateIndexError(error: Error | DecoratedError): boolean; // (undocumented) + static isEsCannotExecuteScriptError(error: Error | DecoratedError): boolean; + // (undocumented) static isEsUnavailableError(error: Error | DecoratedError): boolean; // (undocumented) static isForbiddenError(error: Error | DecoratedError): boolean; @@ -2064,8 +2096,6 @@ export interface SavedObjectsLegacyService { // (undocumented) getScopedSavedObjectsClient: SavedObjectsClientProvider['getClient']; // (undocumented) - importAndExportableTypes: string[]; - // (undocumented) importExport: { objectLimit: number; importSavedObjects(options: SavedObjectsImportOptions): Promise; @@ -2106,6 +2136,9 @@ export interface SavedObjectsMigrationVersion { [pluginName: string]: string; } +// @public +export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; + // @public export interface SavedObjectsRawDoc { // (undocumented) @@ -2124,6 +2157,7 @@ export interface SavedObjectsRawDoc { // @public (undocumented) export class SavedObjectsRepository { + addToNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsAddToNamespacesOptions): Promise<{}>; bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; @@ -2134,6 +2168,7 @@ export class SavedObjectsRepository { static createRepository(migrator: KibanaMigrator, typeRegistry: SavedObjectTypeRegistry, indexName: string, callCluster: APICaller, extraTypes?: string[], injectedConstructor?: any): ISavedObjectsRepository; delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise; + deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise<{}>; // (undocumented) find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, }: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; @@ -2175,7 +2210,11 @@ export class SavedObjectsSchema { // (undocumented) isHiddenType(type: string): boolean; // (undocumented) + isMultiNamespace(type: string): boolean; + // (undocumented) isNamespaceAgnostic(type: string): boolean; + // (undocumented) + isSingleNamespace(type: string): boolean; } // @public @@ -2224,7 +2263,7 @@ export interface SavedObjectsType { mappings: SavedObjectsTypeMappingDefinition; migrations?: SavedObjectMigrationMap; name: string; - namespaceAgnostic: boolean; + namespaceType: SavedObjectsNamespaceType; } // @public @@ -2269,7 +2308,9 @@ export class SavedObjectTypeRegistry { getType(type: string): SavedObjectsType | undefined; isHidden(type: string): boolean; isImportableAndExportable(type: string): boolean; + isMultiNamespace(type: string): boolean; isNamespaceAgnostic(type: string): boolean; + isSingleNamespace(type: string): boolean; registerType(type: SavedObjectsType): void; } @@ -2432,7 +2473,6 @@ export const validBodyOutput: readonly ["data", "stream"]; // src/core/server/legacy/types.ts:164:3 - (ae-forgotten-export) The symbol "LegacyNavLinkSpec" needs to be exported by the entry point index.d.ts // src/core/server/legacy/types.ts:165:3 - (ae-forgotten-export) The symbol "LegacyAppSpec" needs to be exported by the entry point index.d.ts // src/core/server/legacy/types.ts:166:16 - (ae-forgotten-export) The symbol "LegacyPluginSpec" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/plugins_service.ts:47:5 - (ae-forgotten-export) The symbol "InternalPluginInfo" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:230:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:230:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:232:3 - (ae-forgotten-export) The symbol "PathConfigType" needs to be exported by the entry point index.d.ts diff --git a/src/core/server/server.test.ts b/src/core/server/server.test.ts index 24c41d511180a..1e3e1638cf2a0 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -46,7 +46,10 @@ const rawConfigService = rawConfigServiceMock.create({}); beforeEach(() => { mockConfigService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: true })); - mockPluginsService.discover.mockResolvedValue(new Map()); + mockPluginsService.discover.mockResolvedValue({ + pluginTree: new Map(), + uiPlugins: { internal: new Map(), public: new Map(), browserConfigs: new Map() }, + }); }); afterEach(() => { @@ -88,7 +91,10 @@ test('injects legacy dependency to context#setup()', async () => { [pluginA, []], [pluginB, [pluginA]], ]); - mockPluginsService.discover.mockResolvedValue(pluginDependencies); + mockPluginsService.discover.mockResolvedValue({ + pluginTree: pluginDependencies, + uiPlugins: { internal: new Map(), public: new Map(), browserConfigs: new Map() }, + }); await server.setup(); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 07ea431dd3a0d..d4c0ebcfb7cf2 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -29,7 +29,8 @@ import { import { CoreApp } from './core_app'; import { ElasticsearchService } from './elasticsearch'; import { HttpService } from './http'; -import { RenderingService, RenderingServiceSetup } from './rendering'; +import { HttpResourcesService } from './http_resources'; +import { RenderingService } from './rendering'; import { LegacyService, ensureValidConfiguration } from './legacy'; import { Logger, LoggerFactory } from './logging'; import { UiSettingsService } from './ui_settings'; @@ -71,6 +72,7 @@ export class Server { private readonly uiSettings: UiSettingsService; private readonly uuid: UuidService; private readonly metrics: MetricsService; + private readonly httpResources: HttpResourcesService; private readonly status: StatusService; private readonly coreApp: CoreApp; @@ -99,13 +101,14 @@ export class Server { this.metrics = new MetricsService(core); this.status = new StatusService(core); this.coreApp = new CoreApp(core); + this.httpResources = new HttpResourcesService(core); } public async setup() { this.log.debug('setting up server'); // Discover any plugins before continuing. This allows other systems to utilize the plugin dependency graph. - const pluginDependencies = await this.plugins.discover(); + const { pluginTree, uiPlugins } = await this.plugins.discover(); const legacyPlugins = await this.legacy.discoverPlugins(); // Immediately terminate in case of invalid configuration @@ -117,10 +120,7 @@ export class Server { // 1) Can access context from any NP plugin // 2) Can register context providers that will only be available to other legacy plugins and will not leak into // New Platform plugins. - pluginDependencies: new Map([ - ...pluginDependencies, - [this.legacy.legacyId, [...pluginDependencies.keys()]], - ]), + pluginDependencies: new Map([...pluginTree, [this.legacy.legacyId, [...pluginTree.keys()]]]), }); const uuidSetup = await this.uuid.setup(); @@ -148,6 +148,17 @@ export class Server { const metricsSetup = await this.metrics.setup({ http: httpSetup }); + const renderingSetup = await this.rendering.setup({ + http: httpSetup, + legacyPlugins, + uiPlugins, + }); + + const httpResourcesSetup = this.httpResources.setup({ + http: httpSetup, + rendering: renderingSetup, + }); + const statusSetup = this.status.setup({ elasticsearch: elasticsearchServiceSetup, savedObjects: savedObjectsSetup, @@ -158,28 +169,25 @@ export class Server { context: contextServiceSetup, elasticsearch: elasticsearchServiceSetup, http: httpSetup, - metrics: metricsSetup, savedObjects: savedObjectsSetup, status: statusSetup, uiSettings: uiSettingsSetup, uuid: uuidSetup, + metrics: metricsSetup, + rendering: renderingSetup, + httpResources: httpResourcesSetup, }; const pluginsSetup = await this.plugins.setup(coreSetup); this.pluginsInitialized = pluginsSetup.initialized; - const renderingSetup = await this.rendering.setup({ - http: httpSetup, - legacyPlugins, - plugins: pluginsSetup, - }); - await this.legacy.setup({ core: { ...coreSetup, plugins: pluginsSetup, rendering: renderingSetup }, plugins: mapToObject(pluginsSetup.contracts), + uiPlugins, }); - this.registerCoreContext(coreSetup, renderingSetup); + this.registerCoreContext(coreSetup); this.coreApp.setup(coreSetup); return coreSetup; @@ -201,7 +209,7 @@ export class Server { uiSettings: uiSettingsStart, }; - const pluginsStart = await this.plugins.start(this.coreStart!); + const pluginsStart = await this.plugins.start(this.coreStart); await this.legacy.start({ core: { @@ -212,7 +220,9 @@ export class Server { }); await this.http.start(); - await this.rendering.start(); + await this.rendering.start({ + legacy: this.legacy, + }); await this.metrics.start(); return this.coreStart; @@ -232,7 +242,7 @@ export class Server { await this.status.stop(); } - private registerCoreContext(coreSetup: InternalCoreSetup, rendering: RenderingServiceSetup) { + private registerCoreContext(coreSetup: InternalCoreSetup) { coreSetup.http.registerRouteHandlerContext( coreId, 'core', @@ -241,13 +251,6 @@ export class Server { const uiSettingsClient = coreSetup.uiSettings.asScopedToClient(savedObjectsClient); return { - rendering: { - render: async (options = {}) => - rendering.render(req, uiSettingsClient, { - ...options, - vars: await this.legacy.legacyInternals!.getVars('core', req), - }), - }, savedObjects: { client: savedObjectsClient, typeRegistry: this.coreStart!.savedObjects.getTypeRegistry(), diff --git a/src/core/server/ui_settings/saved_objects/ui_settings.ts b/src/core/server/ui_settings/saved_objects/ui_settings.ts index 031315bec0dab..1bea65ddee924 100644 --- a/src/core/server/ui_settings/saved_objects/ui_settings.ts +++ b/src/core/server/ui_settings/saved_objects/ui_settings.ts @@ -22,7 +22,7 @@ import { SavedObjectsType } from '../../saved_objects'; export const uiSettingsType: SavedObjectsType = { name: 'config', hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', mappings: { // we don't want to allow `true` in the public `SavedObjectsTypeMappingDefinition` type, however // this is needed for the config that is kinda a special type. To avoid adding additional internal types diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index d3faab6c557cd..04aaacc3cf31a 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -96,4 +96,6 @@ export interface SavedObject { references: SavedObjectReference[]; /** {@inheritdoc SavedObjectsMigrationVersion} */ migrationVersion?: SavedObjectsMigrationVersion; + /** Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. */ + namespaces?: string[]; } diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js index a941735c7840e..7da14e0dfe51b 100644 --- a/src/dev/jest/config.js +++ b/src/dev/jest/config.js @@ -62,6 +62,7 @@ export default { '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '/src/dev/jest/mocks/file_mock.js', '\\.(css|less|scss)$': '/src/dev/jest/mocks/style_mock.js', + '\\.ace\\.worker.js$': '/src/dev/jest/mocks/ace_worker_module_mock.js', }, setupFiles: [ '/src/dev/jest/setup/babel_polyfill.js', diff --git a/src/dev/jest/mocks/ace_worker_module_mock.js b/src/dev/jest/mocks/ace_worker_module_mock.js new file mode 100644 index 0000000000000..9d267f494f8bf --- /dev/null +++ b/src/dev/jest/mocks/ace_worker_module_mock.js @@ -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. + */ + +module.exports = ''; diff --git a/src/legacy/core_plugins/kibana/index.js b/src/legacy/core_plugins/kibana/index.js index 1d643418997f5..989583742acd0 100644 --- a/src/legacy/core_plugins/kibana/index.js +++ b/src/legacy/core_plugins/kibana/index.js @@ -54,11 +54,7 @@ export default function(kibana) { }, uiExports: { - hacks: [ - 'plugins/kibana/discover/legacy', - 'plugins/kibana/dev_tools', - 'plugins/kibana/visualize/legacy', - ], + hacks: ['plugins/kibana/discover/legacy', 'plugins/kibana/dev_tools'], app: { id: 'kibana', title: 'Kibana', diff --git a/src/legacy/core_plugins/kibana/inject_vars.js b/src/legacy/core_plugins/kibana/inject_vars.js index 76d1704907ab5..c3b906ee842e3 100644 --- a/src/legacy/core_plugins/kibana/inject_vars.js +++ b/src/legacy/core_plugins/kibana/inject_vars.js @@ -20,10 +20,7 @@ export function injectVars(server) { const serverConfig = server.config(); - const { importAndExportableTypes } = server.savedObjects; - return { - importAndExportableTypes, autocompleteTerminateAfter: serverConfig.get('kibana.autocompleteTerminateAfter'), autocompleteTimeout: serverConfig.get('kibana.autocompleteTimeout'), }; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html index d068e824a3e0a..1221b01657e45 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html @@ -140,6 +140,7 @@

{{screenTitle}}

position="'top'" > { - registry.push(service); - }, - all: () => { - return registry; - }, - get: (id: string) => { - return _.find(registry, { id }); - }, -}; - -const services = { - savedObjectsClient: npStart.core.savedObjects.client, - indexPatterns: npStart.plugins.data.indexPatterns, - search: npStart.plugins.data.search, - chrome: npStart.core.chrome, - overlays: npStart.core.overlays, -}; - -savedObjectManagementRegistry.register({ - id: 'savedVisualizations', - service: npStart.plugins.visualizations.savedVisualizationsLoader, - title: 'visualizations', -}); - -savedObjectManagementRegistry.register({ - id: 'savedDashboards', - service: npStart.plugins.dashboard.getSavedDashboardLoader(), - title: i18n.translate('kbn.dashboard.savedDashboardsTitle', { - defaultMessage: 'dashboards', - }), -}); +const registry = npSetup.plugins.savedObjectsManagement?.serviceRegistry; -savedObjectManagementRegistry.register({ - id: 'savedSearches', - service: createSavedSearchesLoader(services), - title: 'searches', -}); +export const savedObjectManagementRegistry = registry!; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index.js b/src/legacy/core_plugins/kibana/public/management/sections/index.js index 54717ad003ade..adc1741f57263 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/index.js @@ -17,5 +17,4 @@ * under the License. */ -import './objects'; import './index_patterns'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/create_edit_field/create_edit_field.html b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/create_edit_field/create_edit_field.html index fee8525a6af41..2decaf423183e 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/create_edit_field/create_edit_field.html +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/create_edit_field/create_edit_field.html @@ -1,11 +1,5 @@
-
- -
-
diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/create_edit_field/create_edit_field.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/create_edit_field/create_edit_field.js index 0dcf778a5a662..95d6cb6878e53 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/create_edit_field/create_edit_field.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/create_edit_field/create_edit_field.js @@ -34,6 +34,8 @@ import { FieldEditor } from 'ui/field_editor'; import { I18nContext } from 'ui/i18n'; import { i18n } from '@kbn/i18n'; +import { IndexHeader } from '../index_header'; + const REACT_FIELD_EDITOR_ID = 'reactFieldEditor'; const renderFieldEditor = ( $scope, @@ -49,11 +51,11 @@ const renderFieldEditor = ( render( + - +

diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js index 594430ca01f4c..69184a513f53a 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js @@ -18,7 +18,7 @@ */ import _ from 'lodash'; -import './index_header'; +import { IndexHeader } from './index_header'; import './create_edit_field'; import { docTitle } from 'ui/doc_title'; import { KbnUrlProvider } from 'ui/url'; @@ -44,11 +44,14 @@ import { createEditIndexPatternPageStateContainer } from './edit_index_pattern_s const REACT_SOURCE_FILTERS_DOM_ELEMENT_ID = 'reactSourceFiltersTable'; const REACT_INDEXED_FIELDS_DOM_ELEMENT_ID = 'reactIndexedFieldsTable'; const REACT_SCRIPTED_FIELDS_DOM_ELEMENT_ID = 'reactScriptedFieldsTable'; +const REACT_INDEX_HEADER_DOM_ELEMENT_ID = 'reactIndexHeader'; const TAB_INDEXED_FIELDS = 'indexedFields'; const TAB_SCRIPTED_FIELDS = 'scriptedFields'; const TAB_SOURCE_FILTERS = 'sourceFilters'; +const EDIT_FIELD_PATH = '/management/kibana/index_patterns/{{indexPattern.id}}/field/{{name}}'; + function updateSourceFiltersTable($scope) { $scope.$$postDigest(() => { const node = document.getElementById(REACT_SOURCE_FILTERS_DOM_ELEMENT_ID); @@ -97,8 +100,8 @@ function updateScriptedFieldsTable($scope) { fieldFilter={$scope.fieldFilter} scriptedFieldLanguageFilter={$scope.scriptedFieldLanguageFilter} helpers={{ - redirectToRoute: (obj, route) => { - $scope.kbnUrl.changeToRoute(obj, route); + redirectToRoute: field => { + $scope.kbnUrl.changePath(EDIT_FIELD_PATH, field); $scope.$apply(); }, getRouteHref: (obj, route) => $scope.kbnUrl.getRouteHref(obj, route), @@ -140,8 +143,8 @@ function updateIndexedFieldsTable($scope) { fieldWildcardMatcher={$scope.fieldWildcardMatcher} indexedFieldTypeFilter={$scope.indexedFieldTypeFilter} helpers={{ - redirectToRoute: (obj, route) => { - $scope.kbnUrl.changeToRoute(obj, route); + redirectToRoute: field => { + $scope.kbnUrl.changePath(EDIT_FIELD_PATH, field); $scope.$apply(); }, getFieldInfo: $scope.getFieldInfo, @@ -158,6 +161,33 @@ function destroyIndexedFieldsTable() { node && unmountComponentAtNode(node); } +function destroyIndexHeader() { + const node = document.getElementById(REACT_INDEX_HEADER_DOM_ELEMENT_ID); + node && unmountComponentAtNode(node); +} + +function renderIndexHeader($scope, config) { + $scope.$$postDigest(() => { + const node = document.getElementById(REACT_INDEX_HEADER_DOM_ELEMENT_ID); + if (!node) { + return; + } + + render( + + + , + node + ); + }); +} + function handleTabChange($scope, newTab) { destroyIndexedFieldsTable(); destroySourceFiltersTable(); @@ -389,6 +419,9 @@ uiModules destroyIndexedFieldsTable(); destroyScriptedFieldsTable(); destroySourceFiltersTable(); + destroyIndexHeader(); destroyState(); }); + + renderIndexHeader($scope, config); }); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index_header/index.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index_header/index.js deleted file mode 100644 index 7c288286bd61e..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index_header/index.js +++ /dev/null @@ -1,20 +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 './index_header'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index_header/index.ts b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index_header/index.ts new file mode 100644 index 0000000000000..44c05a55b36f9 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index_header/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 { IndexHeader } from './index_header'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index_header/index_header.html b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index_header/index_header.html deleted file mode 100644 index d6b91d96f13d3..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index_header/index_header.html +++ /dev/null @@ -1,59 +0,0 @@ -

-
- -

- - {{indexPattern.title}} -

-
- -
- - - - - -
-
diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index_header/index_header.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index_header/index_header.js deleted file mode 100644 index 87bce06c1146c..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index_header/index_header.js +++ /dev/null @@ -1,40 +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 { uiModules } from 'ui/modules'; -import template from './index_header.html'; -uiModules.get('apps/management').directive('kbnManagementIndexPatternsHeader', function(config) { - return { - restrict: 'E', - template, - replace: true, - scope: { - indexPattern: '=', - setDefault: '&', - refreshFields: '&', - delete: '&', - }, - link: function($scope, $el, attrs) { - $scope.delete = attrs.delete ? $scope.delete : null; - $scope.setDefault = attrs.setDefault ? $scope.setDefault : null; - $scope.refreshFields = attrs.refreshFields ? $scope.refreshFields : null; - config.bindToScope($scope, 'defaultIndex'); - }, - }; -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index_header/index_header.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index_header/index_header.tsx new file mode 100644 index 0000000000000..866d10ecb0e19 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index_header/index_header.tsx @@ -0,0 +1,134 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { + EuiFlexGroup, + EuiToolTip, + EuiFlexItem, + EuiIcon, + EuiTitle, + EuiButtonIcon, +} from '@elastic/eui'; +import { IIndexPattern } from '../../../../../../../../../plugins/data/public'; + +interface IndexHeaderProps { + defaultIndex: string; + indexPattern: IIndexPattern; + setDefault?: () => void; + refreshFields?: () => void; + deleteIndexPattern?: () => void; +} + +const setDefaultAriaLabel = i18n.translate('kbn.management.editIndexPattern.setDefaultAria', { + defaultMessage: 'Set as default index.', +}); + +const setDefaultTooltip = i18n.translate('kbn.management.editIndexPattern.setDefaultTooltip', { + defaultMessage: 'Set as default index.', +}); + +const refreshAriaLabel = i18n.translate('kbn.management.editIndexPattern.refreshAria', { + defaultMessage: 'Reload field list.', +}); + +const refreshTooltip = i18n.translate('kbn.management.editIndexPattern.refreshTooltip', { + defaultMessage: 'Refresh field list.', +}); + +const removeAriaLabel = i18n.translate('kbn.management.editIndexPattern.removeAria', { + defaultMessage: 'Remove index pattern.', +}); + +const removeTooltip = i18n.translate('kbn.management.editIndexPattern.removeTooltip', { + defaultMessage: 'Remove index pattern.', +}); + +export function IndexHeader({ + defaultIndex, + indexPattern, + setDefault, + refreshFields, + deleteIndexPattern, +}: IndexHeaderProps) { + return ( + + + + {defaultIndex === indexPattern.id && ( + + + + )} + + +

{indexPattern.title}

+
+
+
+
+ + + {setDefault && ( + + + + + + )} + + {refreshFields && ( + + + + + + )} + + {deleteIndexPattern && ( + + + + + + )} + + +
+ ); +} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/__jest__/__snapshots__/indexed_fields_table.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/__jest__/__snapshots__/indexed_fields_table.test.js.snap deleted file mode 100644 index dc77fe6c8a69d..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/__jest__/__snapshots__/indexed_fields_table.test.js.snap +++ /dev/null @@ -1,102 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`IndexedFieldsTable should filter based on the query bar 1`] = ` -
- - -`; - -exports[`IndexedFieldsTable should filter based on the type filter 1`] = ` -
-
- -`; - -exports[`IndexedFieldsTable should render normally 1`] = ` -
-
- -`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/__jest__/indexed_fields_table.test.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/__jest__/indexed_fields_table.test.js deleted file mode 100644 index 26e271ea477da..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/__jest__/indexed_fields_table.test.js +++ /dev/null @@ -1,103 +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 React from 'react'; -import { shallow } from 'enzyme'; - -import { IndexedFieldsTable } from '../indexed_fields_table'; - -jest.mock('@elastic/eui', () => ({ - EuiFlexGroup: 'eui-flex-group', - EuiFlexItem: 'eui-flex-item', - EuiIcon: 'eui-icon', - EuiInMemoryTable: 'eui-in-memory-table', -})); - -jest.mock('../components/table', () => ({ - // Note: this seems to fix React complaining about non lowercase attributes - Table: () => { - return 'table'; - }, -})); - -const helpers = { - redirectToRoute: () => {}, -}; - -const fields = [ - { name: 'Elastic', displayName: 'Elastic', searchable: true }, - { name: 'timestamp', displayName: 'timestamp', type: 'date' }, - { name: 'conflictingField', displayName: 'conflictingField', type: 'conflict' }, -]; - -const indexPattern = { - getNonScriptedFields: () => fields, -}; - -describe('IndexedFieldsTable', () => { - it('should render normally', async () => { - const component = shallow( - {}} - /> - ); - - await new Promise(resolve => process.nextTick(resolve)); - component.update(); - - expect(component).toMatchSnapshot(); - }); - - it('should filter based on the query bar', async () => { - const component = shallow( - {}} - /> - ); - - await new Promise(resolve => process.nextTick(resolve)); - component.setProps({ fieldFilter: 'Elast' }); - component.update(); - - expect(component).toMatchSnapshot(); - }); - - it('should filter based on the type filter', async () => { - const component = shallow( - {}} - /> - ); - - await new Promise(resolve => process.nextTick(resolve)); - component.setProps({ indexedFieldTypeFilter: 'date' }); - component.update(); - - expect(component).toMatchSnapshot(); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/__snapshots__/indexed_fields_table.test.tsx.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/__snapshots__/indexed_fields_table.test.tsx.snap new file mode 100644 index 0000000000000..db2a032b1e4d9 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/__snapshots__/indexed_fields_table.test.tsx.snap @@ -0,0 +1,99 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IndexedFieldsTable should filter based on the query bar 1`] = ` +
+
+ +`; + +exports[`IndexedFieldsTable should filter based on the type filter 1`] = ` +
+
+ +`; + +exports[`IndexedFieldsTable should render normally 1`] = ` +
+
+ +`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/__jest__/__snapshots__/table.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/__jest__/__snapshots__/table.test.js.snap deleted file mode 100644 index f3aa2c5da4b67..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/__jest__/__snapshots__/table.test.js.snap +++ /dev/null @@ -1,159 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Table should render conflicting type 1`] = ` - - conflict - -   - - - -`; - -exports[`Table should render normal field name 1`] = ` - - Elastic - -`; - -exports[`Table should render normal type 1`] = ` - - string - -`; - -exports[`Table should render normally 1`] = ` - -`; - -exports[`Table should render the boolean template (false) 1`] = ``; - -exports[`Table should render the boolean template (true) 1`] = ` -
-`; - -exports[`Table should render timestamp field name 1`] = ` - - timestamp - -   - - - -`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/__jest__/table.test.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/__jest__/table.test.js deleted file mode 100644 index 4fd9ef7485bdf..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/__jest__/table.test.js +++ /dev/null @@ -1,110 +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 React from 'react'; -import { shallow } from 'enzyme'; -import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; - -import { Table } from '../table'; - -const indexPattern = { - timeFieldName: 'timestamp', -}; - -const items = [ - { name: 'Elastic', displayName: 'Elastic', searchable: true, info: {} }, - { name: 'timestamp', displayName: 'timestamp', type: 'date', info: {} }, - { name: 'conflictingField', displayName: 'conflictingField', type: 'conflict', info: {} }, -]; - -describe('Table', () => { - it('should render normally', async () => { - const component = shallowWithI18nProvider( -
{}} /> - ); - - expect(component).toMatchSnapshot(); - }); - - it('should render normal field name', async () => { - const component = shallowWithI18nProvider( -
{}} /> - ); - - const tableCell = shallow(component.prop('columns')[0].render('Elastic', items[0])); - expect(tableCell).toMatchSnapshot(); - }); - - it('should render timestamp field name', async () => { - const component = shallowWithI18nProvider( -
{}} /> - ); - - const tableCell = shallow(component.prop('columns')[0].render('timestamp', items[1])); - expect(tableCell).toMatchSnapshot(); - }); - - it('should render the boolean template (true)', async () => { - const component = shallowWithI18nProvider( -
{}} /> - ); - - const tableCell = shallow(component.prop('columns')[3].render(true)); - expect(tableCell).toMatchSnapshot(); - }); - - it('should render the boolean template (false)', async () => { - const component = shallowWithI18nProvider( -
{}} /> - ); - - const tableCell = shallow(component.prop('columns')[3].render(false, items[2])); - expect(tableCell).toMatchSnapshot(); - }); - - it('should render normal type', async () => { - const component = shallowWithI18nProvider( -
{}} /> - ); - - const tableCell = shallow(component.prop('columns')[1].render('string')); - expect(tableCell).toMatchSnapshot(); - }); - - it('should render conflicting type', async () => { - const component = shallowWithI18nProvider( -
{}} /> - ); - - const tableCell = shallow(component.prop('columns')[1].render('conflict', true)); - expect(tableCell).toMatchSnapshot(); - }); - - it('should allow edits', () => { - const editField = jest.fn(); - - const component = shallowWithI18nProvider( -
- ); - - // Click the edit button - component.prop('columns')[6].actions[0].onClick(); - expect(editField).toBeCalled(); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/__snapshots__/table.test.tsx.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/__snapshots__/table.test.tsx.snap new file mode 100644 index 0000000000000..2d51b1722cfb2 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/__snapshots__/table.test.tsx.snap @@ -0,0 +1,166 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Table should render conflicting type 1`] = ` + + conflict + +   + + + +`; + +exports[`Table should render normal field name 1`] = ` + + Elastic + +`; + +exports[`Table should render normal type 1`] = ` + + string + +`; + +exports[`Table should render normally 1`] = ` + +`; + +exports[`Table should render the boolean template (false) 1`] = ``; + +exports[`Table should render the boolean template (true) 1`] = ` +
+`; + +exports[`Table should render timestamp field name 1`] = ` + + timestamp + +   + + + +`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/index.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/index.js rename to src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/index.ts diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/table.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/table.js deleted file mode 100644 index 29e160cf1c182..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/table.js +++ /dev/null @@ -1,231 +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 React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; - -import { EuiIcon, EuiInMemoryTable, EuiIconTip } from '@elastic/eui'; - -import { i18n } from '@kbn/i18n'; - -export class Table extends PureComponent { - static propTypes = { - indexPattern: PropTypes.object.isRequired, - items: PropTypes.array.isRequired, - editField: PropTypes.func.isRequired, - }; - - renderBooleanTemplate(value, label) { - return value ? : ; - } - - renderFieldName(name, field) { - const { indexPattern } = this.props; - - const infoLabel = i18n.translate( - 'kbn.management.editIndexPattern.fields.table.additionalInfoAriaLabel', - { defaultMessage: 'Additional field information' } - ); - const timeLabel = i18n.translate( - 'kbn.management.editIndexPattern.fields.table.primaryTimeAriaLabel', - { defaultMessage: 'Primary time field' } - ); - const timeContent = i18n.translate( - 'kbn.management.editIndexPattern.fields.table.primaryTimeTooltip', - { defaultMessage: 'This field represents the time that events occurred.' } - ); - - return ( - - {name} - {field.info && field.info.length ? ( - -   - ( -
{info}
- ))} - /> -
- ) : null} - {indexPattern.timeFieldName === name ? ( - -   - - - ) : null} -
- ); - } - - renderFieldType(type, isConflict) { - const label = i18n.translate('kbn.management.editIndexPattern.fields.table.multiTypeAria', { - defaultMessage: 'Multiple type field', - }); - const content = i18n.translate( - 'kbn.management.editIndexPattern.fields.table.multiTypeTooltip', - { - defaultMessage: - 'The type of this field changes across indices. It is unavailable for many analysis functions.', - } - ); - - return ( - - {type} - {isConflict ? ( - -   - - - ) : ( - '' - )} - - ); - } - - render() { - const { items, editField } = this.props; - - const pagination = { - initialPageSize: 10, - pageSizeOptions: [5, 10, 25, 50], - }; - - const columns = [ - { - field: 'displayName', - name: i18n.translate('kbn.management.editIndexPattern.fields.table.nameHeader', { - defaultMessage: 'Name', - }), - dataType: 'string', - sortable: true, - render: (value, field) => { - return this.renderFieldName(value, field); - }, - width: '38%', - 'data-test-subj': 'indexedFieldName', - }, - { - field: 'type', - name: i18n.translate('kbn.management.editIndexPattern.fields.table.typeHeader', { - defaultMessage: 'Type', - }), - dataType: 'string', - sortable: true, - render: value => { - return this.renderFieldType(value, value === 'conflict'); - }, - 'data-test-subj': 'indexedFieldType', - }, - { - field: 'format', - name: i18n.translate('kbn.management.editIndexPattern.fields.table.formatHeader', { - defaultMessage: 'Format', - }), - dataType: 'string', - sortable: true, - }, - { - field: 'searchable', - name: i18n.translate('kbn.management.editIndexPattern.fields.table.searchableHeader', { - defaultMessage: 'Searchable', - }), - description: i18n.translate( - 'kbn.management.editIndexPattern.fields.table.searchableDescription', - { defaultMessage: 'These fields can be used in the filter bar' } - ), - dataType: 'boolean', - sortable: true, - render: value => - this.renderBooleanTemplate( - value, - i18n.translate('kbn.management.editIndexPattern.fields.table.isSearchableAria', { - defaultMessage: 'Is searchable', - }) - ), - }, - { - field: 'aggregatable', - name: i18n.translate('kbn.management.editIndexPattern.fields.table.aggregatableLabel', { - defaultMessage: 'Aggregatable', - }), - description: i18n.translate( - 'kbn.management.editIndexPattern.fields.table.aggregatableDescription', - { defaultMessage: 'These fields can be used in visualization aggregations' } - ), - dataType: 'boolean', - sortable: true, - render: value => - this.renderBooleanTemplate( - value, - i18n.translate('kbn.management.editIndexPattern.fields.table.isAggregatableAria', { - defaultMessage: 'Is aggregatable', - }) - ), - }, - { - field: 'excluded', - name: i18n.translate('kbn.management.editIndexPattern.fields.table.excludedLabel', { - defaultMessage: 'Excluded', - }), - description: i18n.translate( - 'kbn.management.editIndexPattern.fields.table.excludedDescription', - { defaultMessage: 'Fields that are excluded from _source when it is fetched' } - ), - dataType: 'boolean', - sortable: true, - render: value => - this.renderBooleanTemplate( - value, - i18n.translate('kbn.management.editIndexPattern.fields.table.isExcludedAria', { - defaultMessage: 'Is excluded', - }) - ), - }, - { - name: '', - actions: [ - { - name: i18n.translate('kbn.management.editIndexPattern.fields.table.editLabel', { - defaultMessage: 'Edit', - }), - description: i18n.translate( - 'kbn.management.editIndexPattern.fields.table.editDescription', - { defaultMessage: 'Edit' } - ), - icon: 'pencil', - onClick: editField, - type: 'icon', - 'data-test-subj': 'editFieldFormat', - }, - ], - width: '40px', - }, - ]; - - return ( - - ); - } -} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/table.test.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/table.test.tsx new file mode 100644 index 0000000000000..d0479a9a9e032 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/table.test.tsx @@ -0,0 +1,132 @@ +/* + * 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 { shallow } from 'enzyme'; +import { IIndexPattern } from '../../../../../../../../../../../plugins/data/public'; +import { IndexedFieldItem } from '../../types'; +import { Table } from './table'; + +const indexPattern = { + timeFieldName: 'timestamp', +} as IIndexPattern; + +const items: IndexedFieldItem[] = [ + { + name: 'Elastic', + displayName: 'Elastic', + searchable: true, + info: [], + type: 'name', + excluded: false, + format: '', + }, + { + name: 'timestamp', + displayName: 'timestamp', + type: 'date', + info: [], + excluded: false, + format: 'YYYY-MM-DD', + }, + { + name: 'conflictingField', + displayName: 'conflictingField', + type: 'conflict', + info: [], + excluded: false, + format: '', + }, +]; + +describe('Table', () => { + test('should render normally', () => { + const component = shallow( +
{}} /> + ); + + expect(component).toMatchSnapshot(); + }); + + test('should render normal field name', () => { + const component = shallow( +
{}} /> + ); + + const tableCell = shallow(component.prop('columns')[0].render('Elastic', items[0])); + expect(tableCell).toMatchSnapshot(); + }); + + test('should render timestamp field name', () => { + const component = shallow( +
{}} /> + ); + + const tableCell = shallow(component.prop('columns')[0].render('timestamp', items[1])); + expect(tableCell).toMatchSnapshot(); + }); + + test('should render the boolean template (true)', () => { + const component = shallow( +
{}} /> + ); + + const tableCell = shallow(component.prop('columns')[3].render(true)); + expect(tableCell).toMatchSnapshot(); + }); + + test('should render the boolean template (false)', () => { + const component = shallow( +
{}} /> + ); + + const tableCell = shallow(component.prop('columns')[3].render(false, items[2])); + expect(tableCell).toMatchSnapshot(); + }); + + test('should render normal type', () => { + const component = shallow( +
{}} /> + ); + + const tableCell = shallow(component.prop('columns')[1].render('string')); + expect(tableCell).toMatchSnapshot(); + }); + + test('should render conflicting type', () => { + const component = shallow( +
{}} /> + ); + + const tableCell = shallow(component.prop('columns')[1].render('conflict', true)); + expect(tableCell).toMatchSnapshot(); + }); + + test('should allow edits', () => { + const editField = jest.fn(); + + const component = shallow( +
+ ); + + // Click the edit button + component.prop('columns')[6].actions[0].onClick(); + expect(editField).toBeCalled(); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/table.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/table.tsx new file mode 100644 index 0000000000000..aa8e8b8e13a07 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/components/table/table.tsx @@ -0,0 +1,281 @@ +/* + * 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, { PureComponent } from 'react'; + +import { EuiIcon, EuiInMemoryTable, EuiIconTip, EuiBasicTableColumn } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { IIndexPattern } from '../../../../../../../../../../../plugins/data/public'; +import { IndexedFieldItem } from '../../types'; + +// localized labels +const additionalInfoAriaLabel = i18n.translate( + 'kbn.management.editIndexPattern.fields.table.additionalInfoAriaLabel', + { defaultMessage: 'Additional field information' } +); + +const primaryTimeAriaLabel = i18n.translate( + 'kbn.management.editIndexPattern.fields.table.primaryTimeAriaLabel', + { defaultMessage: 'Primary time field' } +); + +const primaryTimeTooltip = i18n.translate( + 'kbn.management.editIndexPattern.fields.table.primaryTimeTooltip', + { defaultMessage: 'This field represents the time that events occurred.' } +); + +const multiTypeAriaLabel = i18n.translate( + 'kbn.management.editIndexPattern.fields.table.multiTypeAria', + { + defaultMessage: 'Multiple type field', + } +); + +const multiTypeTooltip = i18n.translate( + 'kbn.management.editIndexPattern.fields.table.multiTypeTooltip', + { + defaultMessage: + 'The type of this field changes across indices. It is unavailable for many analysis functions.', + } +); + +const nameHeader = i18n.translate('kbn.management.editIndexPattern.fields.table.nameHeader', { + defaultMessage: 'Name', +}); + +const typeHeader = i18n.translate('kbn.management.editIndexPattern.fields.table.typeHeader', { + defaultMessage: 'Type', +}); + +const formatHeader = i18n.translate('kbn.management.editIndexPattern.fields.table.formatHeader', { + defaultMessage: 'Format', +}); + +const searchableHeader = i18n.translate( + 'kbn.management.editIndexPattern.fields.table.searchableHeader', + { + defaultMessage: 'Searchable', + } +); + +const searchableDescription = i18n.translate( + 'kbn.management.editIndexPattern.fields.table.searchableDescription', + { defaultMessage: 'These fields can be used in the filter bar' } +); + +const isSearchableAriaLabel = i18n.translate( + 'kbn.management.editIndexPattern.fields.table.isSearchableAria', + { + defaultMessage: 'Is searchable', + } +); + +const aggregatableLabel = i18n.translate( + 'kbn.management.editIndexPattern.fields.table.aggregatableLabel', + { + defaultMessage: 'Aggregatable', + } +); + +const aggregatableDescription = i18n.translate( + 'kbn.management.editIndexPattern.fields.table.aggregatableDescription', + { defaultMessage: 'These fields can be used in visualization aggregations' } +); + +const isAggregatableAriaLabel = i18n.translate( + 'kbn.management.editIndexPattern.fields.table.isAggregatableAria', + { + defaultMessage: 'Is aggregatable', + } +); + +const excludedLabel = i18n.translate('kbn.management.editIndexPattern.fields.table.excludedLabel', { + defaultMessage: 'Excluded', +}); + +const excludedDescription = i18n.translate( + 'kbn.management.editIndexPattern.fields.table.excludedDescription', + { defaultMessage: 'Fields that are excluded from _source when it is fetched' } +); + +const isExcludedAriaLabel = i18n.translate( + 'kbn.management.editIndexPattern.fields.table.isExcludedAria', + { + defaultMessage: 'Is excluded', + } +); + +const editLabel = i18n.translate('kbn.management.editIndexPattern.fields.table.editLabel', { + defaultMessage: 'Edit', +}); + +const editDescription = i18n.translate( + 'kbn.management.editIndexPattern.fields.table.editDescription', + { defaultMessage: 'Edit' } +); + +interface IndexedFieldProps { + indexPattern: IIndexPattern; + items: IndexedFieldItem[]; + editField: (field: IndexedFieldItem) => void; +} + +export class Table extends PureComponent { + renderBooleanTemplate(value: string, arialLabel: string) { + return value ? : ; + } + + renderFieldName(name: string, field: IndexedFieldItem) { + const { indexPattern } = this.props; + + return ( + + {name} + {field.info && field.info.length ? ( + +   + ( +
{info}
+ ))} + /> +
+ ) : null} + {indexPattern.timeFieldName === name ? ( + +   + + + ) : null} +
+ ); + } + + renderFieldType(type: string, isConflict: boolean) { + return ( + + {type} + {isConflict ? ( + +   + + + ) : ( + '' + )} + + ); + } + + render() { + const { items, editField } = this.props; + + const pagination = { + initialPageSize: 10, + pageSizeOptions: [5, 10, 25, 50], + }; + + const columns: Array> = [ + { + field: 'displayName', + name: nameHeader, + dataType: 'string', + sortable: true, + render: (value: string, field: IndexedFieldItem) => { + return this.renderFieldName(value, field); + }, + width: '38%', + 'data-test-subj': 'indexedFieldName', + }, + { + field: 'type', + name: typeHeader, + dataType: 'string', + sortable: true, + render: (value: string) => { + return this.renderFieldType(value, value === 'conflict'); + }, + 'data-test-subj': 'indexedFieldType', + }, + { + field: 'format', + name: formatHeader, + dataType: 'string', + sortable: true, + }, + { + field: 'searchable', + name: searchableHeader, + description: searchableDescription, + dataType: 'boolean', + sortable: true, + render: (value: string) => this.renderBooleanTemplate(value, isSearchableAriaLabel), + }, + { + field: 'aggregatable', + name: aggregatableLabel, + description: aggregatableDescription, + dataType: 'boolean', + sortable: true, + render: (value: string) => this.renderBooleanTemplate(value, isAggregatableAriaLabel), + }, + { + field: 'excluded', + name: excludedLabel, + description: excludedDescription, + dataType: 'boolean', + sortable: true, + render: (value: string) => this.renderBooleanTemplate(value, isExcludedAriaLabel), + }, + { + name: '', + actions: [ + { + name: editLabel, + description: editDescription, + icon: 'pencil', + onClick: editField, + type: 'icon', + 'data-test-subj': 'editFieldFormat', + }, + ], + width: '40px', + }, + ]; + + return ( + + ); + } +} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/index.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/index.js rename to src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/index.ts diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/indexed_fields_table.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/indexed_fields_table.js deleted file mode 100644 index 652efbe98067f..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/indexed_fields_table.js +++ /dev/null @@ -1,112 +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 React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { createSelector } from 'reselect'; - -import { Table } from './components/table'; -import { getFieldFormat } from './lib'; - -export class IndexedFieldsTable extends Component { - static propTypes = { - fields: PropTypes.array.isRequired, - indexPattern: PropTypes.object.isRequired, - fieldFilter: PropTypes.string, - indexedFieldTypeFilter: PropTypes.string, - helpers: PropTypes.shape({ - redirectToRoute: PropTypes.func.isRequired, - getFieldInfo: PropTypes.func, - }), - fieldWildcardMatcher: PropTypes.func.isRequired, - }; - - constructor(props) { - super(props); - - this.state = { - fields: this.mapFields(this.props.fields), - }; - } - - UNSAFE_componentWillReceiveProps(nextProps) { - if (nextProps.fields !== this.props.fields) { - this.setState({ - fields: this.mapFields(nextProps.fields), - }); - } - } - - mapFields(fields) { - const { indexPattern, fieldWildcardMatcher, helpers } = this.props; - const sourceFilters = - indexPattern.sourceFilters && indexPattern.sourceFilters.map(f => f.value); - const fieldWildcardMatch = fieldWildcardMatcher(sourceFilters || []); - - return ( - (fields && - fields.map(field => { - return { - ...field, - displayName: field.displayName, - routes: field.routes, - indexPattern: field.indexPattern, - format: getFieldFormat(indexPattern, field.name), - excluded: fieldWildcardMatch ? fieldWildcardMatch(field.name) : false, - info: helpers.getFieldInfo && helpers.getFieldInfo(indexPattern, field.name), - }; - })) || - [] - ); - } - - getFilteredFields = createSelector( - state => state.fields, - (state, props) => props.fieldFilter, - (state, props) => props.indexedFieldTypeFilter, - (fields, fieldFilter, indexedFieldTypeFilter) => { - if (fieldFilter) { - const normalizedFieldFilter = fieldFilter.toLowerCase(); - fields = fields.filter(field => field.name.toLowerCase().includes(normalizedFieldFilter)); - } - - if (indexedFieldTypeFilter) { - fields = fields.filter(field => field.type === indexedFieldTypeFilter); - } - - return fields; - } - ); - - render() { - const { indexPattern } = this.props; - - const fields = this.getFilteredFields(this.state, this.props); - - return ( -
-
this.props.helpers.redirectToRoute(field, 'edit')} - /> - - ); - } -} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/indexed_fields_table.test.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/indexed_fields_table.test.tsx new file mode 100644 index 0000000000000..f8b78a92e098e --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/indexed_fields_table.test.tsx @@ -0,0 +1,121 @@ +/* + * 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 { shallow } from 'enzyme'; +import { IndexPatternField, IIndexPattern } from '../../../../../../../../../plugins/data/public'; +import { IndexedFieldsTable } from './indexed_fields_table'; + +jest.mock('@elastic/eui', () => ({ + EuiFlexGroup: 'eui-flex-group', + EuiFlexItem: 'eui-flex-item', + EuiIcon: 'eui-icon', + EuiInMemoryTable: 'eui-in-memory-table', +})); + +jest.mock('./components/table', () => ({ + // Note: this seems to fix React complaining about non lowercase attributes + Table: () => { + return 'table'; + }, +})); + +const helpers = { + redirectToRoute: (obj: any) => {}, + getFieldInfo: () => [], +}; + +const fields = [ + { + name: 'Elastic', + displayName: 'Elastic', + searchable: true, + type: 'name', + }, + { name: 'timestamp', displayName: 'timestamp', type: 'date' }, + { name: 'conflictingField', displayName: 'conflictingField', type: 'conflict' }, +] as IndexPatternField[]; + +const indexPattern = ({ + getNonScriptedFields: () => fields, +} as unknown) as IIndexPattern; + +describe('IndexedFieldsTable', () => { + test('should render normally', async () => { + const component = shallow( + { + return () => false; + }} + indexedFieldTypeFilter="" + fieldFilter="" + /> + ); + + await new Promise(resolve => process.nextTick(resolve)); + component.update(); + + expect(component).toMatchSnapshot(); + }); + + test('should filter based on the query bar', async () => { + const component = shallow( + { + return () => false; + }} + indexedFieldTypeFilter="" + fieldFilter="" + /> + ); + + await new Promise(resolve => process.nextTick(resolve)); + component.setProps({ fieldFilter: 'Elast' }); + component.update(); + + expect(component).toMatchSnapshot(); + }); + + test('should filter based on the type filter', async () => { + const component = shallow( + { + return () => false; + }} + indexedFieldTypeFilter="" + fieldFilter="" + /> + ); + + await new Promise(resolve => process.nextTick(resolve)); + component.setProps({ indexedFieldTypeFilter: 'date' }); + component.update(); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx new file mode 100644 index 0000000000000..7c2bb565615d7 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx @@ -0,0 +1,120 @@ +/* + * 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, { Component } from 'react'; +import { createSelector } from 'reselect'; +import { IndexPatternField, IIndexPattern } from '../../../../../../../../../plugins/data/public'; +import { Table } from './components/table'; +import { getFieldFormat } from './lib'; +import { IndexedFieldItem } from './types'; + +interface IndexedFieldsTableProps { + fields: IndexPatternField[]; + indexPattern: IIndexPattern; + fieldFilter?: string; + indexedFieldTypeFilter?: string; + helpers: { + redirectToRoute: (obj: any) => void; + getFieldInfo: (indexPattern: IIndexPattern, field: string) => string[]; + }; + fieldWildcardMatcher: (filters: any[]) => (val: any) => boolean; +} + +interface IndexedFieldsTableState { + fields: IndexedFieldItem[]; +} + +export class IndexedFieldsTable extends Component< + IndexedFieldsTableProps, + IndexedFieldsTableState +> { + constructor(props: IndexedFieldsTableProps) { + super(props); + + this.state = { + fields: this.mapFields(this.props.fields), + }; + } + + UNSAFE_componentWillReceiveProps(nextProps: IndexedFieldsTableProps) { + if (nextProps.fields !== this.props.fields) { + this.setState({ + fields: this.mapFields(nextProps.fields), + }); + } + } + + mapFields(fields: IndexPatternField[]): IndexedFieldItem[] { + const { indexPattern, fieldWildcardMatcher, helpers } = this.props; + const sourceFilters = + indexPattern.sourceFilters && + indexPattern.sourceFilters.map((f: Record) => f.value); + const fieldWildcardMatch = fieldWildcardMatcher(sourceFilters || []); + + return ( + (fields && + fields.map(field => { + return { + ...field, + displayName: field.displayName, + indexPattern: field.indexPattern, + format: getFieldFormat(indexPattern, field.name), + excluded: fieldWildcardMatch ? fieldWildcardMatch(field.name) : false, + info: helpers.getFieldInfo && helpers.getFieldInfo(indexPattern, field.name), + }; + })) || + [] + ); + } + + getFilteredFields = createSelector( + (state: IndexedFieldsTableState) => state.fields, + (state: IndexedFieldsTableState, props: IndexedFieldsTableProps) => props.fieldFilter, + (state: IndexedFieldsTableState, props: IndexedFieldsTableProps) => + props.indexedFieldTypeFilter, + (fields, fieldFilter, indexedFieldTypeFilter) => { + if (fieldFilter) { + const normalizedFieldFilter = fieldFilter.toLowerCase(); + fields = fields.filter(field => field.name.toLowerCase().includes(normalizedFieldFilter)); + } + + if (indexedFieldTypeFilter) { + fields = fields.filter(field => field.type === indexedFieldTypeFilter); + } + + return fields; + } + ); + + render() { + const { indexPattern } = this.props; + + const fields = this.getFilteredFields(this.state, this.props); + + return ( +
+
this.props.helpers.redirectToRoute(field)} + /> + + ); + } +} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/lib/__jest__/get_field_format.test.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/lib/__jest__/get_field_format.test.js deleted file mode 100644 index 7090f70199919..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/lib/__jest__/get_field_format.test.js +++ /dev/null @@ -1,52 +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 { getFieldFormat } from '../get_field_format'; - -const indexPattern = { - fieldFormatMap: { - Elastic: { - type: { - title: 'string', - }, - }, - }, -}; - -describe('getFieldFormat', () => { - it('should handle no arguments', () => { - expect(getFieldFormat()).toEqual(''); - }); - - it('should handle no field name', () => { - expect(getFieldFormat(indexPattern)).toEqual(''); - }); - - it('should handle empty name', () => { - expect(getFieldFormat(indexPattern, '')).toEqual(''); - }); - - it('should handle undefined field name', () => { - expect(getFieldFormat(indexPattern, 'none')).toEqual(undefined); - }); - - it('should retrieve field format', () => { - expect(getFieldFormat(indexPattern, 'Elastic')).toEqual('string'); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/lib/get_field_format.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/lib/get_field_format.js deleted file mode 100644 index 9402694bb1371..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/lib/get_field_format.js +++ /dev/null @@ -1,26 +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 { get } from 'lodash'; - -export function getFieldFormat(indexPattern, fieldName) { - return indexPattern && fieldName - ? get(indexPattern, ['fieldFormatMap', fieldName, 'type', 'title']) - : ''; -} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/lib/get_field_format.test.ts b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/lib/get_field_format.test.ts new file mode 100644 index 0000000000000..fc7477c074ac2 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/lib/get_field_format.test.ts @@ -0,0 +1,53 @@ +/* + * 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 { IIndexPattern } from '../../../../../../../../../../plugins/data/public'; +import { getFieldFormat } from './get_field_format'; + +const indexPattern = ({ + fieldFormatMap: { + Elastic: { + type: { + title: 'string', + }, + }, + }, +} as unknown) as IIndexPattern; + +describe('getFieldFormat', () => { + test('should handle no arguments', () => { + expect(getFieldFormat()).toEqual(''); + }); + + test('should handle no field name', () => { + expect(getFieldFormat(indexPattern)).toEqual(''); + }); + + test('should handle empty name', () => { + expect(getFieldFormat(indexPattern, '')).toEqual(''); + }); + + test('should handle undefined field name', () => { + expect(getFieldFormat(indexPattern, 'none')).toEqual(undefined); + }); + + test('should retrieve field format', () => { + expect(getFieldFormat(indexPattern, 'Elastic')).toEqual('string'); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/lib/get_field_format.ts b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/lib/get_field_format.ts new file mode 100644 index 0000000000000..1d6f267430f07 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/lib/get_field_format.ts @@ -0,0 +1,27 @@ +/* + * 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 { get } from 'lodash'; +import { IIndexPattern } from '../../../../../../../../../../plugins/data/public'; + +export function getFieldFormat(indexPattern?: IIndexPattern, fieldName?: string): string { + return indexPattern && fieldName + ? get(indexPattern, ['fieldFormatMap', fieldName, 'type', 'title']) + : ''; +} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/lib/index.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/lib/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/lib/index.js rename to src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/lib/index.ts diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/types.ts b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/types.ts new file mode 100644 index 0000000000000..f27f4608bf5d5 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/indexed_fields_table/types.ts @@ -0,0 +1,25 @@ +/* + * 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 { IFieldType } from '../../../../../../../../../plugins/data/public'; + +export interface IndexedFieldItem extends IFieldType { + info: string[]; + excluded: boolean; +} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/__jest__/__snapshots__/scripted_field_table.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/__jest__/__snapshots__/scripted_field_table.test.js.snap deleted file mode 100644 index a53f4d7f609cb..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/__jest__/__snapshots__/scripted_field_table.test.js.snap +++ /dev/null @@ -1,186 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ScriptedFieldsTable should filter based on the lang filter 1`] = ` -
-
- - -
- -`; - -exports[`ScriptedFieldsTable should filter based on the query bar 1`] = ` -
-
- - -
- -`; - -exports[`ScriptedFieldsTable should hide the table if there are no scripted fields 1`] = ` -
-
- - -
- -`; - -exports[`ScriptedFieldsTable should render normally 1`] = ` -
-
- - -
- -`; - -exports[`ScriptedFieldsTable should show a delete modal 1`] = ` -
-
- - -
- - - - -`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/__jest__/scripted_field_table.test.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/__jest__/scripted_field_table.test.js deleted file mode 100644 index 5be963ad94b7d..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/__jest__/scripted_field_table.test.js +++ /dev/null @@ -1,176 +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 React from 'react'; -import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; - -import { ScriptedFieldsTable } from '../scripted_fields_table'; - -jest.mock('@elastic/eui', () => ({ - EuiTitle: 'eui-title', - EuiText: 'eui-text', - EuiHorizontalRule: 'eui-horizontal-rule', - EuiSpacer: 'eui-spacer', - EuiCallOut: 'eui-call-out', - EuiLink: 'eui-link', - EuiOverlayMask: 'eui-overlay-mask', - EuiConfirmModal: 'eui-confirm-modal', - Comparators: { - property: () => {}, - default: () => {}, - }, -})); -jest.mock('../components/header', () => ({ Header: 'header' })); -jest.mock('../components/call_outs', () => ({ CallOuts: 'call-outs' })); -jest.mock('../components/table', () => ({ - // Note: this seems to fix React complaining about non lowercase attributes - Table: () => { - return 'table'; - }, -})); -jest.mock('ui/scripting_languages', () => ({ - getSupportedScriptingLanguages: () => ['painless'], - getDeprecatedScriptingLanguages: () => [], -})); -jest.mock('ui/documentation_links', () => ({ - documentationLinks: { - scriptedFields: { - painless: 'painlessDocs', - }, - }, -})); - -const helpers = { - redirectToRoute: () => {}, - getRouteHref: () => '#', -}; - -const indexPattern = { - getScriptedFields: () => [ - { name: 'ScriptedField', lang: 'painless', script: 'x++' }, - { name: 'JustATest', lang: 'painless', script: 'z++' }, - ], -}; - -describe('ScriptedFieldsTable', () => { - it('should render normally', async () => { - const component = shallowWithI18nProvider( - - ); - - // Allow the componentWillMount code to execute - // https://github.com/airbnb/enzyme/issues/450 - await component.update(); // Fire `componentWillMount()` - await component.update(); // Force update the component post async actions - - expect(component).toMatchSnapshot(); - }); - - it('should filter based on the query bar', async () => { - const component = shallowWithI18nProvider( - - ); - - // Allow the componentWillMount code to execute - // https://github.com/airbnb/enzyme/issues/450 - await component.update(); // Fire `componentWillMount()` - await component.update(); // Force update the component post async actions - - component.setProps({ fieldFilter: 'Just' }); - component.update(); - - expect(component).toMatchSnapshot(); - }); - - it('should filter based on the lang filter', async () => { - const component = shallowWithI18nProvider( - [ - { name: 'ScriptedField', lang: 'painless', script: 'x++' }, - { name: 'JustATest', lang: 'painless', script: 'z++' }, - { name: 'Bad', lang: 'somethingElse', script: 'z++' }, - ], - }} - helpers={helpers} - /> - ); - - // Allow the componentWillMount code to execute - // https://github.com/airbnb/enzyme/issues/450 - await component.update(); // Fire `componentWillMount()` - await component.update(); // Force update the component post async actions - - component.setProps({ scriptedFieldLanguageFilter: 'painless' }); - component.update(); - - expect(component).toMatchSnapshot(); - }); - - it('should hide the table if there are no scripted fields', async () => { - const component = shallowWithI18nProvider( - [], - }} - helpers={helpers} - /> - ); - - // Allow the componentWillMount code to execute - // https://github.com/airbnb/enzyme/issues/450 - await component.update(); // Fire `componentWillMount()` - await component.update(); // Force update the component post async actions - - expect(component).toMatchSnapshot(); - }); - - it('should show a delete modal', async () => { - const component = shallowWithI18nProvider( - - ); - - await component.update(); // Fire `componentWillMount()` - component.instance().startDeleteField({ name: 'ScriptedField' }); - await component.update(); - - // Ensure the modal is visible - expect(component).toMatchSnapshot(); - }); - - it('should delete a field', async () => { - const removeScriptedField = jest.fn(); - const component = shallowWithI18nProvider( - - ); - - await component.update(); // Fire `componentWillMount()` - component.instance().startDeleteField({ name: 'ScriptedField' }); - await component.update(); - await component.instance().deleteField(); - await component.update(); - expect(removeScriptedField).toBeCalled(); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/__snapshots__/scripted_field_table.test.tsx.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/__snapshots__/scripted_field_table.test.tsx.snap new file mode 100644 index 0000000000000..569b75c848c52 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/__snapshots__/scripted_field_table.test.tsx.snap @@ -0,0 +1,188 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ScriptedFieldsTable should filter based on the lang filter 1`] = ` + +
+ + +
+ +`; + +exports[`ScriptedFieldsTable should filter based on the query bar 1`] = ` + +
+ + +
+ +`; + +exports[`ScriptedFieldsTable should hide the table if there are no scripted fields 1`] = ` + +
+ + +
+ +`; + +exports[`ScriptedFieldsTable should render normally 1`] = ` + +
+ + +
+ +`; + +exports[`ScriptedFieldsTable should show a delete modal 1`] = ` + +
+ + +
+ + +`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/__jest__/__snapshots__/call_outs.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/__jest__/__snapshots__/call_outs.test.js.snap deleted file mode 100644 index e6f0d6cd819e3..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/__jest__/__snapshots__/call_outs.test.js.snap +++ /dev/null @@ -1,43 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CallOuts should render normally 1`] = ` -
- - } - > -

- - - , - } - } - /> -

-
- -
-`; - -exports[`CallOuts should render without any call outs 1`] = `""`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/__jest__/call_outs.test.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/__jest__/call_outs.test.js deleted file mode 100644 index 12e0ee8839967..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/__jest__/call_outs.test.js +++ /dev/null @@ -1,44 +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 React from 'react'; -import { shallow } from 'enzyme'; - -import { CallOuts } from '../call_outs'; - -describe('CallOuts', () => { - it('should render normally', async () => { - const component = shallow( - - ); - - expect(component).toMatchSnapshot(); - }); - - it('should render without any call outs', async () => { - const component = shallow( - - ); - - expect(component).toMatchSnapshot(); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/__snapshots__/call_outs.test.tsx.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/__snapshots__/call_outs.test.tsx.snap new file mode 100644 index 0000000000000..4dfda1b9339b1 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/__snapshots__/call_outs.test.tsx.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CallOuts should render normally 1`] = ` + + + } + > +

+ + + , + } + } + /> +

+
+ +
+`; + +exports[`CallOuts should render without any call outs 1`] = `""`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/call_outs.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/call_outs.js deleted file mode 100644 index 0c321c8ba8b01..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/call_outs.js +++ /dev/null @@ -1,65 +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 React from 'react'; - -import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; - -import { FormattedMessage } from '@kbn/i18n/react'; - -export const CallOuts = ({ deprecatedLangsInUse, painlessDocLink }) => { - if (!deprecatedLangsInUse.length) { - return null; - } - - return ( -
- - } - color="danger" - iconType="cross" - > -

- - - - ), - }} - /> -

-
- -
- ); -}; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/call_outs.test.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/call_outs.test.tsx new file mode 100644 index 0000000000000..407928931191d --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/call_outs.test.tsx @@ -0,0 +1,44 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { CallOuts } from '../call_outs'; + +describe('CallOuts', () => { + test('should render normally', () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('should render without any call outs', () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/call_outs.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/call_outs.tsx new file mode 100644 index 0000000000000..8e38b569a32fa --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/call_outs.tsx @@ -0,0 +1,69 @@ +/* + * 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 { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface CallOutsProps { + deprecatedLangsInUse: string[]; + painlessDocLink: string; +} + +export const CallOuts = ({ deprecatedLangsInUse, painlessDocLink }: CallOutsProps) => { + if (!deprecatedLangsInUse.length) { + return null; + } + + return ( + <> + + } + color="danger" + iconType="cross" + > +

+ + + + ), + }} + /> +

+
+ + + ); +}; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/index.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/index.js rename to src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/call_outs/index.ts diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap new file mode 100644 index 0000000000000..2b320782cb163 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DeleteScritpedFieldConfirmationModal should render normally 1`] = ` + + + +`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/confirmation_modal/confirmation_modal.test.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/confirmation_modal/confirmation_modal.test.tsx new file mode 100644 index 0000000000000..f3594e7507a6a --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/confirmation_modal/confirmation_modal.test.tsx @@ -0,0 +1,37 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { DeleteScritpedFieldConfirmationModal } from './confirmation_modal'; + +describe('DeleteScritpedFieldConfirmationModal', () => { + test('should render normally', () => { + const component = shallow( + {}} + hideDeleteConfirmationModal={() => {}} + /> + ); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/confirmation_modal/confirmation_modal.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/confirmation_modal/confirmation_modal.tsx new file mode 100644 index 0000000000000..1e82174f863b0 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/confirmation_modal/confirmation_modal.tsx @@ -0,0 +1,63 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EUI_MODAL_CONFIRM_BUTTON, EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; + +import { ScriptedFieldItem } from '../../types'; + +interface DeleteScritpedFieldConfirmationModalProps { + field: ScriptedFieldItem; + hideDeleteConfirmationModal: ( + event?: React.KeyboardEvent | React.MouseEvent + ) => void; + deleteField: (event: React.MouseEvent) => void; +} + +export const DeleteScritpedFieldConfirmationModal = ({ + field, + hideDeleteConfirmationModal, + deleteField, +}: DeleteScritpedFieldConfirmationModalProps) => { + const title = i18n.translate('kbn.management.editIndexPattern.scripted.deleteFieldLabel', { + defaultMessage: "Delete scripted field '{fieldName}'?", + values: { fieldName: field.name }, + }); + const cancelButtonText = i18n.translate( + 'kbn.management.editIndexPattern.scripted.deleteField.cancelButton', + { defaultMessage: 'Cancel' } + ); + const confirmButtonText = i18n.translate( + 'kbn.management.editIndexPattern.scripted.deleteField.deleteButton', + { defaultMessage: 'Delete' } + ); + + return ( + + + + ); +}; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/confirmation_modal/index.ts b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/confirmation_modal/index.ts new file mode 100644 index 0000000000000..b87b572333e6f --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/confirmation_modal/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 { DeleteScritpedFieldConfirmationModal } from './confirmation_modal'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/__jest__/header.test.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/__jest__/header.test.js deleted file mode 100644 index 3e377ccfbdd41..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/__jest__/header.test.js +++ /dev/null @@ -1,31 +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 React from 'react'; -import { shallow } from 'enzyme'; - -import { Header } from '../header'; - -describe('Header', () => { - it('should render normally', async () => { - const component = shallow(
); - - expect(component).toMatchSnapshot(); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/__jest__/__snapshots__/header.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/__snapshots__/header.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/__jest__/__snapshots__/header.test.js.snap rename to src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/__snapshots__/header.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/header.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/header.js deleted file mode 100644 index 97c235d82f870..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/header.js +++ /dev/null @@ -1,62 +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 React from 'react'; -import PropTypes from 'prop-types'; - -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui'; - -import { FormattedMessage } from '@kbn/i18n/react'; - -export const Header = ({ addScriptedFieldUrl }) => ( - - - -

- -

-
- -

- -

-
-
- - - - - - -
-); - -Header.propTypes = { - addScriptedFieldUrl: PropTypes.string.isRequired, -}; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/header.test.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/header.test.tsx new file mode 100644 index 0000000000000..19479de8f2aa4 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/header.test.tsx @@ -0,0 +1,31 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { Header } from './header'; + +describe('Header', () => { + test('should render normally', () => { + const component = shallow(
); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/header.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/header.tsx new file mode 100644 index 0000000000000..b8f832dad72af --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/header.tsx @@ -0,0 +1,60 @@ +/* + * 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 { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n/react'; + +interface HeaderProps { + addScriptedFieldUrl: string; +} + +export const Header = ({ addScriptedFieldUrl }: HeaderProps) => ( + + + +

+ +

+
+ +

+ +

+
+
+ + + + + + +
+); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/index.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/index.js rename to src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/header/index.ts diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/index.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/index.js deleted file mode 100644 index 5c0bb41eab765..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/index.js +++ /dev/null @@ -1,22 +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. - */ - -export { Table } from './table'; -export { Header } from './header'; -export { CallOuts } from './call_outs'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/index.ts b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/index.ts new file mode 100644 index 0000000000000..7d74776fb2bca --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/index.ts @@ -0,0 +1,23 @@ +/* + * 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 { Table } from './table'; +export { Header } from './header'; +export { CallOuts } from './call_outs'; +export { DeleteScritpedFieldConfirmationModal } from './confirmation_modal'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/__jest__/__snapshots__/table.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/__jest__/__snapshots__/table.test.js.snap deleted file mode 100644 index 2da4d84463b29..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/__jest__/__snapshots__/table.test.js.snap +++ /dev/null @@ -1,87 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Table should render normally 1`] = ` - -`; - -exports[`Table should render the format 1`] = ` - - string - -`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/__jest__/table.test.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/__jest__/table.test.js deleted file mode 100644 index 4545bfa8f64db..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/__jest__/table.test.js +++ /dev/null @@ -1,103 +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 React from 'react'; -import { shallow } from 'enzyme'; -import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; - -import { Table } from '../table'; - -const indexPattern = { - fieldFormatMap: { - Elastic: { - type: { - title: 'string', - }, - }, - }, -}; - -const items = [{ id: 1, name: 'Elastic' }]; - -describe('Table', () => { - it('should render normally', async () => { - const component = shallowWithI18nProvider( -
{}} - deleteField={() => {}} - onChange={() => {}} - /> - ); - - expect(component).toMatchSnapshot(); - }); - - it('should render the format', async () => { - const component = shallowWithI18nProvider( -
{}} - deleteField={() => {}} - onChange={() => {}} - /> - ); - - const formatTableCell = shallow(component.prop('columns')[3].render('Elastic')); - expect(formatTableCell).toMatchSnapshot(); - }); - - it('should allow edits', () => { - const editField = jest.fn(); - - const component = shallowWithI18nProvider( -
{}} - onChange={() => {}} - /> - ); - - // Click the delete button - component.prop('columns')[4].actions[0].onClick(); - expect(editField).toBeCalled(); - }); - - it('should allow deletes', () => { - const deleteField = jest.fn(); - - const component = shallowWithI18nProvider( -
{}} - deleteField={deleteField} - onChange={() => {}} - /> - ); - - // Click the delete button - component.prop('columns')[4].actions[1].onClick(); - expect(deleteField).toBeCalled(); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/__snapshots__/table.test.tsx.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/__snapshots__/table.test.tsx.snap new file mode 100644 index 0000000000000..8439887dd468a --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/__snapshots__/table.test.tsx.snap @@ -0,0 +1,90 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Table should render normally 1`] = ` + +`; + +exports[`Table should render the format 1`] = ` + + string + +`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/index.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/index.js rename to src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/index.ts diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/table.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/table.js deleted file mode 100644 index 5e05dd95827c7..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/table.js +++ /dev/null @@ -1,140 +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 React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; - -import { EuiInMemoryTable } from '@elastic/eui'; - -import { i18n } from '@kbn/i18n'; - -export class Table extends PureComponent { - static propTypes = { - indexPattern: PropTypes.object.isRequired, - items: PropTypes.array.isRequired, - editField: PropTypes.func.isRequired, - deleteField: PropTypes.func.isRequired, - }; - - renderFormatCell = value => { - const { indexPattern } = this.props; - - const title = - indexPattern.fieldFormatMap[value] && indexPattern.fieldFormatMap[value].type - ? indexPattern.fieldFormatMap[value].type.title - : ''; - - return {title}; - }; - - render() { - const { items, editField, deleteField } = this.props; - - const columns = [ - { - field: 'displayName', - name: i18n.translate('kbn.management.editIndexPattern.scripted.table.nameHeader', { - defaultMessage: 'Name', - }), - description: i18n.translate( - 'kbn.management.editIndexPattern.scripted.table.nameDescription', - { defaultMessage: 'Name of the field' } - ), - dataType: 'string', - sortable: true, - width: '38%', - }, - { - field: 'lang', - name: i18n.translate('kbn.management.editIndexPattern.scripted.table.langHeader', { - defaultMessage: 'Lang', - }), - description: i18n.translate( - 'kbn.management.editIndexPattern.scripted.table.langDescription', - { defaultMessage: 'Language used for the field' } - ), - dataType: 'string', - sortable: true, - 'data-test-subj': 'scriptedFieldLang', - }, - { - field: 'script', - name: i18n.translate('kbn.management.editIndexPattern.scripted.table.scriptHeader', { - defaultMessage: 'Script', - }), - description: i18n.translate( - 'kbn.management.editIndexPattern.scripted.table.scriptDescription', - { defaultMessage: 'Script for the field' } - ), - dataType: 'string', - sortable: true, - }, - { - field: 'name', - name: i18n.translate('kbn.management.editIndexPattern.scripted.table.formatHeader', { - defaultMessage: 'Format', - }), - description: i18n.translate( - 'kbn.management.editIndexPattern.scripted.table.formatDescription', - { defaultMessage: 'Format used for the field' } - ), - render: this.renderFormatCell, - sortable: false, - }, - { - name: '', - actions: [ - { - name: i18n.translate('kbn.management.editIndexPattern.scripted.table.editHeader', { - defaultMessage: 'Edit', - }), - description: i18n.translate( - 'kbn.management.editIndexPattern.scripted.table.editDescription', - { defaultMessage: 'Edit this field' } - ), - icon: 'pencil', - onClick: editField, - }, - { - name: i18n.translate('kbn.management.editIndexPattern.scripted.table.deleteHeader', { - defaultMessage: 'Delete', - }), - description: i18n.translate( - 'kbn.management.editIndexPattern.scripted.table.deleteDescription', - { defaultMessage: 'Delete this field' } - ), - icon: 'trash', - color: 'danger', - onClick: deleteField, - }, - ], - width: '40px', - }, - ]; - - const pagination = { - initialPageSize: 10, - pageSizeOptions: [5, 10, 25, 50], - }; - - return ( - - ); - } -} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/table.test.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/table.test.tsx new file mode 100644 index 0000000000000..13b3875f58687 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/table.test.tsx @@ -0,0 +1,106 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { Table } from '../table'; +import { ScriptedFieldItem } from '../../types'; +import { IIndexPattern } from '../../../../../../../../../../../plugins/data/public'; + +const getIndexPatternMock = (mockedFields: any = {}) => ({ ...mockedFields } as IIndexPattern); + +const items: ScriptedFieldItem[] = [{ name: '1', lang: 'Elastic', script: '' }]; + +describe('Table', () => { + let indexPattern: IIndexPattern; + + beforeEach(() => { + indexPattern = getIndexPatternMock({ + fieldFormatMap: { + Elastic: { + type: { + title: 'string', + }, + }, + }, + }); + }); + + test('should render normally', () => { + const component = shallow
( +
{}} + deleteField={() => {}} + /> + ); + + expect(component).toMatchSnapshot(); + }); + + test('should render the format', () => { + const component = shallow( +
{}} + deleteField={() => {}} + /> + ); + + const formatTableCell = shallow(component.prop('columns')[3].render('Elastic')); + expect(formatTableCell).toMatchSnapshot(); + }); + + test('should allow edits', () => { + const editField = jest.fn(); + + const component = shallow( +
{}} + /> + ); + + // Click the delete button + component.prop('columns')[4].actions[0].onClick(); + expect(editField).toBeCalled(); + }); + + test('should allow deletes', () => { + const deleteField = jest.fn(); + + const component = shallow( +
{}} + deleteField={deleteField} + /> + ); + + // Click the delete button + component.prop('columns')[4].actions[1].onClick(); + expect(deleteField).toBeCalled(); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/table.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/table.tsx new file mode 100644 index 0000000000000..14aed11b32203 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/table.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, { PureComponent } from 'react'; +import { get } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { EuiInMemoryTable, EuiBasicTableColumn } from '@elastic/eui'; + +import { ScriptedFieldItem } from '../../types'; +import { IIndexPattern } from '../../../../../../../../../../../plugins/data/public'; + +interface TableProps { + indexPattern: IIndexPattern; + items: ScriptedFieldItem[]; + editField: (field: ScriptedFieldItem) => void; + deleteField: (field: ScriptedFieldItem) => void; +} + +export class Table extends PureComponent { + renderFormatCell = (value: string) => { + const { indexPattern } = this.props; + const title = get(indexPattern, ['fieldFormatMap', value, 'type', 'title'], ''); + + return {title}; + }; + + render() { + const { items, editField, deleteField } = this.props; + + const columns: Array> = [ + { + field: 'displayName', + name: i18n.translate('kbn.management.editIndexPattern.scripted.table.nameHeader', { + defaultMessage: 'Name', + }), + description: i18n.translate( + 'kbn.management.editIndexPattern.scripted.table.nameDescription', + { defaultMessage: 'Name of the field' } + ), + dataType: 'string', + sortable: true, + width: '38%', + }, + { + field: 'lang', + name: i18n.translate('kbn.management.editIndexPattern.scripted.table.langHeader', { + defaultMessage: 'Lang', + }), + description: i18n.translate( + 'kbn.management.editIndexPattern.scripted.table.langDescription', + { defaultMessage: 'Language used for the field' } + ), + dataType: 'string', + sortable: true, + 'data-test-subj': 'scriptedFieldLang', + }, + { + field: 'script', + name: i18n.translate('kbn.management.editIndexPattern.scripted.table.scriptHeader', { + defaultMessage: 'Script', + }), + description: i18n.translate( + 'kbn.management.editIndexPattern.scripted.table.scriptDescription', + { defaultMessage: 'Script for the field' } + ), + dataType: 'string', + sortable: true, + }, + { + field: 'name', + name: i18n.translate('kbn.management.editIndexPattern.scripted.table.formatHeader', { + defaultMessage: 'Format', + }), + description: i18n.translate( + 'kbn.management.editIndexPattern.scripted.table.formatDescription', + { defaultMessage: 'Format used for the field' } + ), + render: this.renderFormatCell, + sortable: false, + }, + { + name: '', + actions: [ + { + type: 'icon', + name: i18n.translate('kbn.management.editIndexPattern.scripted.table.editHeader', { + defaultMessage: 'Edit', + }), + description: i18n.translate( + 'kbn.management.editIndexPattern.scripted.table.editDescription', + { defaultMessage: 'Edit this field' } + ), + icon: 'pencil', + onClick: editField, + }, + { + type: 'icon', + name: i18n.translate('kbn.management.editIndexPattern.scripted.table.deleteHeader', { + defaultMessage: 'Delete', + }), + description: i18n.translate( + 'kbn.management.editIndexPattern.scripted.table.deleteDescription', + { defaultMessage: 'Delete this field' } + ), + icon: 'trash', + color: 'danger', + onClick: deleteField, + }, + ], + width: '40px', + }, + ]; + + const pagination = { + initialPageSize: 10, + pageSizeOptions: [5, 10, 25, 50], + }; + + return ( + + ); + } +} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/index.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/index.js rename to src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/index.ts diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/scripted_field_table.test.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/scripted_field_table.test.tsx new file mode 100644 index 0000000000000..914d80f9f61d7 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/scripted_field_table.test.tsx @@ -0,0 +1,187 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { ScriptedFieldsTable } from '../scripted_fields_table'; +import { IIndexPattern } from '../../../../../../../../../plugins/data/common/index_patterns'; + +jest.mock('@elastic/eui', () => ({ + EuiTitle: 'eui-title', + EuiText: 'eui-text', + EuiHorizontalRule: 'eui-horizontal-rule', + EuiSpacer: 'eui-spacer', + EuiCallOut: 'eui-call-out', + EuiLink: 'eui-link', + EuiOverlayMask: 'eui-overlay-mask', + EuiConfirmModal: 'eui-confirm-modal', + Comparators: { + property: () => {}, + default: () => {}, + }, +})); +jest.mock('./components/header', () => ({ Header: 'header' })); +jest.mock('./components/call_outs', () => ({ CallOuts: 'call-outs' })); +jest.mock('./components/table', () => ({ + // Note: this seems to fix React complaining about non lowercase attributes + Table: () => { + return 'table'; + }, +})); + +jest.mock('ui/scripting_languages', () => ({ + getSupportedScriptingLanguages: () => ['painless'], + getDeprecatedScriptingLanguages: () => [], +})); + +jest.mock('ui/documentation_links', () => ({ + documentationLinks: { + scriptedFields: { + painless: 'painlessDocs', + }, + }, +})); + +const helpers = { + redirectToRoute: () => {}, + getRouteHref: () => '#', +}; + +const getIndexPatternMock = (mockedFields: any = {}) => ({ ...mockedFields } as IIndexPattern); + +describe('ScriptedFieldsTable', () => { + let indexPattern: IIndexPattern; + + beforeEach(() => { + indexPattern = getIndexPatternMock({ + getScriptedFields: () => [ + { name: 'ScriptedField', lang: 'painless', script: 'x++' }, + { name: 'JustATest', lang: 'painless', script: 'z++' }, + ], + }); + }); + + test('should render normally', async () => { + const component = shallow( + + ); + + // Allow the componentWillMount code to execute + // https://github.com/airbnb/enzyme/issues/450 + await component.update(); // Fire `componentWillMount()` + await component.update(); // Force update the component post async actions + + expect(component).toMatchSnapshot(); + }); + + test('should filter based on the query bar', async () => { + const component = shallow( + + ); + + // Allow the componentWillMount code to execute + // https://github.com/airbnb/enzyme/issues/450 + await component.update(); // Fire `componentWillMount()` + await component.update(); // Force update the component post async actions + + component.setProps({ fieldFilter: 'Just' }); + component.update(); + + expect(component).toMatchSnapshot(); + }); + + test('should filter based on the lang filter', async () => { + const component = shallow( + [ + { name: 'ScriptedField', lang: 'painless', script: 'x++' }, + { name: 'JustATest', lang: 'painless', script: 'z++' }, + { name: 'Bad', lang: 'somethingElse', script: 'z++' }, + ], + })} + helpers={helpers} + /> + ); + + // Allow the componentWillMount code to execute + // https://github.com/airbnb/enzyme/issues/450 + await component.update(); // Fire `componentWillMount()` + await component.update(); // Force update the component post async actions + + component.setProps({ scriptedFieldLanguageFilter: 'painless' }); + component.update(); + + expect(component).toMatchSnapshot(); + }); + + test('should hide the table if there are no scripted fields', async () => { + const component = shallow( + [], + })} + helpers={helpers} + /> + ); + + // Allow the componentWillMount code to execute + // https://github.com/airbnb/enzyme/issues/450 + await component.update(); // Fire `componentWillMount()` + await component.update(); // Force update the component post async actions + + expect(component).toMatchSnapshot(); + }); + + test('should show a delete modal', async () => { + const component = shallow( + + ); + + await component.update(); // Fire `componentWillMount()` + component.instance().startDeleteField({ name: 'ScriptedField', lang: '', script: '' }); + await component.update(); + + // Ensure the modal is visible + expect(component).toMatchSnapshot(); + }); + + test('should delete a field', async () => { + const removeScriptedField = jest.fn(); + const component = shallow( + + ); + + await component.update(); // Fire `componentWillMount()` + component.instance().startDeleteField({ name: 'ScriptedField', lang: '', script: '' }); + + await component.update(); + await component.instance().deleteField(); + await component.update(); + + expect(removeScriptedField).toBeCalled(); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/scripted_fields_table.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/scripted_fields_table.js deleted file mode 100644 index 69343a5175a25..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/scripted_fields_table.js +++ /dev/null @@ -1,190 +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 React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { - getSupportedScriptingLanguages, - getDeprecatedScriptingLanguages, -} from 'ui/scripting_languages'; -import { documentationLinks } from 'ui/documentation_links'; - -import { EuiSpacer, EuiOverlayMask, EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -import { Table, Header, CallOuts } from './components'; - -export class ScriptedFieldsTable extends Component { - static propTypes = { - indexPattern: PropTypes.object.isRequired, - fieldFilter: PropTypes.string, - scriptedFieldLanguageFilter: PropTypes.string, - helpers: PropTypes.shape({ - redirectToRoute: PropTypes.func.isRequired, - getRouteHref: PropTypes.func.isRequired, - }), - onRemoveField: PropTypes.func, - }; - - constructor(props) { - super(props); - - this.state = { - deprecatedLangsInUse: [], - fieldToDelete: undefined, - isDeleteConfirmationModalVisible: false, - fields: [], - }; - } - - UNSAFE_componentWillMount() { - this.fetchFields(); - } - - fetchFields = async () => { - const fields = await this.props.indexPattern.getScriptedFields(); - - const deprecatedLangsInUse = []; - const deprecatedLangs = getDeprecatedScriptingLanguages(); - const supportedLangs = getSupportedScriptingLanguages(); - - for (const { lang } of fields) { - if (deprecatedLangs.includes(lang) || !supportedLangs.includes(lang)) { - deprecatedLangsInUse.push(lang); - } - } - - this.setState({ - fields, - deprecatedLangsInUse, - }); - }; - - getFilteredItems = () => { - const { fields } = this.state; - const { fieldFilter, scriptedFieldLanguageFilter } = this.props; - - let languageFilteredFields = fields; - - if (scriptedFieldLanguageFilter) { - languageFilteredFields = fields.filter( - field => field.lang === this.props.scriptedFieldLanguageFilter - ); - } - - let filteredFields = languageFilteredFields; - - if (fieldFilter) { - const normalizedFieldFilter = this.props.fieldFilter.toLowerCase(); - filteredFields = languageFilteredFields.filter(field => - field.name.toLowerCase().includes(normalizedFieldFilter) - ); - } - - return filteredFields; - }; - - renderCallOuts() { - const { deprecatedLangsInUse } = this.state; - - return ( - - ); - } - - startDeleteField = field => { - this.setState({ fieldToDelete: field, isDeleteConfirmationModalVisible: true }); - }; - - hideDeleteConfirmationModal = () => { - this.setState({ fieldToDelete: undefined, isDeleteConfirmationModalVisible: false }); - }; - - deleteField = () => { - const { indexPattern, onRemoveField } = this.props; - const { fieldToDelete } = this.state; - - indexPattern.removeScriptedField(fieldToDelete); - onRemoveField && onRemoveField(); - this.fetchFields(); - this.hideDeleteConfirmationModal(); - }; - - renderDeleteConfirmationModal() { - const { fieldToDelete } = this.state; - - if (!fieldToDelete) { - return null; - } - - const title = i18n.translate('kbn.management.editIndexPattern.scripted.deleteFieldLabel', { - defaultMessage: "Delete scripted field '{fieldName}'?", - values: { fieldName: fieldToDelete.name }, - }); - const cancelButtonText = i18n.translate( - 'kbn.management.editIndexPattern.scripted.deleteField.cancelButton', - { defaultMessage: 'Cancel' } - ); - const confirmButtonText = i18n.translate( - 'kbn.management.editIndexPattern.scripted.deleteField.deleteButton', - { defaultMessage: 'Delete' } - ); - - return ( - - - - ); - } - - render() { - const { helpers, indexPattern } = this.props; - - const items = this.getFilteredItems(); - - return ( -
-
- - {this.renderCallOuts()} - - - -
this.props.helpers.redirectToRoute(field, 'edit')} - deleteField={this.startDeleteField} - /> - - {this.renderDeleteConfirmationModal()} - - ); - } -} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/scripted_fields_table.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/scripted_fields_table.tsx new file mode 100644 index 0000000000000..e8dfbd6496057 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/scripted_fields_table.tsx @@ -0,0 +1,172 @@ +/* + * 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, { Component } from 'react'; +import { + getSupportedScriptingLanguages, + getDeprecatedScriptingLanguages, +} from 'ui/scripting_languages'; +import { documentationLinks } from 'ui/documentation_links'; + +import { EuiSpacer } from '@elastic/eui'; + +import { Table, Header, CallOuts, DeleteScritpedFieldConfirmationModal } from './components'; +import { ScriptedFieldItem } from './types'; + +import { IIndexPattern } from '../../../../../../../../../plugins/data/public'; + +interface ScriptedFieldsTableProps { + indexPattern: IIndexPattern; + fieldFilter?: string; + scriptedFieldLanguageFilter?: string; + helpers: { + redirectToRoute: Function; + getRouteHref: Function; + }; + onRemoveField?: () => void; +} + +interface ScriptedFieldsTableState { + deprecatedLangsInUse: string[]; + fieldToDelete: ScriptedFieldItem | undefined; + isDeleteConfirmationModalVisible: boolean; + fields: ScriptedFieldItem[]; +} + +export class ScriptedFieldsTable extends Component< + ScriptedFieldsTableProps, + ScriptedFieldsTableState +> { + constructor(props: ScriptedFieldsTableProps) { + super(props); + + this.state = { + deprecatedLangsInUse: [], + fieldToDelete: undefined, + isDeleteConfirmationModalVisible: false, + fields: [], + }; + } + + UNSAFE_componentWillMount() { + this.fetchFields(); + } + + fetchFields = async () => { + const fields = await this.props.indexPattern.getScriptedFields(); + + const deprecatedLangsInUse = []; + const deprecatedLangs = getDeprecatedScriptingLanguages(); + const supportedLangs = getSupportedScriptingLanguages(); + + for (const field of fields) { + const lang: string = field.lang; + if (deprecatedLangs.includes(lang) || !supportedLangs.includes(lang)) { + deprecatedLangsInUse.push(lang); + } + } + + this.setState({ + fields, + deprecatedLangsInUse, + }); + }; + + getFilteredItems = () => { + const { fields } = this.state; + const { fieldFilter, scriptedFieldLanguageFilter } = this.props; + + let languageFilteredFields = fields; + + if (scriptedFieldLanguageFilter) { + languageFilteredFields = fields.filter( + field => field.lang === this.props.scriptedFieldLanguageFilter + ); + } + + let filteredFields = languageFilteredFields; + + if (fieldFilter) { + const normalizedFieldFilter = fieldFilter.toLowerCase(); + + filteredFields = languageFilteredFields.filter(field => + field.name.toLowerCase().includes(normalizedFieldFilter) + ); + } + + return filteredFields; + }; + + startDeleteField = (field: ScriptedFieldItem) => { + this.setState({ fieldToDelete: field, isDeleteConfirmationModalVisible: true }); + }; + + hideDeleteConfirmationModal = () => { + this.setState({ fieldToDelete: undefined, isDeleteConfirmationModalVisible: false }); + }; + + deleteField = () => { + const { indexPattern, onRemoveField } = this.props; + const { fieldToDelete } = this.state; + + indexPattern.removeScriptedField(fieldToDelete); + + if (onRemoveField) { + onRemoveField(); + } + + this.fetchFields(); + this.hideDeleteConfirmationModal(); + }; + + render() { + const { helpers, indexPattern } = this.props; + const { fieldToDelete, deprecatedLangsInUse } = this.state; + + const items = this.getFilteredItems(); + + return ( + <> +
+ + + + + +
this.props.helpers.redirectToRoute(field)} + deleteField={this.startDeleteField} + /> + + {fieldToDelete && ( + + )} + + ); + } +} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/types.ts b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/types.ts new file mode 100644 index 0000000000000..c1227393c561f --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/types.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** @internal **/ +export interface ScriptedFieldItem { + name: string; + lang: string; + script: string; +} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/__jest__/__snapshots__/source_filters_table.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/__jest__/__snapshots__/source_filters_table.test.js.snap deleted file mode 100644 index 52fccb8607a83..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/__jest__/__snapshots__/source_filters_table.test.js.snap +++ /dev/null @@ -1,348 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SourceFiltersTable should add a filter 1`] = ` -
-
- - -
- -`; - -exports[`SourceFiltersTable should filter based on the query bar 1`] = ` -
-
- - -
- -`; - -exports[`SourceFiltersTable should remove a filter 1`] = ` -
-
- - -
- -`; - -exports[`SourceFiltersTable should render normally 1`] = ` -
-
- - -
- -`; - -exports[`SourceFiltersTable should should a loading indicator when saving 1`] = ` -
-
- - -
- -`; - -exports[`SourceFiltersTable should show a delete modal 1`] = ` -
-
- - -
- - - } - confirmButtonText={ - - } - onCancel={[Function]} - onConfirm={[Function]} - title={ - - } - /> - - -`; - -exports[`SourceFiltersTable should update a filter 1`] = ` -
-
- - -
- -`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/__jest__/source_filters_table.test.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/__jest__/source_filters_table.test.js deleted file mode 100644 index a39958a77abbf..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/__jest__/source_filters_table.test.js +++ /dev/null @@ -1,159 +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 React from 'react'; -import { shallow } from 'enzyme'; - -import { SourceFiltersTable } from '../source_filters_table'; - -jest.mock('@elastic/eui', () => ({ - EuiButton: 'eui-button', - EuiTitle: 'eui-title', - EuiText: 'eui-text', - EuiButton: 'eui-button', - EuiHorizontalRule: 'eui-horizontal-rule', - EuiSpacer: 'eui-spacer', - EuiCallOut: 'eui-call-out', - EuiLink: 'eui-link', - EuiOverlayMask: 'eui-overlay-mask', - EuiConfirmModal: 'eui-confirm-modal', - EuiLoadingSpinner: 'eui-loading-spinner', - Comparators: { - property: () => {}, - default: () => {}, - }, -})); -jest.mock('../components/header', () => ({ Header: 'header' })); -jest.mock('../components/table', () => ({ - // Note: this seems to fix React complaining about non lowercase attributes - Table: () => { - return 'table'; - }, -})); - -const indexPattern = { - sourceFilters: [{ value: 'time*' }, { value: 'nam*' }, { value: 'age*' }], -}; - -describe('SourceFiltersTable', () => { - it('should render normally', async () => { - const component = shallow( - {}} /> - ); - - expect(component).toMatchSnapshot(); - }); - - it('should filter based on the query bar', async () => { - const component = shallow( - {}} /> - ); - - component.setProps({ filterFilter: 'ti' }); - expect(component).toMatchSnapshot(); - }); - - it('should should a loading indicator when saving', async () => { - const component = shallow( - {}} - /> - ); - - component.setState({ isSaving: true }); - expect(component).toMatchSnapshot(); - }); - - it('should show a delete modal', async () => { - const component = shallow( - {}} - /> - ); - - component.instance().startDeleteFilter({ value: 'tim*' }); - component.update(); // We are not calling `.setState` directly so we need to re-render - expect(component).toMatchSnapshot(); - }); - - it('should remove a filter', async () => { - const save = jest.fn(); - const component = shallow( - {}} - /> - ); - - component.instance().startDeleteFilter({ value: 'tim*' }); - component.update(); // We are not calling `.setState` directly so we need to re-render - await component.instance().deleteFilter(); - component.update(); // We are not calling `.setState` directly so we need to re-render - - expect(save).toBeCalled(); - expect(component).toMatchSnapshot(); - }); - - it('should add a filter', async () => { - const save = jest.fn(); - const component = shallow( - {}} - /> - ); - - await component.instance().onAddFilter('na*'); - component.update(); // We are not calling `.setState` directly so we need to re-render - - expect(save).toBeCalled(); - expect(component).toMatchSnapshot(); - }); - - it('should update a filter', async () => { - const save = jest.fn(); - const component = shallow( - {}} - /> - ); - - await component.instance().saveFilter({ oldFilterValue: 'tim*', newFilterValue: 'ti*' }); - component.update(); // We are not calling `.setState` directly so we need to re-render - - expect(save).toBeCalled(); - expect(component).toMatchSnapshot(); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/__snapshots__/source_filters_table.test.tsx.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/__snapshots__/source_filters_table.test.tsx.snap new file mode 100644 index 0000000000000..a7b73624c4665 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/__snapshots__/source_filters_table.test.tsx.snap @@ -0,0 +1,313 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SourceFiltersTable should add a filter 1`] = ` + +
+ + +
+ +`; + +exports[`SourceFiltersTable should filter based on the query bar 1`] = ` + +
+ + +
+ +`; + +exports[`SourceFiltersTable should remove a filter 1`] = ` + +
+ + +
+ +`; + +exports[`SourceFiltersTable should render normally 1`] = ` + +
+ + +
+ +`; + +exports[`SourceFiltersTable should should a loading indicator when saving 1`] = ` + +
+ + +
+ +`; + +exports[`SourceFiltersTable should show a delete modal 1`] = ` + +
+ + +
+ + +`; + +exports[`SourceFiltersTable should update a filter 1`] = ` + +
+ + +
+ +`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/__jest__/add_filter.test.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/__jest__/add_filter.test.js deleted file mode 100644 index 915d9490db045..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/__jest__/add_filter.test.js +++ /dev/null @@ -1,55 +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 React from 'react'; -import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; - -import { AddFilter } from '../add_filter'; - -describe('AddFilter', () => { - it('should render normally', async () => { - const component = shallowWithI18nProvider( {}} />); - - expect(component).toMatchSnapshot(); - }); - - it('should allow adding a filter', async () => { - const onAddFilter = jest.fn(); - const component = shallowWithI18nProvider(); - - // Set a value in the input field - component.setState({ filter: 'tim*' }); - - // Click the button - component.find('EuiButton').simulate('click'); - component.update(); - - expect(onAddFilter).toBeCalledWith('tim*'); - }); - - it('should ignore strings with just spaces', async () => { - const component = shallowWithI18nProvider( {}} />); - - // Set a value in the input field - component.find('EuiFieldText').simulate('keypress', ' '); - component.update(); - - expect(component).toMatchSnapshot(); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/__jest__/__snapshots__/add_filter.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/__snapshots__/add_filter.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/__jest__/__snapshots__/add_filter.test.js.snap rename to src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/__snapshots__/add_filter.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/add_filter.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/add_filter.js deleted file mode 100644 index 2124b76b3a915..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/add_filter.js +++ /dev/null @@ -1,73 +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 React, { Component } from 'react'; -import PropTypes from 'prop-types'; - -import { EuiFlexGroup, EuiFlexItem, EuiFieldText, EuiButton } from '@elastic/eui'; - -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export class AddFilter extends Component { - static propTypes = { - onAddFilter: PropTypes.func.isRequired, - }; - - constructor(props) { - super(props); - this.state = { - filter: '', - }; - } - - onAddFilter = () => { - this.props.onAddFilter(this.state.filter); - this.setState({ filter: '' }); - }; - - render() { - const { filter } = this.state; - const placeholder = i18n.translate('kbn.management.editIndexPattern.sourcePlaceholder', { - defaultMessage: - "source filter, accepts wildcards (e.g., `user*` to filter fields starting with 'user')", - }); - - return ( - - - this.setState({ filter: e.target.value.trim() })} - placeholder={placeholder} - /> - - - - - - - - ); - } -} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/add_filter.test.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/add_filter.test.tsx new file mode 100644 index 0000000000000..1ebaa3eaf89f8 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/add_filter.test.tsx @@ -0,0 +1,52 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { AddFilter } from './add_filter'; + +describe('AddFilter', () => { + test('should render normally', () => { + const component = shallow( {}} />); + + expect(component).toMatchSnapshot(); + }); + + test('should allow adding a filter', async () => { + const onAddFilter = jest.fn(); + const component = shallow(); + + component.find('EuiFieldText').simulate('change', { target: { value: 'tim*' } }); + component.find('EuiButton').simulate('click'); + component.update(); + + expect(onAddFilter).toBeCalledWith('tim*'); + }); + + test('should ignore strings with just spaces', () => { + const component = shallow( {}} />); + + // Set a value in the input field + component.find('EuiFieldText').simulate('keypress', ' '); + component.update(); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/add_filter.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/add_filter.tsx new file mode 100644 index 0000000000000..d0f397637de33 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/add_filter.tsx @@ -0,0 +1,63 @@ +/* + * 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, { useState, useCallback } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiFieldText, EuiButton } from '@elastic/eui'; + +interface AddFilterProps { + onAddFilter: (filter: string) => void; +} + +const sourcePlaceholder = i18n.translate('kbn.management.editIndexPattern.sourcePlaceholder', { + defaultMessage: + "source filter, accepts wildcards (e.g., `user*` to filter fields starting with 'user')", +}); + +export const AddFilter = ({ onAddFilter }: AddFilterProps) => { + const [filter, setFilter] = useState(''); + + const onAddButtonClick = useCallback(() => { + onAddFilter(filter); + setFilter(''); + }, [filter, onAddFilter]); + + return ( + + + setFilter(e.target.value.trim())} + placeholder={sourcePlaceholder} + /> + + + + + + + + ); +}; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/index.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/index.js rename to src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/index.ts diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap new file mode 100644 index 0000000000000..62376b498d887 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Header should render normally 1`] = ` + + + } + confirmButtonText={ + + } + defaultFocusedButton="confirm" + onCancel={[Function]} + onConfirm={[Function]} + title={ + + } + /> + +`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/confirmation_modal/confirmation_modal.test.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/confirmation_modal/confirmation_modal.test.tsx new file mode 100644 index 0000000000000..ac7237095e4b3 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/confirmation_modal/confirmation_modal.test.tsx @@ -0,0 +1,37 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { DeleteFilterConfirmationModal } from './confirmation_modal'; + +describe('Header', () => { + test('should render normally', () => { + const component = shallow( + {}} + onDeleteFilter={() => {}} + /> + ); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/confirmation_modal/confirmation_modal.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/confirmation_modal/confirmation_modal.tsx new file mode 100644 index 0000000000000..dabfb6d8f275a --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/confirmation_modal/confirmation_modal.tsx @@ -0,0 +1,76 @@ +/* + * 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 PropTypes from 'prop-types'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiOverlayMask, EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; + +interface DeleteFilterConfirmationModalProps { + filterToDeleteValue: string; + onCancelConfirmationModal: ( + event?: React.KeyboardEvent | React.MouseEvent + ) => void; + onDeleteFilter: (event: React.MouseEvent) => void; +} + +export const DeleteFilterConfirmationModal = ({ + filterToDeleteValue, + onCancelConfirmationModal, + onDeleteFilter, +}: DeleteFilterConfirmationModalProps) => { + return ( + + + } + onCancel={onCancelConfirmationModal} + onConfirm={onDeleteFilter} + cancelButtonText={ + + } + buttonColor="danger" + confirmButtonText={ + + } + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + /> + + ); +}; + +DeleteFilterConfirmationModal.propTypes = { + filterToDeleteValue: PropTypes.string.isRequired, + onCancelConfirmationModal: PropTypes.func.isRequired, + onDeleteFilter: PropTypes.func.isRequired, +}; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/confirmation_modal/index.ts b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/confirmation_modal/index.ts new file mode 100644 index 0000000000000..e48e38b7c3dcb --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/confirmation_modal/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 { DeleteFilterConfirmationModal } from './confirmation_modal'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/__jest__/__snapshots__/header.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/__jest__/__snapshots__/header.test.js.snap deleted file mode 100644 index a5be141a18c89..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/__jest__/__snapshots__/header.test.js.snap +++ /dev/null @@ -1,36 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Header should render normally 1`] = ` -
- -

- -

-
- -

- -

-

- -

-
- -
-`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/__jest__/header.test.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/__jest__/header.test.js deleted file mode 100644 index 058bf99fe26fa..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/__jest__/header.test.js +++ /dev/null @@ -1,31 +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 React from 'react'; -import { shallow } from 'enzyme'; - -import { Header } from '../header'; - -describe('Header', () => { - it('should render normally', async () => { - const component = shallow(
); - - expect(component).toMatchSnapshot(); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/__snapshots__/header.test.tsx.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/__snapshots__/header.test.tsx.snap new file mode 100644 index 0000000000000..cde0de79caacd --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/__snapshots__/header.test.tsx.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Header should render normally 1`] = ` + + +

+ +

+
+ +

+ +

+

+ +

+
+ +
+`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/header.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/header.js deleted file mode 100644 index 8822ca6236250..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/header.js +++ /dev/null @@ -1,56 +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 React from 'react'; - -import { EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; - -import { FormattedMessage } from '@kbn/i18n/react'; - -export const Header = () => ( -
- -

- -

-
- -

- -

-

- -

-
- -
-); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/header.test.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/header.test.tsx new file mode 100644 index 0000000000000..869bdeb55cf02 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/header.test.tsx @@ -0,0 +1,31 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { Header } from '../header'; + +describe('Header', () => { + test('should render normally', () => { + const component = shallow(
); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/header.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/header.tsx new file mode 100644 index 0000000000000..7b37f75043dd5 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/header.tsx @@ -0,0 +1,55 @@ +/* + * 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 { EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const Header = () => ( + <> + +

+ +

+
+ +

+ +

+

+ +

+
+ + +); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/index.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/index.js rename to src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/header/index.ts diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/index.ts b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/index.ts new file mode 100644 index 0000000000000..87ac13ad15f50 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/index.ts @@ -0,0 +1,23 @@ +/* + * 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 { AddFilter } from './add_filter'; +export { DeleteFilterConfirmationModal } from './confirmation_modal'; +export { Header } from './header'; +export { Table } from './table'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/__jest__/__snapshots__/table.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/__jest__/__snapshots__/table.test.js.snap deleted file mode 100644 index a0853b8628cc9..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/__jest__/__snapshots__/table.test.js.snap +++ /dev/null @@ -1,102 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Table editing should show a save button 1`] = ` -
- - -
-`; - -exports[`Table editing should show an input field 1`] = ` - - - - - -`; - -exports[`Table editing should update the matches dynamically as input value is changed 1`] = ` -
- - time, value - -
-`; - -exports[`Table should render filter matches 1`] = ` - - time - -`; - -exports[`Table should render normally 1`] = ` - -`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/__jest__/table.test.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/__jest__/table.test.js deleted file mode 100644 index 7fba1fcfe4876..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/__jest__/table.test.js +++ /dev/null @@ -1,299 +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 React from 'react'; -import { shallow } from 'enzyme'; -import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; - -import { Table } from '../table'; -import { keyCodes } from '@elastic/eui'; - -const indexPattern = {}; -const items = [{ value: 'tim*' }]; - -describe('Table', () => { - it('should render normally', async () => { - const component = shallowWithI18nProvider( -
{}} - fieldWildcardMatcher={() => {}} - saveFilter={() => {}} - isSaving={true} - /> - ); - - expect(component).toMatchSnapshot(); - }); - - it('should render filter matches', async () => { - const component = shallowWithI18nProvider( -
[{ name: 'time' }, { name: 'value' }], - }} - items={items} - deleteFilter={() => {}} - fieldWildcardMatcher={filter => field => field.includes(filter[0])} - saveFilter={() => {}} - isSaving={false} - /> - ); - - const matchesTableCell = shallow(component.prop('columns')[1].render('tim', { clientId: 1 })); - expect(matchesTableCell).toMatchSnapshot(); - }); - - describe('editing', () => { - const saveFilter = jest.fn(); - const clientId = 1; - let component; - - beforeEach(() => { - component = shallowWithI18nProvider( -
{}} - fieldWildcardMatcher={() => {}} - saveFilter={saveFilter} - isSaving={false} - /> - ); - }); - - it('should show an input field', () => { - // Start the editing process - const editingComponent = shallow( - // Wrap in a div because: https://github.com/airbnb/enzyme/issues/1213 -
{component.prop('columns')[2].render({ clientId, value: 'tim*' })}
- ); - editingComponent - .find('EuiButtonIcon') - .at(1) - .simulate('click'); - // Ensure the state change propagates - component.update(); - - // Ensure the table cell switches to an input - const filterNameTableCell = shallow( - component.prop('columns')[0].render('tim*', { clientId }) - ); - expect(filterNameTableCell).toMatchSnapshot(); - }); - - it('should show a save button', () => { - // Start the editing process - const editingComponent = shallow( - // Fixes: Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. -
{component.prop('columns')[2].render({ clientId, value: 'tim*' })}
- ); - editingComponent - .find('EuiButtonIcon') - .at(1) - .simulate('click'); - - // Ensure the state change propagates - component.update(); - - // Verify save button - const saveTableCell = shallow( - // Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. -
{component.prop('columns')[2].render({ clientId, value: 'tim*' })}
- ); - expect(saveTableCell).toMatchSnapshot(); - }); - - it('should update the matches dynamically as input value is changed', () => { - const localComponent = shallowWithI18nProvider( -
[{ name: 'time' }, { name: 'value' }], - }} - items={items} - deleteFilter={() => {}} - fieldWildcardMatcher={query => () => { - return query.includes('time*'); - }} - saveFilter={saveFilter} - isSaving={false} - /> - ); - - // Start the editing process - const editingComponent = shallow( - // Fixes: Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. -
{localComponent.prop('columns')[2].render({ clientId, value: 'tim*' })}
- ); - editingComponent - .find('EuiButtonIcon') - .at(1) - .simulate('click'); - - // Update the value - localComponent.setState({ editingFilterValue: 'time*' }); - - // Ensure the state change propagates - localComponent.update(); - - // Verify updated matches - const matchesTableCell = shallow( - // Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. -
{localComponent.prop('columns')[1].render('tim*', { clientId })}
- ); - expect(matchesTableCell).toMatchSnapshot(); - }); - - it('should exit on save', () => { - // Change the value to something else - component.setState({ - editingFilterId: clientId, - editingFilterValue: 'ti*', - }); - - // Click the save button - const editingComponent = shallow( - // Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. -
{component.prop('columns')[2].render({ clientId, value: 'tim*' })}
- ); - editingComponent - .find('EuiButtonIcon') - .at(0) - .simulate('click'); - - // Ensure we call saveFilter properly - expect(saveFilter).toBeCalledWith({ - filterId: clientId, - newFilterValue: 'ti*', - }); - - // Ensure the state is properly reset - expect(component.state('editingFilterId')).toBe(null); - }); - }); - - it('should allow deletes', () => { - const deleteFilter = jest.fn(); - - const component = shallowWithI18nProvider( -
{}} - saveFilter={() => {}} - isSaving={false} - /> - ); - - // Click the delete button - const deleteCellComponent = shallow( - // Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. -
{component.prop('columns')[2].render({ clientId: 1, value: 'tim*' })}
- ); - deleteCellComponent - .find('EuiButtonIcon') - .at(0) - .simulate('click'); - expect(deleteFilter).toBeCalled(); - }); - - it('should save when in edit mode and the enter key is pressed', () => { - const saveFilter = jest.fn(); - const clientId = 1; - - const component = shallowWithI18nProvider( -
{}} - fieldWildcardMatcher={() => {}} - saveFilter={saveFilter} - isSaving={false} - /> - ); - - // Start the editing process - const editingComponent = shallow( - // Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. -
{component.prop('columns')[2].render({ clientId, value: 'tim*' })}
- ); - editingComponent - .find('EuiButtonIcon') - .at(1) - .simulate('click'); - // Ensure the state change propagates - component.update(); - - // Get the rendered input cell - const filterNameTableCell = shallow( - // Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. -
{component.prop('columns')[0].render('tim*', { clientId })}
- ); - - // Press the enter key - filterNameTableCell.find('EuiFieldText').simulate('keydown', { keyCode: keyCodes.ENTER }); - expect(saveFilter).toBeCalled(); - - // It should reset - expect(component.state('editingFilterId')).toBe(null); - }); - - it('should cancel when in edit mode and the esc key is pressed', () => { - const saveFilter = jest.fn(); - const clientId = 1; - - const component = shallowWithI18nProvider( -
{}} - fieldWildcardMatcher={() => {}} - saveFilter={saveFilter} - isSaving={false} - /> - ); - - // Start the editing process - const editingComponent = shallow( - // Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. -
{component.prop('columns')[2].render({ clientId, value: 'tim*' })}
- ); - editingComponent - .find('EuiButtonIcon') - .at(1) - .simulate('click'); - // Ensure the state change propagates - component.update(); - - // Get the rendered input cell - const filterNameTableCell = shallow( - // Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. -
{component.prop('columns')[0].render('tim*', { clientId })}
- ); - - // Press the enter key - filterNameTableCell.find('EuiFieldText').simulate('keydown', { keyCode: keyCodes.ESCAPE }); - expect(saveFilter).not.toBeCalled(); - - // It should reset - expect(component.state('editingFilterId')).toBe(null); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/__snapshots__/table.test.tsx.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/__snapshots__/table.test.tsx.snap new file mode 100644 index 0000000000000..c70d0871bb854 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/__snapshots__/table.test.tsx.snap @@ -0,0 +1,97 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Table editing should show a save button 1`] = ` +
+ + +
+`; + +exports[`Table editing should show an input field 1`] = ` + + tim* + +`; + +exports[`Table editing should update the matches dynamically as input value is changed 1`] = ` +
+ + + +
+`; + +exports[`Table should render filter matches 1`] = ` + + time + +`; + +exports[`Table should render normally 1`] = ` + +`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/index.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/index.js rename to src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/index.ts diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/table.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/table.js deleted file mode 100644 index f16663e1cd41a..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/table.js +++ /dev/null @@ -1,218 +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 React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; - -import { - EuiInMemoryTable, - EuiFieldText, - EuiButtonIcon, - keyCodes, - RIGHT_ALIGNMENT, -} from '@elastic/eui'; - -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export class Table extends Component { - static propTypes = { - indexPattern: PropTypes.object.isRequired, - items: PropTypes.array.isRequired, - deleteFilter: PropTypes.func.isRequired, - fieldWildcardMatcher: PropTypes.func.isRequired, - saveFilter: PropTypes.func.isRequired, - isSaving: PropTypes.bool.isRequired, - }; - - constructor(props) { - super(props); - this.state = { - editingFilterId: null, - editingFilterValue: null, - }; - } - - startEditingFilter = (id, value) => - this.setState({ editingFilterId: id, editingFilterValue: value }); - stopEditingFilter = () => this.setState({ editingFilterId: null }); - onEditingFilterChange = e => this.setState({ editingFilterValue: e.target.value }); - - onEditFieldKeyDown = ({ keyCode }) => { - if (keyCodes.ENTER === keyCode) { - this.props.saveFilter({ - filterId: this.state.editingFilterId, - newFilterValue: this.state.editingFilterValue, - }); - this.stopEditingFilter(); - } - if (keyCodes.ESCAPE === keyCode) { - this.stopEditingFilter(); - } - }; - - getColumns() { - const { deleteFilter, fieldWildcardMatcher, indexPattern, saveFilter } = this.props; - - return [ - { - field: 'value', - name: i18n.translate('kbn.management.editIndexPattern.source.table.filterHeader', { - defaultMessage: 'Filter', - }), - description: i18n.translate( - 'kbn.management.editIndexPattern.source.table.filterDescription', - { defaultMessage: 'Filter name' } - ), - dataType: 'string', - sortable: true, - render: (value, filter) => { - if (this.state.editingFilterId === filter.clientId) { - return ( - - ); - } - - return {value}; - }, - }, - { - field: 'value', - name: i18n.translate('kbn.management.editIndexPattern.source.table.matchesHeader', { - defaultMessage: 'Matches', - }), - description: i18n.translate( - 'kbn.management.editIndexPattern.source.table.matchesDescription', - { defaultMessage: 'Language used for the field' } - ), - dataType: 'string', - sortable: true, - render: (value, filter) => { - const realtimeValue = - this.state.editingFilterId === filter.clientId ? this.state.editingFilterValue : value; - const matcher = fieldWildcardMatcher([realtimeValue]); - const matches = indexPattern - .getNonScriptedFields() - .map(f => f.name) - .filter(matcher) - .sort(); - if (matches.length) { - return {matches.join(', ')}; - } - - return ( - - - - ); - }, - }, - { - name: '', - align: RIGHT_ALIGNMENT, - width: '100', - render: filter => { - if (this.state.editingFilterId === filter.clientId) { - return ( - - { - saveFilter({ - filterId: this.state.editingFilterId, - newFilterValue: this.state.editingFilterValue, - }); - this.stopEditingFilter(); - }} - iconType="checkInCircleFilled" - aria-label={i18n.translate( - 'kbn.management.editIndexPattern.source.table.saveAria', - { defaultMessage: 'Save' } - )} - /> - { - this.stopEditingFilter(); - }} - iconType="cross" - aria-label={i18n.translate( - 'kbn.management.editIndexPattern.source.table.cancelAria', - { defaultMessage: 'Cancel' } - )} - /> - - ); - } - - return ( - - deleteFilter(filter)} - iconType="trash" - aria-label={i18n.translate( - 'kbn.management.editIndexPattern.source.table.deleteAria', - { defaultMessage: 'Delete' } - )} - /> - this.startEditingFilter(filter.clientId, filter.value)} - iconType="pencil" - aria-label={i18n.translate( - 'kbn.management.editIndexPattern.source.table.editAria', - { defaultMessage: 'Edit' } - )} - /> - - ); - }, - }, - ]; - } - - render() { - const { items, isSaving } = this.props; - const columns = this.getColumns(); - const pagination = { - initialPageSize: 10, - pageSizeOptions: [5, 10, 25, 50], - }; - - return ( - - ); - } -} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/table.test.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/table.test.tsx new file mode 100644 index 0000000000000..4705ecd2d1685 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/table.test.tsx @@ -0,0 +1,319 @@ +/* + * 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, { ReactElement } from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; + +import { Table, TableProps, TableState } from './table'; +import { EuiTableFieldDataColumnType, keyCodes } from '@elastic/eui'; +import { IIndexPattern } from '../../../../../../../../../../../plugins/data/public'; +import { SourceFiltersTableFilter } from '../../types'; + +const indexPattern = {} as IIndexPattern; +const items: SourceFiltersTableFilter[] = [{ value: 'tim*', clientId: '' }]; + +const getIndexPatternMock = (mockedFields: any = {}) => ({ ...mockedFields } as IIndexPattern); + +const getTableColumnRender = ( + component: ShallowWrapper, + index: number = 0 +) => { + const columns = component.prop>>( + 'columns' + ); + return { + render: columns[index].render as (...args: any) => ReactElement, + }; +}; + +describe('Table', () => { + test('should render normally', () => { + const component = shallow( +
{}} + fieldWildcardMatcher={() => {}} + saveFilter={() => undefined} + isSaving={true} + /> + ); + + expect(component).toMatchSnapshot(); + }); + + test('should render filter matches', () => { + const component = shallow
( +
[{ name: 'time' }, { name: 'value' }], + })} + items={items} + deleteFilter={() => {}} + fieldWildcardMatcher={(filter: string) => (field: string) => field.includes(filter[0])} + saveFilter={() => undefined} + isSaving={false} + /> + ); + + const matchesTableCell = shallow( + getTableColumnRender(component, 1).render('tim', { clientId: 1 }) + ); + expect(matchesTableCell).toMatchSnapshot(); + }); + + describe('editing', () => { + const saveFilter = jest.fn(); + const clientId = '1'; + let component: ShallowWrapper; + + beforeEach(() => { + component = shallow
( +
{}} + fieldWildcardMatcher={() => {}} + saveFilter={saveFilter} + isSaving={false} + /> + ); + }); + + test('should show an input field', () => { + // Start the editing process + + const editingComponent = shallow( + // Wrap in a div because: https://github.com/airbnb/enzyme/issues/1213 +
{getTableColumnRender(component, 2).render({ clientId, value: 'tim*' })}
+ ); + editingComponent + .find('EuiButtonIcon') + .at(1) + .simulate('click'); + // Ensure the state change propagates + component.update(); + + const cell = getTableColumnRender(component).render('tim*', { clientId }); + const filterNameTableCell = shallow(cell); + + expect(filterNameTableCell).toMatchSnapshot(); + }); + + test('should show a save button', () => { + // Start the editing process + const editingComponent = shallow( + // Fixes: Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. +
{getTableColumnRender(component, 2).render({ clientId, value: 'tim*' })}
+ ); + + editingComponent + .find('EuiButtonIcon') + .at(1) + .simulate('click'); + + // Ensure the state change propagates + component.update(); + + // Verify save button + const saveTableCell = shallow( + // Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. +
{getTableColumnRender(component, 2).render({ clientId, value: 'tim*' })}
+ ); + expect(saveTableCell).toMatchSnapshot(); + }); + + test('should update the matches dynamically as input value is changed', () => { + const localComponent = shallow( +
[{ name: 'time' }, { name: 'value' }], + })} + items={items} + deleteFilter={() => {}} + fieldWildcardMatcher={(query: string) => () => query.includes('time*')} + saveFilter={saveFilter} + isSaving={false} + /> + ); + + // Start the editing process + const editingComponent = shallow( + // Fixes: Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. +
{localComponent.prop('columns')[2].render({ clientId, value: 'tim*' })}
+ ); + + editingComponent + .find('EuiButtonIcon') + .at(1) + .simulate('click'); + + // Update the value + localComponent.setState({ editingFilterValue: 'time*' }); + + // Ensure the state change propagates + localComponent.update(); + + // Verify updated matches + const matchesTableCell = shallow( + // Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. +
{localComponent.prop('columns')[1].render('tim*', { clientId })}
+ ); + expect(matchesTableCell).toMatchSnapshot(); + }); + + test('should exit on save', () => { + // Change the value to something else + component.setState({ + editingFilterId: clientId, + editingFilterValue: 'ti*', + }); + + // Click the save button + const editingComponent = shallow( + // Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. +
{getTableColumnRender(component, 2).render({ clientId, value: 'tim*' })}
+ ); + + editingComponent + .find('EuiButtonIcon') + .at(0) + .simulate('click'); + + editingComponent.update(); + + // Ensure we call saveFilter properly + expect(saveFilter).toBeCalledWith({ + clientId, + value: 'ti*', + }); + + // Ensure the state is properly reset + expect(component.state('editingFilterId')).toBe(''); + }); + }); + + test('should allow deletes', () => { + const deleteFilter = jest.fn(); + + const component = shallow( +
{}} + saveFilter={() => undefined} + isSaving={false} + /> + ); + + // Click the delete button + const deleteCellComponent = shallow( + // Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. +
{component.prop('columns')[2].render({ clientId: 1, value: 'tim*' })}
+ ); + deleteCellComponent + .find('EuiButtonIcon') + .at(1) + .simulate('click'); + expect(deleteFilter).toBeCalled(); + }); + + test('should save when in edit mode and the enter key is pressed', () => { + const saveFilter = jest.fn(); + + const component = shallow( +
{}} + fieldWildcardMatcher={() => {}} + saveFilter={saveFilter} + isSaving={false} + /> + ); + + // Start the editing process + const editingComponent = shallow( + // Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. +
{component.prop('columns')[2].render({ clientId: 1, value: 'tim*' })}
+ ); + editingComponent + .find('EuiButtonIcon') + .at(0) + .simulate('click'); + + component.update(); + + // Get the rendered input cell + const filterNameTableCell = shallow( + // Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. +
{component.prop('columns')[0].render('tim*', { clientId: 1 })}
+ ); + + // Press the enter key + filterNameTableCell.find('EuiFieldText').simulate('keydown', { keyCode: keyCodes.ENTER }); + expect(saveFilter).toBeCalled(); + + // It should reset + expect(component.state('editingFilterId')).toBe(''); + }); + + test('should cancel when in edit mode and the esc key is pressed', () => { + const saveFilter = jest.fn(); + + const component = shallow( +
{}} + fieldWildcardMatcher={() => {}} + saveFilter={saveFilter} + isSaving={false} + /> + ); + + // Start the editing process + const editingComponent = shallow( + // Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. +
{component.prop('columns')[2].render({ clientId: 1, value: 'tim*' })}
+ ); + + editingComponent + .find('EuiButtonIcon') + .at(0) + .simulate('click'); + + // Ensure the state change propagates + component.update(); + + // Get the rendered input cell + const filterNameTableCell = shallow( + // Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`. +
{component.prop('columns')[0].render('tim*', { clientId: 1 })}
+ ); + + // Press the ESCAPE key + filterNameTableCell.find('EuiFieldText').simulate('keydown', { keyCode: keyCodes.ESCAPE }); + expect(saveFilter).not.toBeCalled(); + + // It should reset + expect(component.state('editingFilterId')).toBe(''); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/table.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/table.tsx new file mode 100644 index 0000000000000..db2b74bbc9824 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/table/table.tsx @@ -0,0 +1,243 @@ +/* + * 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, { Component } from 'react'; + +import { + keyCodes, + EuiBasicTableColumn, + EuiInMemoryTable, + EuiFieldText, + EuiButtonIcon, + RIGHT_ALIGNMENT, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { SourceFiltersTableFilter } from '../../types'; + +import { IIndexPattern } from '../../../../../../../../../../../plugins/data/public'; + +const filterHeader = i18n.translate('kbn.management.editIndexPattern.source.table.filterHeader', { + defaultMessage: 'Filter', +}); + +const filterDescription = i18n.translate( + 'kbn.management.editIndexPattern.source.table.filterDescription', + { defaultMessage: 'Filter name' } +); + +const matchesHeader = i18n.translate('kbn.management.editIndexPattern.source.table.matchesHeader', { + defaultMessage: 'Matches', +}); + +const matchesDescription = i18n.translate( + 'kbn.management.editIndexPattern.source.table.matchesDescription', + { defaultMessage: 'Language used for the field' } +); + +const editAria = i18n.translate('kbn.management.editIndexPattern.source.table.editAria', { + defaultMessage: 'Edit', +}); + +const saveAria = i18n.translate('kbn.management.editIndexPattern.source.table.saveAria', { + defaultMessage: 'Save', +}); + +const deleteAria = i18n.translate('kbn.management.editIndexPattern.source.table.deleteAria', { + defaultMessage: 'Delete', +}); + +const cancelAria = i18n.translate('kbn.management.editIndexPattern.source.table.cancelAria', { + defaultMessage: 'Cancel', +}); + +export interface TableProps { + indexPattern: IIndexPattern; + items: SourceFiltersTableFilter[]; + deleteFilter: Function; + fieldWildcardMatcher: Function; + saveFilter: (filter: SourceFiltersTableFilter) => any; + isSaving: boolean; +} + +export interface TableState { + editingFilterId: string | number; + editingFilterValue: string; +} + +export class Table extends Component { + constructor(props: TableProps) { + super(props); + this.state = { + editingFilterId: '', + editingFilterValue: '', + }; + } + + startEditingFilter = ( + editingFilterId: TableState['editingFilterId'], + editingFilterValue: TableState['editingFilterValue'] + ) => this.setState({ editingFilterId, editingFilterValue }); + + stopEditingFilter = () => this.setState({ editingFilterId: '' }); + onEditingFilterChange = (e: React.ChangeEvent) => + this.setState({ editingFilterValue: e.target.value }); + + onEditFieldKeyDown = ({ keyCode }: React.KeyboardEvent) => { + if (keyCodes.ENTER === keyCode && this.state.editingFilterId && this.state.editingFilterValue) { + this.props.saveFilter({ + clientId: this.state.editingFilterId, + value: this.state.editingFilterValue, + }); + this.stopEditingFilter(); + } + if (keyCodes.ESCAPE === keyCode) { + this.stopEditingFilter(); + } + }; + + getColumns(): Array> { + const { deleteFilter, fieldWildcardMatcher, indexPattern, saveFilter } = this.props; + + return [ + { + field: 'value', + name: filterHeader, + description: filterDescription, + dataType: 'string', + sortable: true, + render: (value, filter) => { + if (this.state.editingFilterId && this.state.editingFilterId === filter.clientId) { + return ( + + ); + } + + return {value}; + }, + }, + { + field: 'value', + name: matchesHeader, + description: matchesDescription, + dataType: 'string', + sortable: true, + render: (value, filter) => { + const wildcardMatcher = fieldWildcardMatcher([ + this.state.editingFilterId === filter.clientId ? this.state.editingFilterValue : value, + ]); + const matches = indexPattern + .getNonScriptedFields() + .map((currentFilter: any) => currentFilter.name) + .filter(wildcardMatcher) + .sort(); + + if (matches.length) { + return {matches.join(', ')}; + } + + return ( + + + + ); + }, + }, + { + name: '', + align: RIGHT_ALIGNMENT, + width: '100', + render: (filter: SourceFiltersTableFilter) => { + if (this.state.editingFilterId === filter.clientId) { + return ( + <> + { + saveFilter({ + clientId: this.state.editingFilterId, + value: this.state.editingFilterValue, + }); + this.stopEditingFilter(); + }} + iconType="checkInCircleFilled" + aria-label={saveAria} + /> + { + this.stopEditingFilter(); + }} + iconType="cross" + aria-label={cancelAria} + /> + + ); + } + + return ( + <> + this.startEditingFilter(filter.clientId, filter.value)} + iconType="pencil" + aria-label={editAria} + /> + deleteFilter(filter)} + iconType="trash" + aria-label={deleteAria} + /> + + ); + }, + }, + ]; + } + + render() { + const { items, isSaving } = this.props; + const columns = this.getColumns(); + const pagination = { + initialPageSize: 10, + pageSizeOptions: [5, 10, 25, 50], + }; + + return ( + + ); + } +} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/index.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/index.js rename to src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/index.ts diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/source_filters_table.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/source_filters_table.js deleted file mode 100644 index 3b485573f3821..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/source_filters_table.js +++ /dev/null @@ -1,212 +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 React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { createSelector } from 'reselect'; - -import { EuiSpacer, EuiOverlayMask, EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; - -import { Table } from './components/table'; -import { Header } from './components/header'; -import { AddFilter } from './components/add_filter'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export class SourceFiltersTable extends Component { - static propTypes = { - indexPattern: PropTypes.object.isRequired, - filterFilter: PropTypes.string, - fieldWildcardMatcher: PropTypes.func.isRequired, - onAddOrRemoveFilter: PropTypes.func, - }; - - constructor(props) { - super(props); - - // Source filters do not have any unique ids, only the value is stored. - // To ensure we can create a consistent and expected UX when managing - // source filters, we are assigning a unique id to each filter on the - // client side only - this.clientSideId = 0; - - this.state = { - filterToDelete: undefined, - isDeleteConfirmationModalVisible: false, - isSaving: false, - filters: [], - }; - } - - UNSAFE_componentWillMount() { - this.updateFilters(); - } - - updateFilters = () => { - const sourceFilters = this.props.indexPattern.sourceFilters || []; - const filters = sourceFilters.map(filter => ({ - ...filter, - clientId: ++this.clientSideId, - })); - - this.setState({ filters }); - }; - - getFilteredFilters = createSelector( - state => state.filters, - (state, props) => props.filterFilter, - (filters, filterFilter) => { - if (filterFilter) { - const filterFilterToLowercase = filterFilter.toLowerCase(); - return filters.filter(filter => - filter.value.toLowerCase().includes(filterFilterToLowercase) - ); - } - - return filters; - } - ); - - startDeleteFilter = filter => { - this.setState({ - filterToDelete: filter, - isDeleteConfirmationModalVisible: true, - }); - }; - - hideDeleteConfirmationModal = () => { - this.setState({ - filterToDelete: undefined, - isDeleteConfirmationModalVisible: false, - }); - }; - - deleteFilter = async () => { - const { indexPattern, onAddOrRemoveFilter } = this.props; - const { filterToDelete, filters } = this.state; - - indexPattern.sourceFilters = filters.filter(filter => { - return filter.clientId !== filterToDelete.clientId; - }); - - this.setState({ isSaving: true }); - await indexPattern.save(); - onAddOrRemoveFilter && onAddOrRemoveFilter(); - this.updateFilters(); - this.setState({ isSaving: false }); - this.hideDeleteConfirmationModal(); - }; - - onAddFilter = async value => { - const { indexPattern, onAddOrRemoveFilter } = this.props; - - indexPattern.sourceFilters = [...(indexPattern.sourceFilters || []), { value }]; - - this.setState({ isSaving: true }); - await indexPattern.save(); - onAddOrRemoveFilter && onAddOrRemoveFilter(); - this.updateFilters(); - this.setState({ isSaving: false }); - }; - - saveFilter = async ({ filterId, newFilterValue }) => { - const { indexPattern } = this.props; - const { filters } = this.state; - - indexPattern.sourceFilters = filters.map(filter => { - if (filter.clientId === filterId) { - return { - value: newFilterValue, - clientId: filter.clientId, - }; - } - return filter; - }); - - this.setState({ isSaving: true }); - await indexPattern.save(); - this.updateFilters(); - this.setState({ isSaving: false }); - }; - - renderDeleteConfirmationModal() { - const { filterToDelete } = this.state; - - if (!filterToDelete) { - return null; - } - - return ( - - - } - onCancel={this.hideDeleteConfirmationModal} - onConfirm={this.deleteFilter} - cancelButtonText={ - - } - buttonColor="danger" - confirmButtonText={ - - } - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - /> - - ); - } - - render() { - const { indexPattern, fieldWildcardMatcher } = this.props; - - const { isSaving } = this.state; - - const filteredFilters = this.getFilteredFilters(this.state, this.props); - - return ( -
-
- - -
- - {this.renderDeleteConfirmationModal()} - - ); - } -} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/source_filters_table.test.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/source_filters_table.test.tsx new file mode 100644 index 0000000000000..1b68dd13566d3 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/source_filters_table.test.tsx @@ -0,0 +1,175 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { SourceFiltersTable } from './source_filters_table'; +import { IIndexPattern } from '../../../../../../../../../plugins/data/public'; + +jest.mock('@elastic/eui', () => ({ + EuiButton: 'eui-button', + EuiTitle: 'eui-title', + EuiText: 'eui-text', + EuiHorizontalRule: 'eui-horizontal-rule', + EuiSpacer: 'eui-spacer', + EuiCallOut: 'eui-call-out', + EuiLink: 'eui-link', + EuiOverlayMask: 'eui-overlay-mask', + EuiConfirmModal: 'eui-confirm-modal', + EuiLoadingSpinner: 'eui-loading-spinner', + Comparators: { + property: () => {}, + default: () => {}, + }, +})); + +jest.mock('./components/header', () => ({ Header: 'header' })); +jest.mock('./components/table', () => ({ + // Note: this seems to fix React complaining about non lowercase attributes + Table: () => { + return 'table'; + }, +})); + +const getIndexPatternMock = (mockedFields: any = {}) => + ({ + sourceFilters: [{ value: 'time*' }, { value: 'nam*' }, { value: 'age*' }], + ...mockedFields, + } as IIndexPattern); + +describe('SourceFiltersTable', () => { + test('should render normally', () => { + const component = shallow( + {}} + filterFilter={''} + /> + ); + + expect(component).toMatchSnapshot(); + }); + + test('should filter based on the query bar', () => { + const component = shallow( + {}} + filterFilter={''} + /> + ); + + component.setProps({ filterFilter: 'ti' }); + expect(component).toMatchSnapshot(); + }); + + test('should should a loading indicator when saving', () => { + const component = shallow( + {}} + /> + ); + + component.setState({ isSaving: true }); + expect(component).toMatchSnapshot(); + }); + + test('should show a delete modal', () => { + const component = shallow( + {}} + /> + ); + + component.instance().startDeleteFilter({ value: 'tim*', clientId: 1 }); + component.update(); // We are not calling `.setState` directly so we need to re-render + expect(component).toMatchSnapshot(); + }); + + test('should remove a filter', async () => { + const save = jest.fn(); + const component = shallow( + {}} + /> + ); + + component.instance().startDeleteFilter({ value: 'tim*', clientId: 1 }); + component.update(); // We are not calling `.setState` directly so we need to re-render + await component.instance().deleteFilter(); + component.update(); // We are not calling `.setState` directly so we need to re-render + + expect(save).toBeCalled(); + expect(component).toMatchSnapshot(); + }); + + test('should add a filter', async () => { + const save = jest.fn(); + const component = shallow( + {}} + /> + ); + + await component.instance().onAddFilter('na*'); + component.update(); // We are not calling `.setState` directly so we need to re-render + + expect(save).toBeCalled(); + expect(component).toMatchSnapshot(); + }); + + test('should update a filter', async () => { + const save = jest.fn(); + const component = shallow( + {}} + /> + ); + + await component.instance().saveFilter({ clientId: 'tim*', value: 'ti*' }); + component.update(); // We are not calling `.setState` directly so we need to re-render + + expect(save).toBeCalled(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/source_filters_table.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/source_filters_table.tsx new file mode 100644 index 0000000000000..dcf8ae9e1323f --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/source_filters_table.tsx @@ -0,0 +1,192 @@ +/* + * 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, { Component } from 'react'; +import { createSelector } from 'reselect'; + +import { EuiSpacer } from '@elastic/eui'; +import { AddFilter, Table, Header, DeleteFilterConfirmationModal } from './components'; +import { IIndexPattern } from '../../../../../../../../../plugins/data/public'; +import { SourceFiltersTableFilter } from './types'; + +export interface SourceFiltersTableProps { + indexPattern: IIndexPattern; + filterFilter: string; + fieldWildcardMatcher: Function; + onAddOrRemoveFilter?: Function; +} + +export interface SourceFiltersTableState { + filterToDelete: any; + isDeleteConfirmationModalVisible: boolean; + isSaving: boolean; + filters: SourceFiltersTableFilter[]; +} + +export class SourceFiltersTable extends Component< + SourceFiltersTableProps, + SourceFiltersTableState +> { + // Source filters do not have any unique ids, only the value is stored. + // To ensure we can create a consistent and expected UX when managing + // source filters, we are assigning a unique id to each filter on the + // client side only + private clientSideId: number = 0; + + constructor(props: SourceFiltersTableProps) { + super(props); + + this.state = { + filterToDelete: undefined, + isDeleteConfirmationModalVisible: false, + isSaving: false, + filters: [], + }; + } + + UNSAFE_componentWillMount() { + this.updateFilters(); + } + + updateFilters = () => { + const sourceFilters = this.props.indexPattern.sourceFilters; + const filters = (sourceFilters || []).map((sourceFilter: any) => ({ + ...sourceFilter, + clientId: ++this.clientSideId, + })); + + this.setState({ filters }); + }; + + getFilteredFilters = createSelector( + (state: SourceFiltersTableState) => state.filters, + (state: SourceFiltersTableState, props: SourceFiltersTableProps) => props.filterFilter, + (filters, filterFilter) => { + if (filterFilter) { + const filterFilterToLowercase = filterFilter.toLowerCase(); + return filters.filter(filter => + filter.value.toLowerCase().includes(filterFilterToLowercase) + ); + } + + return filters; + } + ); + + startDeleteFilter = (filter: SourceFiltersTableFilter) => { + this.setState({ + filterToDelete: filter, + isDeleteConfirmationModalVisible: true, + }); + }; + + hideDeleteConfirmationModal = () => { + this.setState({ + filterToDelete: undefined, + isDeleteConfirmationModalVisible: false, + }); + }; + + deleteFilter = async () => { + const { indexPattern, onAddOrRemoveFilter } = this.props; + const { filterToDelete, filters } = this.state; + + indexPattern.sourceFilters = filters.filter(filter => { + return filter.clientId !== filterToDelete.clientId; + }); + + this.setState({ isSaving: true }); + await indexPattern.save(); + + if (onAddOrRemoveFilter) { + onAddOrRemoveFilter(); + } + + this.updateFilters(); + this.setState({ isSaving: false }); + this.hideDeleteConfirmationModal(); + }; + + onAddFilter = async (value: string) => { + const { indexPattern, onAddOrRemoveFilter } = this.props; + + indexPattern.sourceFilters = [...(indexPattern.sourceFilters || []), { value }]; + + this.setState({ isSaving: true }); + await indexPattern.save(); + + if (onAddOrRemoveFilter) { + onAddOrRemoveFilter(); + } + + this.updateFilters(); + this.setState({ isSaving: false }); + }; + + saveFilter = async ({ clientId, value }: SourceFiltersTableFilter) => { + const { indexPattern } = this.props; + const { filters } = this.state; + + indexPattern.sourceFilters = filters.map(filter => { + if (filter.clientId === clientId) { + return { + value, + clientId, + }; + } + + return filter; + }); + + this.setState({ isSaving: true }); + await indexPattern.save(); + this.updateFilters(); + this.setState({ isSaving: false }); + }; + + render() { + const { indexPattern, fieldWildcardMatcher } = this.props; + const { isSaving, filterToDelete } = this.state; + const filteredFilters = this.getFilteredFilters(this.state, this.props); + + return ( + <> +
+ + +
+ + {filterToDelete && ( + + )} + + ); + } +} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/types.ts b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/types.ts new file mode 100644 index 0000000000000..ee3689f017471 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/types.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +/** @internal **/ +export interface SourceFiltersTableFilter { + value: string; + clientId: string | number; +} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/_objects.html b/src/legacy/core_plugins/kibana/public/management/sections/objects/_objects.html deleted file mode 100644 index 090fb7b636685..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/_objects.html +++ /dev/null @@ -1,5 +0,0 @@ - - -
-
-
diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/_objects.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/_objects.js deleted file mode 100644 index c5901ca6ee6bf..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/_objects.js +++ /dev/null @@ -1,104 +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 { savedObjectManagementRegistry } from '../../saved_object_registry'; -import objectIndexHTML from './_objects.html'; -import uiRoutes from 'ui/routes'; -import chrome from 'ui/chrome'; -import { uiModules } from 'ui/modules'; -import React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; -import { ObjectsTable } from './components/objects_table'; -import { I18nContext } from 'ui/i18n'; -import { get } from 'lodash'; -import { npStart } from 'ui/new_platform'; -import { getIndexBreadcrumbs } from './breadcrumbs'; - -const REACT_OBJECTS_TABLE_DOM_ELEMENT_ID = 'reactSavedObjectsTable'; - -function updateObjectsTable($scope, $injector) { - const indexPatterns = npStart.plugins.data.indexPatterns; - const $http = $injector.get('$http'); - const kbnUrl = $injector.get('kbnUrl'); - const config = $injector.get('config'); - - const savedObjectsClient = npStart.core.savedObjects.client; - const services = savedObjectManagementRegistry.all().map(obj => obj.service); - const uiCapabilites = npStart.core.application.capabilities; - - $scope.$$postDigest(() => { - const node = document.getElementById(REACT_OBJECTS_TABLE_DOM_ELEMENT_ID); - if (!node) { - return; - } - - render( - - { - if (object.meta.editUrl) { - kbnUrl.change(object.meta.editUrl); - $scope.$apply(); - } - }} - canGoInApp={object => { - const { inAppUrl } = object.meta; - return inAppUrl && get(uiCapabilites, inAppUrl.uiCapabilitiesPath); - }} - /> - , - node - ); - }); -} - -function destroyObjectsTable() { - const node = document.getElementById(REACT_OBJECTS_TABLE_DOM_ELEMENT_ID); - node && unmountComponentAtNode(node); -} - -uiRoutes - .when('/management/kibana/objects', { - template: objectIndexHTML, - k7Breadcrumbs: getIndexBreadcrumbs, - requireUICapability: 'management.kibana.objects', - }) - .when('/management/kibana/objects/:service', { - redirectTo: '/management/kibana/objects', - }); - -uiModules.get('apps/management').directive('kbnManagementObjects', function() { - return { - restrict: 'E', - controllerAs: 'managementObjectsController', - controller: function($scope, $injector) { - updateObjectsTable($scope, $injector); - $scope.$on('$destroy', destroyObjectsTable); - }, - }; -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.html b/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.html deleted file mode 100644 index 8bce0aabcd64a..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.html +++ /dev/null @@ -1,5 +0,0 @@ - - -
-
-
diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js deleted file mode 100644 index a847055b40015..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js +++ /dev/null @@ -1,85 +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 React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; -import 'angular'; -import 'angular-elastic/elastic'; -import uiRoutes from 'ui/routes'; -import { uiModules } from 'ui/modules'; -import { I18nContext } from 'ui/i18n'; -import { npStart } from 'ui/new_platform'; -import objectViewHTML from './_view.html'; -import { getViewBreadcrumbs } from './breadcrumbs'; -import { savedObjectManagementRegistry } from '../../saved_object_registry'; -import { SavedObjectEdition } from './saved_object_view'; - -const REACT_OBJECTS_VIEW_DOM_ELEMENT_ID = 'reactSavedObjectsView'; - -uiRoutes.when('/management/kibana/objects/:service/:id', { - template: objectViewHTML, - k7Breadcrumbs: getViewBreadcrumbs, - requireUICapability: 'management.kibana.objects', -}); - -function createReactView($scope, $routeParams) { - const { service: serviceName, id: objectId, notFound } = $routeParams; - - const { savedObjects, overlays, notifications, application } = npStart.core; - - $scope.$$postDigest(() => { - const node = document.getElementById(REACT_OBJECTS_VIEW_DOM_ELEMENT_ID); - if (!node) { - return; - } - - render( - - - , - node - ); - }); -} - -function destroyReactView() { - const node = document.getElementById(REACT_OBJECTS_VIEW_DOM_ELEMENT_ID); - node && unmountComponentAtNode(node); -} - -uiModules - .get('apps/management', ['monospaced.elastic']) - .directive('kbnManagementObjectsView', function() { - return { - restrict: 'E', - controller: function($scope, $routeParams) { - createReactView($scope, $routeParams); - $scope.$on('$destroy', destroyReactView); - }, - }; - }); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/breadcrumbs.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/breadcrumbs.js deleted file mode 100644 index e9082bfeb680d..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/breadcrumbs.js +++ /dev/null @@ -1,50 +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 { MANAGEMENT_BREADCRUMB } from 'ui/management'; -import { i18n } from '@kbn/i18n'; - -import { savedObjectManagementRegistry } from '../../saved_object_registry'; - -export function getIndexBreadcrumbs() { - return [ - MANAGEMENT_BREADCRUMB, - { - text: i18n.translate('kbn.management.savedObjects.indexBreadcrumb', { - defaultMessage: 'Saved objects', - }), - href: '#/management/kibana/objects', - }, - ]; -} - -export function getViewBreadcrumbs($routeParams) { - const serviceObj = savedObjectManagementRegistry.get($routeParams.service); - const { service } = serviceObj; - - return [ - ...getIndexBreadcrumbs(), - { - text: i18n.translate('kbn.management.savedObjects.editBreadcrumb', { - defaultMessage: 'Edit {savedObjectType}', - values: { savedObjectType: service.type }, - }), - }, - ]; -} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/header.test.tsx.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/header.test.tsx.snap deleted file mode 100644 index 7e1f7ea12b014..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/header.test.tsx.snap +++ /dev/null @@ -1,165 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Intro component renders correctly 1`] = ` -
- -
- -
- -

- - Edit search - -

-
-
-
- -
- -
- -
- - - - - - -
- - - -
-
-
- -
- -
- -
-`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/header.tsx b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/header.tsx deleted file mode 100644 index 641493e0cbaa8..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/header.tsx +++ /dev/null @@ -1,110 +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 React from 'react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiTitle, - EuiButton, - EuiPageContentHeader, - EuiPageContentHeaderSection, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -interface HeaderProps { - canEdit: boolean; - canDelete: boolean; - canViewInApp: boolean; - type: string; - viewUrl: string; - onDeleteClick: () => void; -} - -export const Header = ({ - canEdit, - canDelete, - canViewInApp, - type, - viewUrl, - onDeleteClick, -}: HeaderProps) => { - return ( - - - - {canEdit ? ( -

- -

- ) : ( -

- -

- )} -
-
- - - {canViewInApp && ( - - - - - - )} - {canDelete && ( - - onDeleteClick()} - data-test-subj="savedObjectEditDelete" - > - - - - )} - - -
- ); -}; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/__snapshots__/objects_table.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/__snapshots__/objects_table.test.js.snap deleted file mode 100644 index 2c0a5d8f6b8f1..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/__snapshots__/objects_table.test.js.snap +++ /dev/null @@ -1,354 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ObjectsTable delete should show a confirm modal 1`] = ` - - } - confirmButtonText={ - - } - defaultFocusedButton="confirm" - onCancel={[Function]} - onConfirm={[Function]} - title={ - - } -> -

- -

- -
-`; - -exports[`ObjectsTable export should allow the user to choose when exporting all 1`] = ` - - - - - - - - - } - labelType="legend" - > - - - - - } - name="includeReferencesDeep" - onChange={[Function]} - /> - - - - - - - - - - - - - - - - - - - - -`; - -exports[`ObjectsTable import should show the flyout 1`] = ` - -`; - -exports[`ObjectsTable relationships should show the flyout 1`] = ` - -`; - -exports[`ObjectsTable should render normally 1`] = ` - -
- -
- -`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/objects_table.test.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/objects_table.test.js deleted file mode 100644 index 7b9c17640a0f3..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/objects_table.test.js +++ /dev/null @@ -1,592 +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 React from 'react'; -import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; -import { mockManagementPlugin } from '../../../../../../../../../../plugins/index_pattern_management/public/mocks'; -import { Query } from '@elastic/eui'; - -import { ObjectsTable, POSSIBLE_TYPES } from '../objects_table'; -import { Flyout } from '../components/flyout/'; -import { Relationships } from '../components/relationships/'; -import { findObjects } from '../../../lib'; -import { extractExportDetails } from '../../../lib/extract_export_details'; - -jest.mock('ui/kfetch', () => ({ kfetch: jest.fn() })); - -jest.mock('../../../../../../../../../../plugins/index_pattern_management/public', () => ({ - setup: mockManagementPlugin.createSetupContract(), - start: mockManagementPlugin.createStartContract(), -})); - -jest.mock('../../../lib/find_objects', () => ({ - findObjects: jest.fn(), -})); - -jest.mock('../components/header', () => ({ - Header: () => 'Header', -})); - -jest.mock('ui/chrome', () => ({ - addBasePath: () => '', - getInjected: () => ['index-pattern', 'visualization', 'dashboard', 'search'], -})); - -jest.mock('../../../lib/fetch_export_objects', () => ({ - fetchExportObjects: jest.fn(), -})); - -jest.mock('../../../lib/fetch_export_by_type_and_search', () => ({ - fetchExportByTypeAndSearch: jest.fn(), -})); - -jest.mock('../../../lib/extract_export_details', () => ({ - extractExportDetails: jest.fn(), -})); - -jest.mock('../../../lib/get_saved_object_counts', () => ({ - getSavedObjectCounts: jest.fn().mockImplementation(() => { - return { - 'index-pattern': 0, - visualization: 0, - dashboard: 0, - search: 0, - }; - }), -})); - -jest.mock('@elastic/filesaver', () => ({ - saveAs: jest.fn(), -})); - -jest.mock('../../../lib/get_relationships', () => ({ - getRelationships: jest.fn(), -})); - -jest.mock('ui/notify', () => ({})); - -const allSavedObjects = [ - { - id: '1', - type: 'index-pattern', - attributes: { - title: `MyIndexPattern*`, - }, - }, - { - id: '2', - type: 'search', - attributes: { - title: `MySearch`, - }, - }, - { - id: '3', - type: 'dashboard', - attributes: { - title: `MyDashboard`, - }, - }, - { - id: '4', - type: 'visualization', - attributes: { - title: `MyViz`, - }, - }, -]; - -const $http = () => {}; -$http.post = jest.fn().mockImplementation(() => []); -const defaultProps = { - goInspectObject: () => {}, - confirmModalPromise: jest.fn(), - savedObjectsClient: { - find: jest.fn(), - bulkGet: jest.fn(), - }, - indexPatterns: { - clearCache: jest.fn(), - }, - $http, - basePath: '', - newIndexPatternUrl: '', - kbnIndex: '', - services: [], - uiCapabilities: { - savedObjectsManagement: { - read: true, - edit: false, - delete: false, - }, - }, - canDelete: true, -}; - -beforeEach(() => { - findObjects.mockImplementation(() => ({ - total: 4, - savedObjects: [ - { - id: '1', - type: 'index-pattern', - meta: { - title: `MyIndexPattern*`, - icon: 'indexPatternApp', - editUrl: '#/management/kibana/index_patterns/1', - inAppUrl: { - path: '/management/kibana/index_patterns/1', - uiCapabilitiesPath: 'management.kibana.index_patterns', - }, - }, - }, - { - id: '2', - type: 'search', - meta: { - title: `MySearch`, - icon: 'search', - editUrl: '#/management/kibana/objects/savedSearches/2', - inAppUrl: { - path: '/discover/2', - uiCapabilitiesPath: 'discover.show', - }, - }, - }, - { - id: '3', - type: 'dashboard', - meta: { - title: `MyDashboard`, - icon: 'dashboardApp', - editUrl: '#/management/kibana/objects/savedDashboards/3', - inAppUrl: { - path: '/dashboard/3', - uiCapabilitiesPath: 'dashboard.show', - }, - }, - }, - { - id: '4', - type: 'visualization', - meta: { - title: `MyViz`, - icon: 'visualizeApp', - editUrl: '#/management/kibana/objects/savedVisualizations/4', - inAppUrl: { - path: '/visualize/edit/4', - uiCapabilitiesPath: 'visualize.show', - }, - }, - }, - ], - })); -}); - -let addDangerMock; -let addSuccessMock; -let addWarningMock; - -describe('ObjectsTable', () => { - beforeEach(() => { - defaultProps.savedObjectsClient.find.mockClear(); - extractExportDetails.mockReset(); - // mock _.debounce to fire immediately with no internal timer - require('lodash').debounce = func => { - function debounced(...args) { - return func.apply(this, args); - } - return debounced; - }; - addDangerMock = jest.fn(); - addSuccessMock = jest.fn(); - addWarningMock = jest.fn(); - require('ui/notify').toastNotifications = { - addDanger: addDangerMock, - addSuccess: addSuccessMock, - addWarning: addWarningMock, - }; - }); - - it('should render normally', async () => { - const component = shallowWithI18nProvider( - - ); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component).toMatchSnapshot(); - }); - - it('should add danger toast when find fails', async () => { - findObjects.mockImplementation(() => { - throw new Error('Simulated find error'); - }); - const component = shallowWithI18nProvider( - - ); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(addDangerMock).toHaveBeenCalled(); - }); - - describe('export', () => { - it('should export selected objects', async () => { - const mockSelectedSavedObjects = [ - { id: '1', type: 'index-pattern' }, - { id: '3', type: 'dashboard' }, - ]; - - const mockSavedObjects = mockSelectedSavedObjects.map(obj => ({ - _id: obj.id, - _source: {}, - })); - - const mockSavedObjectsClient = { - ...defaultProps.savedObjectsClient, - bulkGet: jest.fn().mockImplementation(() => ({ - savedObjects: mockSavedObjects, - })), - }; - - const { fetchExportObjects } = require('../../../lib/fetch_export_objects'); - - const component = shallowWithI18nProvider( - - ); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - // Set some as selected - component.instance().onSelectionChanged(mockSelectedSavedObjects); - - await component.instance().onExport(true); - - expect(fetchExportObjects).toHaveBeenCalledWith(mockSelectedSavedObjects, true); - expect(addSuccessMock).toHaveBeenCalledWith({ - title: 'Your file is downloading in the background', - }); - }); - - it('should display a warning is export contains missing references', async () => { - const mockSelectedSavedObjects = [ - { id: '1', type: 'index-pattern' }, - { id: '3', type: 'dashboard' }, - ]; - - const mockSavedObjects = mockSelectedSavedObjects.map(obj => ({ - _id: obj.id, - _source: {}, - })); - - const mockSavedObjectsClient = { - ...defaultProps.savedObjectsClient, - bulkGet: jest.fn().mockImplementation(() => ({ - savedObjects: mockSavedObjects, - })), - }; - - const { fetchExportObjects } = require('../../../lib/fetch_export_objects'); - extractExportDetails.mockImplementation(() => ({ - exportedCount: 2, - missingRefCount: 1, - missingReferences: [{ id: '7', type: 'visualisation' }], - })); - - const component = shallowWithI18nProvider( - - ); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - // Set some as selected - component.instance().onSelectionChanged(mockSelectedSavedObjects); - - await component.instance().onExport(true); - - expect(fetchExportObjects).toHaveBeenCalledWith(mockSelectedSavedObjects, true); - expect(addWarningMock).toHaveBeenCalledWith({ - title: - 'Your file is downloading in the background. ' + - 'Some related objects could not be found. ' + - 'Please see the last line in the exported file for a list of missing objects.', - }); - }); - - it('should allow the user to choose when exporting all', async () => { - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - component.find('Header').prop('onExportAll')(); - component.update(); - - expect(component.find('EuiModal')).toMatchSnapshot(); - }); - - it('should export all', async () => { - const { - fetchExportByTypeAndSearch, - } = require('../../../lib/fetch_export_by_type_and_search'); - const { saveAs } = require('@elastic/filesaver'); - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - // Set up mocks - const blob = new Blob([JSON.stringify(allSavedObjects)], { type: 'application/ndjson' }); - fetchExportByTypeAndSearch.mockImplementation(() => blob); - - await component.instance().onExportAll(); - - expect(fetchExportByTypeAndSearch).toHaveBeenCalledWith(POSSIBLE_TYPES, undefined, true); - expect(saveAs).toHaveBeenCalledWith(blob, 'export.ndjson'); - expect(addSuccessMock).toHaveBeenCalledWith({ - title: 'Your file is downloading in the background', - }); - }); - - it('should export all, accounting for the current search criteria', async () => { - const { - fetchExportByTypeAndSearch, - } = require('../../../lib/fetch_export_by_type_and_search'); - const { saveAs } = require('@elastic/filesaver'); - const component = shallowWithI18nProvider(); - - component.instance().onQueryChange({ - query: Query.parse('test'), - }); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - // Set up mocks - const blob = new Blob([JSON.stringify(allSavedObjects)], { type: 'application/ndjson' }); - fetchExportByTypeAndSearch.mockImplementation(() => blob); - - await component.instance().onExportAll(); - - expect(fetchExportByTypeAndSearch).toHaveBeenCalledWith(POSSIBLE_TYPES, 'test*', true); - expect(saveAs).toHaveBeenCalledWith(blob, 'export.ndjson'); - expect(addSuccessMock).toHaveBeenCalledWith({ - title: 'Your file is downloading in the background', - }); - }); - }); - - describe('import', () => { - it('should show the flyout', async () => { - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - component.instance().showImportFlyout(); - component.update(); - - expect(component.find(Flyout)).toMatchSnapshot(); - }); - - it('should hide the flyout', async () => { - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - component.instance().hideImportFlyout(); - component.update(); - - expect(component.find(Flyout).length).toBe(0); - }); - }); - - describe('relationships', () => { - it('should fetch relationships', async () => { - const { getRelationships } = require('../../../lib/get_relationships'); - - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - await component.instance().getRelationships('search', '1'); - const savedObjectTypes = ['index-pattern', 'visualization', 'dashboard', 'search']; - expect(getRelationships).toHaveBeenCalledWith( - 'search', - '1', - savedObjectTypes, - defaultProps.$http, - defaultProps.basePath - ); - }); - - it('should show the flyout', async () => { - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - component.instance().onShowRelationships({ - id: '2', - type: 'search', - meta: { - title: `MySearch`, - icon: 'search', - editUrl: '#/management/kibana/objects/savedSearches/2', - inAppUrl: { - path: '/discover/2', - uiCapabilitiesPath: 'discover.show', - }, - }, - }); - component.update(); - - expect(component.find(Relationships)).toMatchSnapshot(); - expect(component.state('relationshipObject')).toEqual({ - id: '2', - type: 'search', - meta: { - title: 'MySearch', - editUrl: '#/management/kibana/objects/savedSearches/2', - icon: 'search', - inAppUrl: { - path: '/discover/2', - uiCapabilitiesPath: 'discover.show', - }, - }, - }); - }); - - it('should hide the flyout', async () => { - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - component.instance().onHideRelationships(); - component.update(); - - expect(component.find(Relationships).length).toBe(0); - expect(component.state('relationshipId')).toBe(undefined); - expect(component.state('relationshipType')).toBe(undefined); - expect(component.state('relationshipTitle')).toBe(undefined); - }); - }); - - describe('delete', () => { - it('should show a confirm modal', async () => { - const component = shallowWithI18nProvider(); - - const mockSelectedSavedObjects = [ - { id: '1', type: 'index-pattern', title: 'Title 1' }, - { id: '3', type: 'dashboard', title: 'Title 2' }, - ]; - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - // Set some as selected - component.instance().onSelectionChanged(mockSelectedSavedObjects); - await component.instance().onDelete(); - component.update(); - - expect(component.find('EuiConfirmModal')).toMatchSnapshot(); - }); - - it('should delete selected objects', async () => { - const mockSelectedSavedObjects = [ - { id: '1', type: 'index-pattern' }, - { id: '3', type: 'dashboard' }, - ]; - - const mockSavedObjects = mockSelectedSavedObjects.map(obj => ({ - id: obj.id, - type: obj.type, - source: {}, - })); - - const mockSavedObjectsClient = { - ...defaultProps.savedObjectsClient, - bulkGet: jest.fn().mockImplementation(() => ({ - savedObjects: mockSavedObjects, - })), - delete: jest.fn(), - }; - - const component = shallowWithI18nProvider( - - ); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - // Set some as selected - component.instance().onSelectionChanged(mockSelectedSavedObjects); - - await component.instance().delete(); - - expect(defaultProps.indexPatterns.clearCache).toHaveBeenCalled(); - expect(mockSavedObjectsClient.bulkGet).toHaveBeenCalledWith(mockSelectedSavedObjects); - expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith( - mockSavedObjects[0].type, - mockSavedObjects[0].id - ); - expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith( - mockSavedObjects[1].type, - mockSavedObjects[1].id - ); - expect(component.state('selectedSavedObjects').length).toBe(0); - }); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__jest__/__snapshots__/flyout.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__jest__/__snapshots__/flyout.test.js.snap deleted file mode 100644 index 34ce8394232ed..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__jest__/__snapshots__/flyout.test.js.snap +++ /dev/null @@ -1,679 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Flyout conflicts should allow conflict resolution 1`] = ` - - - -

- -

-
-
- - - - - } - > -

- - - , - } - } - /> -

-
-
- -
- - - - - - - - - - - - - - -
-`; - -exports[`Flyout conflicts should allow conflict resolution 2`] = ` -[MockFunction] { - "calls": Array [ - Array [ - Object { - "getConflictResolutions": [Function], - "state": Object { - "conflictedIndexPatterns": undefined, - "conflictedSavedObjectsLinkedToSavedSearches": undefined, - "conflictedSearchDocs": undefined, - "conflictingRecord": undefined, - "error": undefined, - "failedImports": Array [ - Object { - "error": Object { - "references": Array [ - Object { - "id": "MyIndexPattern*", - "type": "index-pattern", - }, - ], - "type": "missing_references", - }, - "obj": Object { - "id": "1", - "title": "My Visualization", - "type": "visualization", - }, - }, - ], - "file": Object { - "name": "foo.ndjson", - "path": "/home/foo.ndjson", - }, - "importCount": 0, - "indexPatterns": Array [ - Object { - "id": "1", - }, - Object { - "id": "2", - }, - ], - "isLegacyFile": false, - "isOverwriteAllChecked": true, - "loadingMessage": undefined, - "status": "loading", - "unmatchedReferences": Array [ - Object { - "existingIndexPatternId": "MyIndexPattern*", - "list": Array [ - Object { - "id": "1", - "title": "My Visualization", - "type": "visualization", - }, - ], - "newIndexPatternId": "2", - }, - ], - }, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Object { - "failedImports": Array [], - "importCount": 1, - "status": "success", - }, - }, - ], -} -`; - -exports[`Flyout conflicts should handle errors 1`] = ` - - } -> -

- -

-

- -`; - -exports[`Flyout errors should display unsupported type errors properly 1`] = ` - - } -> -

- -

-

- wigwags [id=1] unsupported type -

-
-`; - -exports[`Flyout legacy conflicts should allow conflict resolution 1`] = ` - - - -

- -

-
-
- - - - - } - > -

- -

-
-
- - - - } - > -

- - - , - } - } - /> -

-
-
- -
- - - - - - - - - - - - - - -
-`; - -exports[`Flyout legacy conflicts should handle errors 1`] = ` -Array [ - - } - > -

- -

-
, - - } - > -

- - - , - } - } - /> -

-
, - - } - > -

- foobar -

-
, -] -`; - -exports[`Flyout should render import step 1`] = ` - - - -

- -

-
-
- - - - } - labelType="label" - > - - } - onChange={[Function]} - /> - - - - } - name="overwriteAll" - onChange={[Function]} - /> - - - - - - - - - - - - - - - - - -
-`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__jest__/flyout.test.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__jest__/flyout.test.js deleted file mode 100644 index 0d16e0ae35dd6..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__jest__/flyout.test.js +++ /dev/null @@ -1,566 +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 React from 'react'; -import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; -import { mockManagementPlugin } from '../../../../../../../../../../../../plugins/index_pattern_management/public/mocks'; -import { Flyout } from '../flyout'; - -jest.mock('ui/kfetch', () => ({ kfetch: jest.fn() })); - -jest.mock('../../../../../lib/import_file', () => ({ - importFile: jest.fn(), -})); - -jest.mock('../../../../../lib/resolve_import_errors', () => ({ - resolveImportErrors: jest.fn(), -})); - -jest.mock('ui/chrome', () => ({ - addBasePath: () => {}, - getInjected: () => ['index-pattern', 'visualization', 'dashboard', 'search'], -})); - -jest.mock('../../../../../lib/import_legacy_file', () => ({ - importLegacyFile: jest.fn(), -})); - -jest.mock('../../../../../lib/resolve_saved_objects', () => ({ - resolveSavedObjects: jest.fn(), - resolveSavedSearches: jest.fn(), - resolveIndexPatternConflicts: jest.fn(), - saveObjects: jest.fn(), -})); - -jest.mock('../../../../../../../../../../../../plugins/index_pattern_management/public', () => ({ - setup: mockManagementPlugin.createSetupContract(), - start: mockManagementPlugin.createStartContract(), -})); - -jest.mock('ui/notify', () => ({})); - -const defaultProps = { - close: jest.fn(), - done: jest.fn(), - services: [], - newIndexPatternUrl: '', - getConflictResolutions: jest.fn(), - confirmModalPromise: jest.fn(), - indexPatterns: { - getFields: jest.fn().mockImplementation(() => [{ id: '1' }, { id: '2' }]), - }, -}; - -const mockFile = { - name: 'foo.ndjson', - path: '/home/foo.ndjson', -}; -const legacyMockFile = { - name: 'foo.json', - path: '/home/foo.json', -}; - -describe('Flyout', () => { - it('should render import step', async () => { - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component).toMatchSnapshot(); - }); - - it('should toggle the overwrite all control', async () => { - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component.state('isOverwriteAllChecked')).toBe(true); - component.find('EuiSwitch').simulate('change'); - expect(component.state('isOverwriteAllChecked')).toBe(false); - }); - - it('should allow picking a file', async () => { - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component.state('file')).toBe(undefined); - component.find('EuiFilePicker').simulate('change', [mockFile]); - expect(component.state('file')).toBe(mockFile); - }); - - it('should allow removing a file', async () => { - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await Promise.resolve(); - // Ensure the state changes are reflected - component.update(); - - expect(component.state('file')).toBe(undefined); - component.find('EuiFilePicker').simulate('change', [mockFile]); - expect(component.state('file')).toBe(mockFile); - component.find('EuiFilePicker').simulate('change', []); - expect(component.state('file')).toBe(undefined); - }); - - it('should handle invalid files', async () => { - const { importLegacyFile } = require('../../../../../lib/import_legacy_file'); - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - importLegacyFile.mockImplementation(() => { - throw new Error('foobar'); - }); - - await component.instance().legacyImport(); - expect(component.state('error')).toBe('The file could not be processed.'); - - importLegacyFile.mockImplementation(() => ({ - invalid: true, - })); - - await component.instance().legacyImport(); - expect(component.state('error')).toBe( - 'Saved objects file format is invalid and cannot be imported.' - ); - }); - - describe('conflicts', () => { - const { importFile } = require('../../../../../lib/import_file'); - const { resolveImportErrors } = require('../../../../../lib/resolve_import_errors'); - - beforeEach(() => { - importFile.mockImplementation(() => ({ - success: false, - successCount: 0, - errors: [ - { - id: '1', - type: 'visualization', - title: 'My Visualization', - error: { - type: 'missing_references', - references: [ - { - id: 'MyIndexPattern*', - type: 'index-pattern', - }, - ], - }, - }, - ], - })); - resolveImportErrors.mockImplementation(() => ({ - status: 'success', - importCount: 1, - failedImports: [], - })); - }); - - it('should figure out unmatchedReferences', async () => { - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - component.setState({ file: mockFile, isLegacyFile: false }); - await component.instance().import(); - - expect(importFile).toHaveBeenCalledWith(mockFile, true); - expect(component.state()).toMatchObject({ - conflictedIndexPatterns: undefined, - conflictedSavedObjectsLinkedToSavedSearches: undefined, - conflictedSearchDocs: undefined, - importCount: 0, - status: 'idle', - error: undefined, - unmatchedReferences: [ - { - existingIndexPatternId: 'MyIndexPattern*', - newIndexPatternId: undefined, - list: [ - { - id: '1', - type: 'visualization', - title: 'My Visualization', - }, - ], - }, - ], - }); - }); - - it('should allow conflict resolution', async () => { - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - component.setState({ file: mockFile, isLegacyFile: false }); - await component.instance().import(); - - // Ensure it looks right - component.update(); - expect(component).toMatchSnapshot(); - - // Ensure we can change the resolution - component.instance().onIndexChanged('MyIndexPattern*', { target: { value: '2' } }); - expect(component.state('unmatchedReferences')[0].newIndexPatternId).toBe('2'); - - // Let's resolve now - await component - .find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]') - .simulate('click'); - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - expect(resolveImportErrors).toMatchSnapshot(); - }); - - it('should handle errors', async () => { - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - resolveImportErrors.mockImplementation(() => ({ - status: 'success', - importCount: 0, - failedImports: [ - { - obj: { - type: 'visualization', - id: '1', - }, - error: { - type: 'unknown', - }, - }, - ], - })); - - component.setState({ file: mockFile, isLegacyFile: false }); - - // Go through the import flow - await component.instance().import(); - component.update(); - // Set a resolution - component.instance().onIndexChanged('MyIndexPattern*', { target: { value: '2' } }); - await component - .find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]') - .simulate('click'); - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - - expect(component.state('failedImports')).toEqual([ - { - error: { - type: 'unknown', - }, - obj: { - id: '1', - type: 'visualization', - }, - }, - ]); - expect(component.find('EuiFlyoutBody EuiCallOut')).toMatchSnapshot(); - }); - }); - - describe('errors', () => { - const { importFile } = require('../../../../../lib/import_file'); - const { resolveImportErrors } = require('../../../../../lib/resolve_import_errors'); - - it('should display unsupported type errors properly', async () => { - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await Promise.resolve(); - // Ensure the state changes are reflected - component.update(); - - importFile.mockImplementation(() => ({ - success: false, - successCount: 0, - errors: [ - { - id: '1', - type: 'wigwags', - title: 'My Title', - error: { - type: 'unsupported_type', - }, - }, - ], - })); - resolveImportErrors.mockImplementation(() => ({ - status: 'success', - importCount: 0, - failedImports: [ - { - error: { - type: 'unsupported_type', - }, - obj: { - id: '1', - type: 'wigwags', - title: 'My Title', - }, - }, - ], - })); - - component.setState({ file: mockFile, isLegacyFile: false }); - - // Go through the import flow - await component.instance().import(); - component.update(); - - // Ensure all promises resolve - await Promise.resolve(); - - expect(component.state('status')).toBe('success'); - expect(component.state('failedImports')).toEqual([ - { - error: { - type: 'unsupported_type', - }, - obj: { - id: '1', - type: 'wigwags', - title: 'My Title', - }, - }, - ]); - expect(component.find('EuiFlyout EuiCallOut')).toMatchSnapshot(); - }); - }); - - describe('legacy conflicts', () => { - const { importLegacyFile } = require('../../../../../lib/import_legacy_file'); - const { - resolveSavedObjects, - resolveSavedSearches, - resolveIndexPatternConflicts, - saveObjects, - } = require('../../../../../lib/resolve_saved_objects'); - - const mockData = [ - { - _id: '1', - _type: 'search', - }, - { - _id: '2', - _type: 'index-pattern', - }, - { - _id: '3', - _type: 'invalid', - }, - ]; - - const mockConflictedIndexPatterns = [ - { - doc: { - _type: 'index-pattern', - _id: '1', - _source: { - title: 'MyIndexPattern*', - }, - }, - obj: { - searchSource: { - getOwnField: field => { - if (field === 'index') { - return 'MyIndexPattern*'; - } - if (field === 'filter') { - return [{ meta: { index: 'filterIndex' } }]; - } - }, - }, - _serialize: () => { - return { references: [{ id: 'MyIndexPattern*' }, { id: 'filterIndex' }] }; - }, - }, - }, - ]; - - const mockConflictedSavedObjectsLinkedToSavedSearches = [2]; - const mockConflictedSearchDocs = [3]; - - beforeEach(() => { - importLegacyFile.mockImplementation(() => mockData); - resolveSavedObjects.mockImplementation(() => ({ - conflictedIndexPatterns: mockConflictedIndexPatterns, - conflictedSavedObjectsLinkedToSavedSearches: mockConflictedSavedObjectsLinkedToSavedSearches, - conflictedSearchDocs: mockConflictedSearchDocs, - importedObjectCount: 2, - confirmModalPromise: () => {}, - })); - }); - - it('should figure out unmatchedReferences', async () => { - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - component.setState({ file: legacyMockFile, isLegacyFile: true }); - await component.instance().legacyImport(); - - expect(importLegacyFile).toHaveBeenCalledWith(legacyMockFile); - // Remove the last element from data since it should be filtered out - expect(resolveSavedObjects).toHaveBeenCalledWith( - mockData.slice(0, 2).map(doc => ({ ...doc, _migrationVersion: {} })), - true, - defaultProps.services, - defaultProps.indexPatterns, - defaultProps.confirmModalPromise - ); - - expect(component.state()).toMatchObject({ - conflictedIndexPatterns: mockConflictedIndexPatterns, - conflictedSavedObjectsLinkedToSavedSearches: mockConflictedSavedObjectsLinkedToSavedSearches, - conflictedSearchDocs: mockConflictedSearchDocs, - importCount: 2, - status: 'idle', - error: undefined, - unmatchedReferences: [ - { - existingIndexPatternId: 'MyIndexPattern*', - newIndexPatternId: undefined, - list: [ - { - id: 'MyIndexPattern*', - title: 'MyIndexPattern*', - type: 'index-pattern', - }, - ], - }, - { - existingIndexPatternId: 'filterIndex', - list: [ - { - id: 'filterIndex', - title: 'MyIndexPattern*', - type: 'index-pattern', - }, - ], - newIndexPatternId: undefined, - }, - ], - }); - }); - - it('should allow conflict resolution', async () => { - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - component.setState({ file: legacyMockFile, isLegacyFile: true }); - await component.instance().legacyImport(); - - // Ensure it looks right - component.update(); - expect(component).toMatchSnapshot(); - - // Ensure we can change the resolution - component.instance().onIndexChanged('MyIndexPattern*', { target: { value: '2' } }); - expect(component.state('unmatchedReferences')[0].newIndexPatternId).toBe('2'); - - // Let's resolve now - await component - .find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]') - .simulate('click'); - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - expect(resolveIndexPatternConflicts).toHaveBeenCalledWith( - component.instance().resolutions, - mockConflictedIndexPatterns, - true, - defaultProps.indexPatterns - ); - expect(saveObjects).toHaveBeenCalledWith( - mockConflictedSavedObjectsLinkedToSavedSearches, - true - ); - expect(resolveSavedSearches).toHaveBeenCalledWith( - mockConflictedSearchDocs, - defaultProps.services, - defaultProps.indexPatterns, - true - ); - }); - - it('should handle errors', async () => { - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - resolveIndexPatternConflicts.mockImplementation(() => { - throw new Error('foobar'); - }); - - component.setState({ file: legacyMockFile, isLegacyFile: true }); - - // Go through the import flow - await component.instance().legacyImport(); - component.update(); - // Set a resolution - component.instance().onIndexChanged('MyIndexPattern*', { target: { value: '2' } }); - await component - .find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]') - .simulate('click'); - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - - expect(component.state('error')).toEqual('foobar'); - expect(component.find('EuiFlyoutBody EuiCallOut')).toMatchSnapshot(); - }); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/flyout.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/flyout.js deleted file mode 100644 index da2221bb54203..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/flyout.js +++ /dev/null @@ -1,945 +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 React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { take, get as getField } from 'lodash'; -import { - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiButtonEmpty, - EuiButton, - EuiText, - EuiTitle, - EuiForm, - EuiFormRow, - EuiSwitch, - EuiFilePicker, - EuiInMemoryTable, - EuiSelect, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingKibana, - EuiCallOut, - EuiSpacer, - EuiLink, - EuiConfirmModal, - EuiOverlayMask, - EUI_MODAL_CONFIRM_BUTTON, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { - importFile, - importLegacyFile, - resolveImportErrors, - logLegacyImport, - getDefaultTitle, -} from '../../../../lib'; -import { processImportResponse } from '../../../../lib/process_import_response'; -import { - resolveSavedObjects, - resolveSavedSearches, - resolveIndexPatternConflicts, - saveObjects, -} from '../../../../lib/resolve_saved_objects'; -import { POSSIBLE_TYPES } from '../../objects_table'; - -export class Flyout extends Component { - static propTypes = { - close: PropTypes.func.isRequired, - done: PropTypes.func.isRequired, - services: PropTypes.array.isRequired, - newIndexPatternUrl: PropTypes.string.isRequired, - indexPatterns: PropTypes.object.isRequired, - confirmModalPromise: PropTypes.func.isRequired, - }; - - constructor(props) { - super(props); - - this.state = { - conflictedIndexPatterns: undefined, - conflictedSavedObjectsLinkedToSavedSearches: undefined, - conflictedSearchDocs: undefined, - unmatchedReferences: undefined, - conflictingRecord: undefined, - error: undefined, - file: undefined, - importCount: 0, - indexPatterns: undefined, - isOverwriteAllChecked: true, - loadingMessage: undefined, - isLegacyFile: false, - status: 'idle', - }; - } - - componentDidMount() { - this.fetchIndexPatterns(); - } - - fetchIndexPatterns = async () => { - const indexPatterns = await this.props.indexPatterns.getFields(['id', 'title']); - this.setState({ indexPatterns }); - }; - - changeOverwriteAll = () => { - this.setState(state => ({ - isOverwriteAllChecked: !state.isOverwriteAllChecked, - })); - }; - - setImportFile = ([file]) => { - if (!file) { - this.setState({ file: undefined, isLegacyFile: false }); - return; - } - this.setState({ - file, - isLegacyFile: /\.json$/i.test(file.name) || file.type === 'application/json', - }); - }; - - /** - * Import - * - * Does the initial import of a file, resolveImportErrors then handles errors and retries - */ - import = async () => { - const { file, isOverwriteAllChecked } = this.state; - this.setState({ status: 'loading', error: undefined }); - - // Import the file - let response; - try { - response = await importFile(file, isOverwriteAllChecked); - } catch (e) { - this.setState({ - status: 'error', - error: i18n.translate('kbn.management.objects.objectsTable.flyout.importFileErrorMessage', { - defaultMessage: 'The file could not be processed.', - }), - }); - return; - } - - this.setState(processImportResponse(response), () => { - // Resolve import errors right away if there's no index patterns to match - // This will ask about overwriting each object, etc - if (this.state.unmatchedReferences.length === 0) { - this.resolveImportErrors(); - } - }); - }; - - /** - * Get Conflict Resolutions - * - * Function iterates through the objects, displays a modal for each asking the user if they wish to overwrite it or not. - * - * @param {array} objects List of objects to request the user if they wish to overwrite it - * @return {Promise} An object with the key being "type:id" and value the resolution chosen by the user - */ - getConflictResolutions = async objects => { - const resolutions = {}; - for (const { type, id, title } of objects) { - const overwrite = await new Promise(resolve => { - this.setState({ - conflictingRecord: { - id, - type, - title, - done: resolve, - }, - }); - }); - resolutions[`${type}:${id}`] = overwrite; - this.setState({ conflictingRecord: undefined }); - } - return resolutions; - }; - - /** - * Resolve Import Errors - * - * Function goes through the failedImports and tries to resolve the issues. - */ - resolveImportErrors = async () => { - this.setState({ - error: undefined, - status: 'loading', - loadingMessage: undefined, - }); - - try { - const updatedState = await resolveImportErrors({ - state: this.state, - getConflictResolutions: this.getConflictResolutions, - }); - this.setState(updatedState); - } catch (e) { - this.setState({ - status: 'error', - error: i18n.translate( - 'kbn.management.objects.objectsTable.flyout.resolveImportErrorsFileErrorMessage', - { defaultMessage: 'The file could not be processed.' } - ), - }); - } - }; - - legacyImport = async () => { - const { services, indexPatterns, confirmModalPromise } = this.props; - const { file, isOverwriteAllChecked } = this.state; - - this.setState({ status: 'loading', error: undefined }); - - // Log warning on server, don't wait for response - logLegacyImport(); - - let contents; - try { - contents = await importLegacyFile(file); - } catch (e) { - this.setState({ - status: 'error', - error: i18n.translate( - 'kbn.management.objects.objectsTable.flyout.importLegacyFileErrorMessage', - { defaultMessage: 'The file could not be processed.' } - ), - }); - return; - } - - if (!Array.isArray(contents)) { - this.setState({ - status: 'error', - error: i18n.translate( - 'kbn.management.objects.objectsTable.flyout.invalidFormatOfImportedFileErrorMessage', - { defaultMessage: 'Saved objects file format is invalid and cannot be imported.' } - ), - }); - return; - } - - contents = contents - .filter(content => POSSIBLE_TYPES.includes(content._type)) - .map(doc => ({ - ...doc, - // The server assumes that documents with no migrationVersion are up to date. - // That assumption enables Kibana and other API consumers to not have to build - // up migrationVersion prior to creating new objects. But it means that imports - // need to set migrationVersion to something other than undefined, so that imported - // docs are not seen as automatically up-to-date. - _migrationVersion: doc._migrationVersion || {}, - })); - - const { - conflictedIndexPatterns, - conflictedSavedObjectsLinkedToSavedSearches, - conflictedSearchDocs, - importedObjectCount, - failedImports, - } = await resolveSavedObjects( - contents, - isOverwriteAllChecked, - services, - indexPatterns, - confirmModalPromise - ); - - const byId = {}; - conflictedIndexPatterns - .map(({ doc, obj }) => { - return { doc, obj: obj._serialize() }; - }) - .forEach(({ doc, obj }) => - obj.references.forEach(ref => { - byId[ref.id] = byId[ref.id] != null ? byId[ref.id].concat({ doc, obj }) : [{ doc, obj }]; - }) - ); - const unmatchedReferences = Object.entries(byId).reduce( - (accum, [existingIndexPatternId, list]) => { - accum.push({ - existingIndexPatternId, - newIndexPatternId: undefined, - list: list.map(({ doc }) => ({ - id: existingIndexPatternId, - type: doc._type, - title: doc._source.title, - })), - }); - return accum; - }, - [] - ); - - this.setState({ - conflictedIndexPatterns, - conflictedSavedObjectsLinkedToSavedSearches, - conflictedSearchDocs, - failedImports, - unmatchedReferences, - importCount: importedObjectCount, - status: unmatchedReferences.length === 0 ? 'success' : 'idle', - }); - }; - - get hasUnmatchedReferences() { - return this.state.unmatchedReferences && this.state.unmatchedReferences.length > 0; - } - - get resolutions() { - return this.state.unmatchedReferences.reduce( - (accum, { existingIndexPatternId, newIndexPatternId }) => { - if (newIndexPatternId) { - accum.push({ - oldId: existingIndexPatternId, - newId: newIndexPatternId, - }); - } - return accum; - }, - [] - ); - } - - confirmLegacyImport = async () => { - const { - conflictedIndexPatterns, - isOverwriteAllChecked, - conflictedSavedObjectsLinkedToSavedSearches, - conflictedSearchDocs, - failedImports, - } = this.state; - - const { services, indexPatterns } = this.props; - - this.setState({ - error: undefined, - status: 'loading', - loadingMessage: undefined, - }); - - let importCount = this.state.importCount; - - if (this.hasUnmatchedReferences) { - try { - const resolutions = this.resolutions; - - // Do not Promise.all these calls as the order matters - this.setState({ - loadingMessage: i18n.translate( - 'kbn.management.objects.objectsTable.flyout.confirmLegacyImport.resolvingConflictsLoadingMessage', - { defaultMessage: 'Resolving conflicts…' } - ), - }); - if (resolutions.length) { - importCount += await resolveIndexPatternConflicts( - resolutions, - conflictedIndexPatterns, - isOverwriteAllChecked, - this.props.indexPatterns - ); - } - this.setState({ - loadingMessage: i18n.translate( - 'kbn.management.objects.objectsTable.flyout.confirmLegacyImport.savingConflictsLoadingMessage', - { defaultMessage: 'Saving conflicts…' } - ), - }); - importCount += await saveObjects( - conflictedSavedObjectsLinkedToSavedSearches, - isOverwriteAllChecked - ); - this.setState({ - loadingMessage: i18n.translate( - 'kbn.management.objects.objectsTable.flyout.confirmLegacyImport.savedSearchAreLinkedProperlyLoadingMessage', - { defaultMessage: 'Ensure saved searches are linked properly…' } - ), - }); - importCount += await resolveSavedSearches( - conflictedSearchDocs, - services, - indexPatterns, - isOverwriteAllChecked - ); - this.setState({ - loadingMessage: i18n.translate( - 'kbn.management.objects.objectsTable.flyout.confirmLegacyImport.retryingFailedObjectsLoadingMessage', - { defaultMessage: 'Retrying failed objects…' } - ), - }); - importCount += await saveObjects( - failedImports.map(({ obj }) => obj), - isOverwriteAllChecked - ); - } catch (e) { - this.setState({ - error: e.message, - status: 'error', - loadingMessage: undefined, - }); - return; - } - } - - this.setState({ status: 'success', importCount }); - }; - - onIndexChanged = (id, e) => { - const value = e.target.value; - this.setState(state => { - const conflictIndex = state.unmatchedReferences.findIndex( - conflict => conflict.existingIndexPatternId === id - ); - if (conflictIndex === -1) { - return state; - } - - return { - unmatchedReferences: [ - ...state.unmatchedReferences.slice(0, conflictIndex), - { - ...state.unmatchedReferences[conflictIndex], - newIndexPatternId: value, - }, - ...state.unmatchedReferences.slice(conflictIndex + 1), - ], - }; - }); - }; - - renderUnmatchedReferences() { - const { unmatchedReferences } = this.state; - - if (!unmatchedReferences) { - return null; - } - - const columns = [ - { - field: 'existingIndexPatternId', - name: i18n.translate( - 'kbn.management.objects.objectsTable.flyout.renderConflicts.columnIdName', - { defaultMessage: 'ID' } - ), - description: i18n.translate( - 'kbn.management.objects.objectsTable.flyout.renderConflicts.columnIdDescription', - { defaultMessage: 'ID of the index pattern' } - ), - sortable: true, - }, - { - field: 'list', - name: i18n.translate( - 'kbn.management.objects.objectsTable.flyout.renderConflicts.columnCountName', - { defaultMessage: 'Count' } - ), - description: i18n.translate( - 'kbn.management.objects.objectsTable.flyout.renderConflicts.columnCountDescription', - { defaultMessage: 'How many affected objects' } - ), - render: list => { - return {list.length}; - }, - }, - { - field: 'list', - name: i18n.translate( - 'kbn.management.objects.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsName', - { defaultMessage: 'Sample of affected objects' } - ), - description: i18n.translate( - 'kbn.management.objects.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsDescription', - { defaultMessage: 'Sample of affected objects' } - ), - render: list => { - return ( -
    - {take(list, 3).map((obj, key) => ( -
  • {obj.title}
  • - ))} -
- ); - }, - }, - { - field: 'existingIndexPatternId', - name: i18n.translate( - 'kbn.management.objects.objectsTable.flyout.renderConflicts.columnNewIndexPatternName', - { defaultMessage: 'New index pattern' } - ), - render: id => { - const options = this.state.indexPatterns.map(indexPattern => ({ - text: indexPattern.title, - value: indexPattern.id, - ['data-test-subj']: `indexPatternOption-${indexPattern.title}`, - })); - - options.unshift({ - text: '-- Skip Import --', - value: '', - }); - - return ( - this.onIndexChanged(id, e)} - options={options} - /> - ); - }, - }, - ]; - - const pagination = { - pageSizeOptions: [5, 10, 25], - }; - - return ( - - ); - } - - renderError() { - const { error, status } = this.state; - - if (status !== 'error') { - return null; - } - - return ( - - - } - color="danger" - > -

{error}

-
- -
- ); - } - - renderBody() { - const { - status, - loadingMessage, - isOverwriteAllChecked, - importCount, - failedImports = [], - isLegacyFile, - } = this.state; - - if (status === 'loading') { - return ( - - - - - -

{loadingMessage}

-
-
-
- ); - } - - // Kept backwards compatible logic - if ( - failedImports.length && - (!this.hasUnmatchedReferences || (isLegacyFile === false && status === 'success')) - ) { - return ( - - } - color="warning" - iconType="help" - > -

- -

-

- {failedImports - .map(({ error, obj }) => { - if (error.type === 'missing_references') { - return error.references.map(reference => { - return i18n.translate( - 'kbn.management.objects.objectsTable.flyout.importFailedMissingReference', - { - defaultMessage: '{type} [id={id}] could not locate {refType} [id={refId}]', - values: { - id: obj.id, - type: obj.type, - refId: reference.id, - refType: reference.type, - }, - } - ); - }); - } else if (error.type === 'unsupported_type') { - return i18n.translate( - 'kbn.management.objects.objectsTable.flyout.importFailedUnsupportedType', - { - defaultMessage: '{type} [id={id}] unsupported type', - values: { - id: obj.id, - type: obj.type, - }, - } - ); - } - return getField(error, 'body.message', error.message || ''); - }) - .join(' ')} -

-
- ); - } - - if (status === 'success') { - if (importCount === 0) { - return ( - - } - color="primary" - /> - ); - } - - return ( - - } - color="success" - iconType="check" - > -

- -

-
- ); - } - - if (this.hasUnmatchedReferences) { - return this.renderUnmatchedReferences(); - } - - return ( - - - } - > - - } - onChange={this.setImportFile} - /> - - - - } - data-test-subj="importSavedObjectsOverwriteToggle" - checked={isOverwriteAllChecked} - onChange={this.changeOverwriteAll} - /> - - - ); - } - - renderFooter() { - const { status } = this.state; - const { done, close } = this.props; - - let confirmButton; - - if (status === 'success') { - confirmButton = ( - - - - ); - } else if (this.hasUnmatchedReferences) { - confirmButton = ( - - - - ); - } else { - confirmButton = ( - - - - ); - } - - return ( - - - - - - - {confirmButton} - - ); - } - - renderSubheader() { - if (this.state.status === 'loading' || this.state.status === 'success') { - return null; - } - - let legacyFileWarning; - if (this.state.isLegacyFile) { - legacyFileWarning = ( - - } - color="warning" - iconType="help" - > -

- -

-
- ); - } - - let indexPatternConflictsWarning; - if (this.hasUnmatchedReferences) { - indexPatternConflictsWarning = ( - - } - color="warning" - iconType="help" - > -

- - - - ), - }} - /> -

-
- ); - } - - if (!legacyFileWarning && !indexPatternConflictsWarning) { - return null; - } - - return ( - - {legacyFileWarning && ( - - - {legacyFileWarning} - - )} - {indexPatternConflictsWarning && ( - - - {indexPatternConflictsWarning} - - )} - - ); - } - - overwriteConfirmed() { - this.state.conflictingRecord.done(true); - } - - overwriteSkipped() { - this.state.conflictingRecord.done(false); - } - - render() { - const { close } = this.props; - - let confirmOverwriteModal; - if (this.state.conflictingRecord) { - confirmOverwriteModal = ( - - -

- -

-
-
- ); - } - - return ( - - - -

- -

-
-
- - - {this.renderSubheader()} - {this.renderError()} - {this.renderBody()} - - - {this.renderFooter()} - {confirmOverwriteModal} -
- ); - } -} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/index.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/index.js deleted file mode 100644 index cdeebdbf7b63a..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/index.js +++ /dev/null @@ -1,20 +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. - */ - -export { Flyout } from './flyout'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/__jest__/__snapshots__/header.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/__jest__/__snapshots__/header.test.js.snap deleted file mode 100644 index 51bd51a5e2e58..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/__jest__/__snapshots__/header.test.js.snap +++ /dev/null @@ -1,106 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Header should render normally 1`] = ` - - - - -

- -

-
-
- - - - - - - - - - - - - - - - - - - -
- - -

- - - -

-
- -
-`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/__jest__/header.test.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/__jest__/header.test.js deleted file mode 100644 index 1f501b5751224..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/__jest__/header.test.js +++ /dev/null @@ -1,39 +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 React from 'react'; -import { shallow } from 'enzyme'; - -import { Header } from '../header'; - -describe('Header', () => { - it('should render normally', () => { - const props = { - onExportAll: () => {}, - onImport: () => {}, - onRefresh: () => {}, - totalCount: 4, - filteredCount: 2, - }; - - const component = shallow(
); - - expect(component).toMatchSnapshot(); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/header.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/header.js deleted file mode 100644 index 0bec8a0cf2daf..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/header.js +++ /dev/null @@ -1,113 +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 React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; - -import { - EuiSpacer, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiTextColor, - EuiButtonEmpty, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export const Header = ({ onExportAll, onImport, onRefresh, filteredCount }) => ( - - - - -

- -

-
-
- - - - - - - - - - - - - - - - - - - - -
- - -

- - - -

-
- -
-); - -Header.propTypes = { - onExportAll: PropTypes.func.isRequired, - onImport: PropTypes.func.isRequired, - onRefresh: PropTypes.func.isRequired, - filteredCount: PropTypes.number.isRequired, -}; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/index.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/index.js deleted file mode 100644 index ac1e7bac06c87..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/header/index.js +++ /dev/null @@ -1,20 +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. - */ - -export { Header } from './header'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap deleted file mode 100644 index 728944f3ccbfe..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap +++ /dev/null @@ -1,716 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Relationships should render dashboards normally 1`] = ` - - - -

- - - -    - MyDashboard -

-
-
- -
- -

- Here are the saved objects related to MyDashboard. Deleting this dashboard affects its parent objects, but not its children. -

-
- - -
-
-
-`; - -exports[`Relationships should render errors 1`] = ` - - - -

- - - -    - MyDashboard -

-
-
- - - } - > - foo - - -
-`; - -exports[`Relationships should render index patterns normally 1`] = ` - - - -

- - - -    - MyIndexPattern* -

-
-
- -
- -

- Here are the saved objects related to MyIndexPattern*. Deleting this index-pattern affects its parent objects, but not its children. -

-
- - -
-
-
-`; - -exports[`Relationships should render searches normally 1`] = ` - - - -

- - - -    - MySearch -

-
-
- -
- -

- Here are the saved objects related to MySearch. Deleting this search affects its parent objects, but not its children. -

-
- - -
-
-
-`; - -exports[`Relationships should render visualizations normally 1`] = ` - - - -

- - - -    - MyViz -

-
-
- -
- -

- Here are the saved objects related to MyViz. Deleting this visualization affects its parent objects, but not its children. -

-
- - -
-
-
-`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/relationships.test.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/relationships.test.js deleted file mode 100644 index 479726e8785d8..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/relationships.test.js +++ /dev/null @@ -1,324 +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 React from 'react'; -import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; - -jest.mock('ui/kfetch', () => ({ kfetch: jest.fn() })); - -jest.mock('ui/chrome', () => ({ - addBasePath: () => '', -})); - -jest.mock('../../../../../lib/fetch_export_by_type_and_search', () => ({ - fetchExportByTypeAndSearch: jest.fn(), -})); - -jest.mock('../../../../../lib/fetch_export_objects', () => ({ - fetchExportObjects: jest.fn(), -})); - -import { Relationships } from '../relationships'; - -describe('Relationships', () => { - it('should render index patterns normally', async () => { - const props = { - goInspectObject: () => {}, - getRelationships: jest.fn().mockImplementation(() => [ - { - type: 'search', - id: '1', - relationship: 'parent', - meta: { - editUrl: '/management/kibana/objects/savedSearches/1', - icon: 'search', - inAppUrl: { - path: '/app/kibana#/discover/1', - uiCapabilitiesPath: 'discover.show', - }, - title: 'My Search Title', - }, - }, - { - type: 'visualization', - id: '2', - relationship: 'parent', - meta: { - editUrl: '/management/kibana/objects/savedVisualizations/2', - icon: 'visualizeApp', - inAppUrl: { - path: '/app/kibana#/visualize/edit/2', - uiCapabilitiesPath: 'visualize.show', - }, - title: 'My Visualization Title', - }, - }, - ]), - savedObject: { - id: '1', - type: 'index-pattern', - meta: { - title: 'MyIndexPattern*', - icon: 'indexPatternApp', - editUrl: '#/management/kibana/index_patterns/1', - inAppUrl: { - path: '/management/kibana/index_patterns/1', - uiCapabilitiesPath: 'management.kibana.index_patterns', - }, - }, - }, - close: jest.fn(), - }; - - const component = shallowWithI18nProvider(); - - // Make sure we are showing loading - expect(component.find('EuiLoadingKibana').length).toBe(1); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(props.getRelationships).toHaveBeenCalled(); - expect(component).toMatchSnapshot(); - }); - - it('should render searches normally', async () => { - const props = { - goInspectObject: () => {}, - getRelationships: jest.fn().mockImplementation(() => [ - { - type: 'index-pattern', - id: '1', - relationship: 'child', - meta: { - editUrl: '/management/kibana/index_patterns/1', - icon: 'indexPatternApp', - inAppUrl: { - path: '/app/kibana#/management/kibana/index_patterns/1', - uiCapabilitiesPath: 'management.kibana.index_patterns', - }, - title: 'My Index Pattern', - }, - }, - { - type: 'visualization', - id: '2', - relationship: 'parent', - meta: { - editUrl: '/management/kibana/objects/savedVisualizations/2', - icon: 'visualizeApp', - inAppUrl: { - path: '/app/kibana#/visualize/edit/2', - uiCapabilitiesPath: 'visualize.show', - }, - title: 'My Visualization Title', - }, - }, - ]), - savedObject: { - id: '1', - type: 'search', - meta: { - title: 'MySearch', - icon: 'search', - editUrl: '#/management/kibana/objects/savedSearches/1', - inAppUrl: { - path: '/discover/1', - uiCapabilitiesPath: 'discover.show', - }, - }, - }, - close: jest.fn(), - }; - - const component = shallowWithI18nProvider(); - - // Make sure we are showing loading - expect(component.find('EuiLoadingKibana').length).toBe(1); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(props.getRelationships).toHaveBeenCalled(); - expect(component).toMatchSnapshot(); - }); - - it('should render visualizations normally', async () => { - const props = { - goInspectObject: () => {}, - getRelationships: jest.fn().mockImplementation(() => [ - { - type: 'dashboard', - id: '1', - relationship: 'parent', - meta: { - editUrl: '/management/kibana/objects/savedDashboards/1', - icon: 'dashboardApp', - inAppUrl: { - path: '/app/kibana#/dashboard/1', - uiCapabilitiesPath: 'dashboard.show', - }, - title: 'My Dashboard 1', - }, - }, - { - type: 'dashboard', - id: '2', - relationship: 'parent', - meta: { - editUrl: '/management/kibana/objects/savedDashboards/2', - icon: 'dashboardApp', - inAppUrl: { - path: '/app/kibana#/dashboard/2', - uiCapabilitiesPath: 'dashboard.show', - }, - title: 'My Dashboard 2', - }, - }, - ]), - savedObject: { - id: '1', - type: 'visualization', - meta: { - title: 'MyViz', - icon: 'visualizeApp', - editUrl: '#/management/kibana/objects/savedVisualizations/1', - inAppUrl: { - path: '/visualize/edit/1', - uiCapabilitiesPath: 'visualize.show', - }, - }, - }, - close: jest.fn(), - }; - - const component = shallowWithI18nProvider(); - - // Make sure we are showing loading - expect(component.find('EuiLoadingKibana').length).toBe(1); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(props.getRelationships).toHaveBeenCalled(); - expect(component).toMatchSnapshot(); - }); - - it('should render dashboards normally', async () => { - const props = { - goInspectObject: () => {}, - getRelationships: jest.fn().mockImplementation(() => [ - { - type: 'visualization', - id: '1', - relationship: 'child', - meta: { - editUrl: '/management/kibana/objects/savedVisualizations/1', - icon: 'visualizeApp', - inAppUrl: { - path: '/app/kibana#/visualize/edit/1', - uiCapabilitiesPath: 'visualize.show', - }, - title: 'My Visualization Title 1', - }, - }, - { - type: 'visualization', - id: '2', - relationship: 'child', - meta: { - editUrl: '/management/kibana/objects/savedVisualizations/2', - icon: 'visualizeApp', - inAppUrl: { - path: '/app/kibana#/visualize/edit/2', - uiCapabilitiesPath: 'visualize.show', - }, - title: 'My Visualization Title 2', - }, - }, - ]), - savedObject: { - id: '1', - type: 'dashboard', - meta: { - title: 'MyDashboard', - icon: 'dashboardApp', - editUrl: '#/management/kibana/objects/savedDashboards/1', - inAppUrl: { - path: '/dashboard/1', - uiCapabilitiesPath: 'dashboard.show', - }, - }, - }, - close: jest.fn(), - }; - - const component = shallowWithI18nProvider(); - - // Make sure we are showing loading - expect(component.find('EuiLoadingKibana').length).toBe(1); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(props.getRelationships).toHaveBeenCalled(); - expect(component).toMatchSnapshot(); - }); - - it('should render errors', async () => { - const props = { - goInspectObject: () => {}, - getRelationships: jest.fn().mockImplementation(() => { - throw new Error('foo'); - }), - savedObject: { - id: '1', - type: 'dashboard', - meta: { - title: 'MyDashboard', - icon: 'dashboardApp', - editUrl: '#/management/kibana/objects/savedDashboards/1', - inAppUrl: { - path: '/dashboard/1', - uiCapabilitiesPath: 'dashboard.show', - }, - }, - }, - close: jest.fn(), - }; - - const component = shallowWithI18nProvider(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(props.getRelationships).toHaveBeenCalled(); - expect(component).toMatchSnapshot(); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/index.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/index.js deleted file mode 100644 index 522b1ce83a6b6..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/index.js +++ /dev/null @@ -1,20 +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. - */ - -export { Relationships } from './relationships'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js deleted file mode 100644 index ce3415ad2f0e7..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js +++ /dev/null @@ -1,340 +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 React, { Component } from 'react'; -import PropTypes from 'prop-types'; - -import { - EuiTitle, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiLink, - EuiIcon, - EuiCallOut, - EuiLoadingKibana, - EuiInMemoryTable, - EuiToolTip, - EuiText, - EuiSpacer, -} from '@elastic/eui'; -import chrome from 'ui/chrome'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { getDefaultTitle, getSavedObjectLabel } from '../../../../lib'; - -export class Relationships extends Component { - static propTypes = { - getRelationships: PropTypes.func.isRequired, - savedObject: PropTypes.object.isRequired, - close: PropTypes.func.isRequired, - goInspectObject: PropTypes.func.isRequired, - canGoInApp: PropTypes.func.isRequired, - }; - - constructor(props) { - super(props); - - this.state = { - relationships: undefined, - isLoading: false, - error: undefined, - }; - } - - UNSAFE_componentWillMount() { - this.getRelationshipData(); - } - - UNSAFE_componentWillReceiveProps(nextProps) { - if (nextProps.savedObject.id !== this.props.savedObject.id) { - this.getRelationshipData(); - } - } - - async getRelationshipData() { - const { savedObject, getRelationships } = this.props; - - this.setState({ isLoading: true }); - - try { - const relationships = await getRelationships(savedObject.type, savedObject.id); - this.setState({ relationships, isLoading: false, error: undefined }); - } catch (err) { - this.setState({ error: err.message, isLoading: false }); - } - } - - renderError() { - const { error } = this.state; - - if (!error) { - return null; - } - - return ( - - } - color="danger" - > - {error} - - ); - } - - renderRelationships() { - const { goInspectObject, savedObject } = this.props; - const { relationships, isLoading, error } = this.state; - - if (error) { - return this.renderError(); - } - - if (isLoading) { - return ; - } - - const columns = [ - { - field: 'type', - name: i18n.translate('kbn.management.objects.objectsTable.relationships.columnTypeName', { - defaultMessage: 'Type', - }), - width: '50px', - align: 'center', - description: i18n.translate( - 'kbn.management.objects.objectsTable.relationships.columnTypeDescription', - { defaultMessage: 'Type of the saved object' } - ), - sortable: false, - render: (type, object) => { - return ( - - - - ); - }, - }, - { - field: 'relationship', - name: i18n.translate( - 'kbn.management.objects.objectsTable.relationships.columnRelationshipName', - { defaultMessage: 'Direct relationship' } - ), - dataType: 'string', - sortable: false, - width: '125px', - 'data-test-subj': 'directRelationship', - render: relationship => { - if (relationship === 'parent') { - return ( - - - - ); - } - if (relationship === 'child') { - return ( - - - - ); - } - }, - }, - { - field: 'meta.title', - name: i18n.translate('kbn.management.objects.objectsTable.relationships.columnTitleName', { - defaultMessage: 'Title', - }), - description: i18n.translate( - 'kbn.management.objects.objectsTable.relationships.columnTitleDescription', - { defaultMessage: 'Title of the saved object' } - ), - dataType: 'string', - sortable: false, - render: (title, object) => { - const { path } = object.meta.inAppUrl || {}; - const canGoInApp = this.props.canGoInApp(object); - if (!canGoInApp) { - return ( - - {title || getDefaultTitle(object)} - - ); - } - return ( - - {title || getDefaultTitle(object)} - - ); - }, - }, - { - name: i18n.translate( - 'kbn.management.objects.objectsTable.relationships.columnActionsName', - { defaultMessage: 'Actions' } - ), - actions: [ - { - name: i18n.translate( - 'kbn.management.objects.objectsTable.relationships.columnActions.inspectActionName', - { defaultMessage: 'Inspect' } - ), - description: i18n.translate( - 'kbn.management.objects.objectsTable.relationships.columnActions.inspectActionDescription', - { defaultMessage: 'Inspect this saved object' } - ), - type: 'icon', - icon: 'inspect', - 'data-test-subj': 'relationshipsTableAction-inspect', - onClick: object => goInspectObject(object), - available: object => !!object.meta.editUrl, - }, - ], - }, - ]; - - const filterTypesMap = new Map( - relationships.map(relationship => [ - relationship.type, - { - value: relationship.type, - name: relationship.type, - view: relationship.type, - }, - ]) - ); - - const search = { - filters: [ - { - type: 'field_value_selection', - field: 'relationship', - name: i18n.translate( - 'kbn.management.objects.objectsTable.relationships.search.filters.relationship.name', - { defaultMessage: 'Direct relationship' } - ), - multiSelect: 'or', - options: [ - { - value: 'parent', - name: 'parent', - view: i18n.translate( - 'kbn.management.objects.objectsTable.relationships.search.filters.relationship.parentAsValue.view', - { defaultMessage: 'Parent' } - ), - }, - { - value: 'child', - name: 'child', - view: i18n.translate( - 'kbn.management.objects.objectsTable.relationships.search.filters.relationship.childAsValue.view', - { defaultMessage: 'Child' } - ), - }, - ], - }, - { - type: 'field_value_selection', - field: 'type', - name: i18n.translate( - 'kbn.management.objects.objectsTable.relationships.search.filters.type.name', - { defaultMessage: 'Type' } - ), - multiSelect: 'or', - options: [...filterTypesMap.values()], - }, - ], - }; - - return ( -
- -

- {i18n.translate( - 'kbn.management.objects.objectsTable.relationships.relationshipsTitle', - { - defaultMessage: - 'Here are the saved objects related to {title}. ' + - 'Deleting this {type} affects its parent objects, but not its children.', - values: { - type: savedObject.type, - title: savedObject.meta.title || getDefaultTitle(savedObject), - }, - } - )} -

-
- - ({ - 'data-test-subj': `relationshipsTableRow`, - })} - /> -
- ); - } - - render() { - const { close, savedObject } = this.props; - - return ( - - - -

- - - -    - {savedObject.meta.title || getDefaultTitle(savedObject)} -

-
-
- - {this.renderRelationships()} -
- ); - } -} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__jest__/__snapshots__/table.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__jest__/__snapshots__/table.test.js.snap deleted file mode 100644 index a4dcfb9c38184..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__jest__/__snapshots__/table.test.js.snap +++ /dev/null @@ -1,428 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Table prevents saved objects from being deleted 1`] = ` - - - - , - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={false} - panelPaddingSize="m" - > - - } - labelType="label" - > - - } - name="includeReferencesDeep" - onChange={[Function]} - /> - - - - - - - , - ] - } - /> - -
- -
-
-`; - -exports[`Table should render normally 1`] = ` - - - - , - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={false} - panelPaddingSize="m" - > - - } - labelType="label" - > - - } - name="includeReferencesDeep" - onChange={[Function]} - /> - - - - - - - , - ] - } - /> - -
- -
-
-`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__jest__/table.test.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__jest__/table.test.js deleted file mode 100644 index 9b3e2314c9f84..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__jest__/table.test.js +++ /dev/null @@ -1,129 +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 React from 'react'; -import { shallowWithI18nProvider, mountWithI18nProvider } from 'test_utils/enzyme_helpers'; -import { findTestSubject } from '@elastic/eui/lib/test'; -import { keyCodes } from '@elastic/eui/lib/services'; -import { npSetup as mockNpSetup } from '../../../../../../../../../../../ui/public/new_platform/__mocks__'; - -jest.mock('ui/kfetch', () => ({ kfetch: jest.fn() })); - -jest.mock('ui/chrome', () => ({ - addBasePath: () => '', -})); - -jest.mock('ui/new_platform', () => ({ - npSetup: mockNpSetup, -})); - -import { Table } from '../table'; - -const defaultProps = { - selectedSavedObjects: [ - { - id: '1', - type: 'index-pattern', - meta: { - title: `MyIndexPattern*`, - icon: 'indexPatternApp', - editUrl: '#/management/kibana/index_patterns/1', - inAppUrl: { - path: '/management/kibana/index_patterns/1', - uiCapabilitiesPath: 'management.kibana.index_patterns', - }, - }, - }, - ], - selectionConfig: { - onSelectionChange: () => {}, - }, - filterOptions: [{ value: 2 }], - onDelete: () => {}, - onExport: () => {}, - goInspectObject: () => {}, - canGoInApp: () => {}, - pageIndex: 1, - pageSize: 2, - items: [ - { - id: '1', - type: 'index-pattern', - meta: { - title: `MyIndexPattern*`, - icon: 'indexPatternApp', - editUrl: '#/management/kibana/index_patterns/1', - inAppUrl: { - path: '/management/kibana/index_patterns/1', - uiCapabilitiesPath: 'management.kibana.index_patterns', - }, - }, - }, - ], - itemId: 'id', - totalItemCount: 3, - onQueryChange: () => {}, - onTableChange: () => {}, - isSearching: false, - onShowRelationships: () => {}, - canDelete: true, -}; - -describe('Table', () => { - it('should render normally', () => { - const component = shallowWithI18nProvider(
); - - expect(component).toMatchSnapshot(); - }); - - it('should handle query parse error', () => { - const onQueryChangeMock = jest.fn(); - const customizedProps = { - ...defaultProps, - onQueryChange: onQueryChangeMock, - }; - - const component = mountWithI18nProvider(
); - const searchBar = findTestSubject(component, 'savedObjectSearchBar'); - - // Send invalid query - searchBar.simulate('keyup', { keyCode: keyCodes.ENTER, target: { value: '?' } }); - expect(onQueryChangeMock).toHaveBeenCalledTimes(0); - expect(component.state().isSearchTextValid).toBe(false); - - onQueryChangeMock.mockReset(); - - // Send valid query to ensure component can recover from invalid query - searchBar.simulate('keyup', { keyCode: keyCodes.ENTER, target: { value: 'I am valid' } }); - expect(onQueryChangeMock).toHaveBeenCalledTimes(1); - expect(component.state().isSearchTextValid).toBe(true); - }); - - it(`prevents saved objects from being deleted`, () => { - const selectedSavedObjects = [ - { type: 'visualization' }, - { type: 'search' }, - { type: 'index-pattern' }, - ]; - const customizedProps = { ...defaultProps, selectedSavedObjects, canDelete: false }; - const component = shallowWithI18nProvider(
); - - expect(component).toMatchSnapshot(); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/index.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/index.js deleted file mode 100644 index e1195c6edfe31..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/index.js +++ /dev/null @@ -1,20 +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. - */ - -export { Table } from './table'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js deleted file mode 100644 index 132fa1e691c1c..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js +++ /dev/null @@ -1,389 +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 chrome from 'ui/chrome'; -import { npSetup } from 'ui/new_platform'; -import React, { PureComponent, Fragment } from 'react'; -import PropTypes from 'prop-types'; - -import { - EuiSearchBar, - EuiBasicTable, - EuiButton, - EuiIcon, - EuiLink, - EuiSpacer, - EuiToolTip, - EuiFormErrorText, - EuiPopover, - EuiSwitch, - EuiFormRow, - EuiText, -} from '@elastic/eui'; -import { getDefaultTitle, getSavedObjectLabel } from '../../../../lib'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export class Table extends PureComponent { - static propTypes = { - selectedSavedObjects: PropTypes.array.isRequired, - selectionConfig: PropTypes.shape({ - selectable: PropTypes.func, - selectableMessage: PropTypes.func, - onSelectionChange: PropTypes.func.isRequired, - }).isRequired, - filterOptions: PropTypes.array.isRequired, - canDelete: PropTypes.bool.isRequired, - onDelete: PropTypes.func.isRequired, - onExport: PropTypes.func.isRequired, - goInspectObject: PropTypes.func.isRequired, - - pageIndex: PropTypes.number.isRequired, - pageSize: PropTypes.number.isRequired, - items: PropTypes.array.isRequired, - itemId: PropTypes.oneOfType([ - PropTypes.string, // the name of the item id property - PropTypes.func, // (item) => string - ]), - totalItemCount: PropTypes.number.isRequired, - onQueryChange: PropTypes.func.isRequired, - onTableChange: PropTypes.func.isRequired, - isSearching: PropTypes.bool.isRequired, - - onShowRelationships: PropTypes.func.isRequired, - }; - - state = { - isSearchTextValid: true, - parseErrorMessage: null, - isExportPopoverOpen: false, - isIncludeReferencesDeepChecked: true, - activeAction: null, - }; - - constructor(props) { - super(props); - this.extraActions = npSetup.plugins.savedObjectsManagement.actionRegistry.getAll(); - } - - onChange = ({ query, error }) => { - if (error) { - this.setState({ - isSearchTextValid: false, - parseErrorMessage: error.message, - }); - return; - } - - this.setState({ - isSearchTextValid: true, - parseErrorMessage: null, - }); - this.props.onQueryChange({ query }); - }; - - closeExportPopover = () => { - this.setState({ isExportPopoverOpen: false }); - }; - - toggleExportPopoverVisibility = () => { - this.setState(state => ({ - isExportPopoverOpen: !state.isExportPopoverOpen, - })); - }; - - toggleIsIncludeReferencesDeepChecked = () => { - this.setState(state => ({ - isIncludeReferencesDeepChecked: !state.isIncludeReferencesDeepChecked, - })); - }; - - onExportClick = () => { - const { onExport } = this.props; - const { isIncludeReferencesDeepChecked } = this.state; - onExport(isIncludeReferencesDeepChecked); - this.setState({ isExportPopoverOpen: false }); - }; - - render() { - const { - pageIndex, - pageSize, - itemId, - items, - totalItemCount, - isSearching, - filterOptions, - selectionConfig: selection, - onDelete, - selectedSavedObjects, - onTableChange, - goInspectObject, - onShowRelationships, - } = this.props; - - const pagination = { - pageIndex: pageIndex, - pageSize: pageSize, - totalItemCount: totalItemCount, - pageSizeOptions: [5, 10, 20, 50], - }; - - const filters = [ - { - type: 'field_value_selection', - field: 'type', - name: i18n.translate('kbn.management.objects.objectsTable.table.typeFilterName', { - defaultMessage: 'Type', - }), - multiSelect: 'or', - options: filterOptions, - }, - // Add this back in once we have tag support - // { - // type: 'field_value_selection', - // field: 'tag', - // name: 'Tags', - // multiSelect: 'or', - // options: [], - // }, - ]; - - const columns = [ - { - field: 'type', - name: i18n.translate('kbn.management.objects.objectsTable.table.columnTypeName', { - defaultMessage: 'Type', - }), - width: '50px', - align: 'center', - description: i18n.translate( - 'kbn.management.objects.objectsTable.table.columnTypeDescription', - { defaultMessage: 'Type of the saved object' } - ), - sortable: false, - 'data-test-subj': 'savedObjectsTableRowType', - render: (type, object) => { - return ( - - - - ); - }, - }, - { - field: 'meta.title', - name: i18n.translate('kbn.management.objects.objectsTable.table.columnTitleName', { - defaultMessage: 'Title', - }), - description: i18n.translate( - 'kbn.management.objects.objectsTable.table.columnTitleDescription', - { defaultMessage: 'Title of the saved object' } - ), - dataType: 'string', - sortable: false, - 'data-test-subj': 'savedObjectsTableRowTitle', - render: (title, object) => { - const { path } = object.meta.inAppUrl || {}; - const canGoInApp = this.props.canGoInApp(object); - if (!canGoInApp) { - return {title || getDefaultTitle(object)}; - } - return ( - {title || getDefaultTitle(object)} - ); - }, - }, - { - name: i18n.translate('kbn.management.objects.objectsTable.table.columnActionsName', { - defaultMessage: 'Actions', - }), - actions: [ - { - name: i18n.translate( - 'kbn.management.objects.objectsTable.table.columnActions.inspectActionName', - { defaultMessage: 'Inspect' } - ), - description: i18n.translate( - 'kbn.management.objects.objectsTable.table.columnActions.inspectActionDescription', - { defaultMessage: 'Inspect this saved object' } - ), - type: 'icon', - icon: 'inspect', - onClick: object => goInspectObject(object), - available: object => !!object.meta.editUrl, - 'data-test-subj': 'savedObjectsTableAction-inspect', - }, - { - name: i18n.translate( - 'kbn.management.objects.objectsTable.table.columnActions.viewRelationshipsActionName', - { defaultMessage: 'Relationships' } - ), - description: i18n.translate( - 'kbn.management.objects.objectsTable.table.columnActions.viewRelationshipsActionDescription', - { - defaultMessage: - 'View the relationships this saved object has to other saved objects', - } - ), - type: 'icon', - icon: 'kqlSelector', - onClick: object => onShowRelationships(object), - 'data-test-subj': 'savedObjectsTableAction-relationships', - }, - ...this.extraActions.map(action => { - return { - ...action.euiAction, - 'data-test-subj': `savedObjectsTableAction-${action.id}`, - onClick: object => { - this.setState({ - activeAction: action, - }); - - action.registerOnFinishCallback(() => { - this.setState({ - activeAction: null, - }); - }); - - action.euiAction.onClick(object); - }, - }; - }), - ], - }, - ]; - - let queryParseError; - if (!this.state.isSearchTextValid) { - const parseErrorMsg = i18n.translate( - 'kbn.management.objects.objectsTable.searchBar.unableToParseQueryErrorMessage', - { defaultMessage: 'Unable to parse query' } - ); - queryParseError = ( - {`${parseErrorMsg}. ${this.state.parseErrorMessage}`} - ); - } - - const button = ( - - - - ); - - const activeActionContents = this.state.activeAction ? this.state.activeAction.render() : null; - - return ( - - {activeActionContents} - - - , - - - } - > - - } - checked={this.state.isIncludeReferencesDeepChecked} - onChange={this.toggleIsIncludeReferencesDeepChecked} - /> - - - - - - - , - ]} - /> - {queryParseError} - -
- ({ - 'data-test-subj': `savedObjectsTableRow row-${item.id}`, - })} - /> -
-
- ); - } -} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/index.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/index.js deleted file mode 100644 index 601dea544361c..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/index.js +++ /dev/null @@ -1,20 +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. - */ - -export { ObjectsTable } from './objects_table'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/objects_table.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/objects_table.js deleted file mode 100644 index 188762f165b24..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/objects_table.js +++ /dev/null @@ -1,722 +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 chrome from 'ui/chrome'; -import { saveAs } from '@elastic/filesaver'; -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { debounce } from 'lodash'; -import { Header } from './components/header'; -import { Flyout } from './components/flyout'; -import { Relationships } from './components/relationships'; -import { Table } from './components/table'; -import { toastNotifications } from 'ui/notify'; - -import { - EuiSpacer, - Query, - EuiInMemoryTable, - EuiIcon, - EuiConfirmModal, - EuiLoadingKibana, - EuiOverlayMask, - EUI_MODAL_CONFIRM_BUTTON, - EuiCheckboxGroup, - EuiToolTip, - EuiPageContent, - EuiSwitch, - EuiModal, - EuiModalHeader, - EuiModalBody, - EuiModalFooter, - EuiButtonEmpty, - EuiButton, - EuiModalHeaderTitle, - EuiFormRow, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { - parseQuery, - getSavedObjectCounts, - getRelationships, - getSavedObjectLabel, - fetchExportObjects, - fetchExportByTypeAndSearch, - findObjects, -} from '../../lib'; -import { extractExportDetails } from '../../lib/extract_export_details'; - -export const POSSIBLE_TYPES = chrome.getInjected('importAndExportableTypes'); - -export class ObjectsTable extends Component { - static propTypes = { - savedObjectsClient: PropTypes.object.isRequired, - indexPatterns: PropTypes.object.isRequired, - $http: PropTypes.func.isRequired, - basePath: PropTypes.string.isRequired, - perPageConfig: PropTypes.number, - newIndexPatternUrl: PropTypes.string.isRequired, - confirmModalPromise: PropTypes.func.isRequired, - services: PropTypes.array.isRequired, - uiCapabilities: PropTypes.object.isRequired, - goInspectObject: PropTypes.func.isRequired, - canGoInApp: PropTypes.func.isRequired, - }; - - constructor(props) { - super(props); - this.savedObjectTypes = POSSIBLE_TYPES; - - this.state = { - totalCount: 0, - page: 0, - perPage: props.perPageConfig || 50, - savedObjects: [], - savedObjectCounts: this.savedObjectTypes.reduce((typeToCountMap, type) => { - typeToCountMap[type] = 0; - return typeToCountMap; - }, {}), - activeQuery: Query.parse(''), - selectedSavedObjects: [], - isShowingImportFlyout: false, - isSearching: false, - filteredItemCount: 0, - isShowingRelationships: false, - relationshipObject: undefined, - isShowingDeleteConfirmModal: false, - isShowingExportAllOptionsModal: false, - isDeleting: false, - exportAllOptions: [], - exportAllSelectedOptions: {}, - isIncludeReferencesDeepChecked: true, - }; - } - - componentDidMount() { - this._isMounted = true; - this.fetchSavedObjects(); - this.fetchCounts(); - } - - componentWillUnmount() { - this._isMounted = false; - this.debouncedFetch.cancel(); - } - - fetchCounts = async () => { - const { queryText, visibleTypes } = parseQuery(this.state.activeQuery); - - const filteredTypes = this.savedObjectTypes.filter( - type => !visibleTypes || visibleTypes.includes(type) - ); - - // These are the saved objects visible in the table. - const filteredSavedObjectCounts = await getSavedObjectCounts( - this.props.$http, - filteredTypes, - queryText - ); - - const exportAllOptions = []; - const exportAllSelectedOptions = {}; - - Object.keys(filteredSavedObjectCounts).forEach(id => { - // Add this type as a bulk-export option. - exportAllOptions.push({ - id, - label: `${id} (${filteredSavedObjectCounts[id] || 0})`, - }); - - // Select it by defayult. - exportAllSelectedOptions[id] = true; - }); - - // Fetch all the saved objects that exist so we can accurately populate the counts within - // the table filter dropdown. - const savedObjectCounts = await getSavedObjectCounts( - this.props.$http, - this.savedObjectTypes, - queryText - ); - - this.setState(state => ({ - ...state, - savedObjectCounts, - exportAllOptions, - exportAllSelectedOptions, - })); - }; - - fetchSavedObjects = () => { - this.setState( - { - isSearching: true, - }, - this.debouncedFetch - ); - }; - - debouncedFetch = debounce(async () => { - const { activeQuery: query, page, perPage } = this.state; - const { queryText, visibleTypes } = parseQuery(query); - // "searchFields" is missing from the "findOptions" but gets injected via the API. - // The API extracts the fields from each uiExports.savedObjectsManagement "defaultSearchField" attribute - const findOptions = { - search: queryText ? `${queryText}*` : undefined, - perPage, - page: page + 1, - fields: ['id'], - type: this.savedObjectTypes.filter(type => !visibleTypes || visibleTypes.includes(type)), - }; - if (findOptions.type.length > 1) { - findOptions.sortField = 'type'; - } - - let resp; - try { - resp = await findObjects(findOptions); - } catch (error) { - if (this._isMounted) { - this.setState({ - isSearching: false, - }); - } - toastNotifications.addDanger({ - title: i18n.translate( - 'kbn.management.objects.objectsTable.unableFindSavedObjectsNotificationMessage', - { defaultMessage: 'Unable find saved objects' } - ), - text: `${error}`, - }); - return; - } - - if (!this._isMounted) { - return; - } - - this.setState(({ activeQuery }) => { - // ignore results for old requests - if (activeQuery.text !== query.text) { - return {}; - } - - return { - savedObjects: resp.savedObjects, - filteredItemCount: resp.total, - isSearching: false, - }; - }); - }, 300); - - refreshData = async () => { - await Promise.all([this.fetchSavedObjects(), this.fetchCounts()]); - }; - - onSelectionChanged = selection => { - this.setState({ selectedSavedObjects: selection }); - }; - - onQueryChange = ({ query }) => { - // TODO: Use isSameQuery to compare new query with state.activeQuery to avoid re-fetching the - // same data we already have. - this.setState( - { - activeQuery: query, - page: 0, // Reset this on each query change - selectedSavedObjects: [], - }, - () => { - this.fetchSavedObjects(); - this.fetchCounts(); - } - ); - }; - - onTableChange = async table => { - const { index: page, size: perPage } = table.page || {}; - - this.setState( - { - page, - perPage, - selectedSavedObjects: [], - }, - this.fetchSavedObjects - ); - }; - - onShowRelationships = object => { - this.setState({ - isShowingRelationships: true, - relationshipObject: object, - }); - }; - - onHideRelationships = () => { - this.setState({ - isShowingRelationships: false, - relationshipObject: undefined, - }); - }; - - onExport = async includeReferencesDeep => { - const { selectedSavedObjects } = this.state; - const objectsToExport = selectedSavedObjects.map(obj => ({ id: obj.id, type: obj.type })); - - let blob; - try { - blob = await fetchExportObjects(objectsToExport, includeReferencesDeep); - } catch (e) { - toastNotifications.addDanger({ - title: i18n.translate('kbn.management.objects.objectsTable.export.dangerNotification', { - defaultMessage: 'Unable to generate export', - }), - }); - throw e; - } - - saveAs(blob, 'export.ndjson'); - - const exportDetails = await extractExportDetails(blob); - this.showExportSuccessMessage(exportDetails); - }; - - onExportAll = async () => { - const { exportAllSelectedOptions, isIncludeReferencesDeepChecked, activeQuery } = this.state; - const { queryText } = parseQuery(activeQuery); - const exportTypes = Object.entries(exportAllSelectedOptions).reduce((accum, [id, selected]) => { - if (selected) { - accum.push(id); - } - return accum; - }, []); - - let blob; - try { - blob = await fetchExportByTypeAndSearch( - exportTypes, - queryText ? `${queryText}*` : undefined, - isIncludeReferencesDeepChecked - ); - } catch (e) { - toastNotifications.addDanger({ - title: i18n.translate('kbn.management.objects.objectsTable.export.dangerNotification', { - defaultMessage: 'Unable to generate export', - }), - }); - throw e; - } - - saveAs(blob, 'export.ndjson'); - - const exportDetails = await extractExportDetails(blob); - this.showExportSuccessMessage(exportDetails); - this.setState({ isShowingExportAllOptionsModal: false }); - }; - - showExportSuccessMessage = exportDetails => { - if (exportDetails && exportDetails.missingReferences.length > 0) { - toastNotifications.addWarning({ - title: i18n.translate( - 'kbn.management.objects.objectsTable.export.successWithMissingRefsNotification', - { - defaultMessage: - 'Your file is downloading in the background. ' + - 'Some related objects could not be found. ' + - 'Please see the last line in the exported file for a list of missing objects.', - } - ), - }); - } else { - toastNotifications.addSuccess({ - title: i18n.translate('kbn.management.objects.objectsTable.export.successNotification', { - defaultMessage: 'Your file is downloading in the background', - }), - }); - } - }; - - finishImport = () => { - this.hideImportFlyout(); - this.fetchSavedObjects(); - this.fetchCounts(); - }; - - showImportFlyout = () => { - this.setState({ isShowingImportFlyout: true }); - }; - - hideImportFlyout = () => { - this.setState({ isShowingImportFlyout: false }); - }; - - onDelete = () => { - this.setState({ isShowingDeleteConfirmModal: true }); - }; - - delete = async () => { - const { savedObjectsClient } = this.props; - const { selectedSavedObjects, isDeleting } = this.state; - - if (isDeleting) { - return; - } - - this.setState({ isDeleting: true }); - - const indexPatterns = selectedSavedObjects.filter(object => object.type === 'index-pattern'); - if (indexPatterns.length) { - await this.props.indexPatterns.clearCache(); - } - - const objects = await savedObjectsClient.bulkGet(selectedSavedObjects); - const deletes = objects.savedObjects.map(object => - savedObjectsClient.delete(object.type, object.id) - ); - await Promise.all(deletes); - - // Unset this - this.setState({ - selectedSavedObjects: [], - }); - - // Fetching all data - await this.fetchSavedObjects(); - await this.fetchCounts(); - - // Allow the user to interact with the table once the saved objects have been re-fetched. - this.setState({ - isShowingDeleteConfirmModal: false, - isDeleting: false, - }); - }; - - getRelationships = async (type, id) => { - return await getRelationships( - type, - id, - this.savedObjectTypes, - this.props.$http, - this.props.basePath - ); - }; - - renderFlyout() { - if (!this.state.isShowingImportFlyout) { - return null; - } - - return ( - - ); - } - - renderRelationships() { - if (!this.state.isShowingRelationships) { - return null; - } - - return ( - - ); - } - - renderDeleteConfirmModal() { - const { isShowingDeleteConfirmModal, isDeleting, selectedSavedObjects } = this.state; - - if (!isShowingDeleteConfirmModal) { - return null; - } - - let modal; - - if (isDeleting) { - // Block the user from interacting with the table while its contents are being deleted. - modal = ; - } else { - const onCancel = () => { - this.setState({ isShowingDeleteConfirmModal: false }); - }; - - const onConfirm = () => { - this.delete(); - }; - - modal = ( - - } - onCancel={onCancel} - onConfirm={onConfirm} - buttonColor="danger" - cancelButtonText={ - - } - confirmButtonText={ - isDeleting ? ( - - ) : ( - - ) - } - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - > -

- -

- ( - - - - ), - }, - { - field: 'id', - name: i18n.translate( - 'kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.idColumnName', - { defaultMessage: 'Id' } - ), - }, - { - field: 'meta.title', - name: i18n.translate( - 'kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName', - { defaultMessage: 'Title' } - ), - }, - ]} - pagination={true} - sorting={false} - /> -
- ); - } - - return {modal}; - } - - changeIncludeReferencesDeep = () => { - this.setState(state => ({ - isIncludeReferencesDeepChecked: !state.isIncludeReferencesDeepChecked, - })); - }; - - closeExportAllModal = () => { - this.setState({ isShowingExportAllOptionsModal: false }); - }; - - renderExportAllOptionsModal() { - const { - isShowingExportAllOptionsModal, - filteredItemCount, - exportAllOptions, - exportAllSelectedOptions, - isIncludeReferencesDeepChecked, - } = this.state; - - if (!isShowingExportAllOptionsModal) { - return null; - } - - return ( - - - - - - - - - - } - labelType="legend" - > - { - const newExportAllSelectedOptions = { - ...exportAllSelectedOptions, - ...{ - [optionId]: !exportAllSelectedOptions[optionId], - }, - }; - - this.setState({ - exportAllSelectedOptions: newExportAllSelectedOptions, - }); - }} - /> - - - - } - checked={isIncludeReferencesDeepChecked} - onChange={this.changeIncludeReferencesDeep} - /> - - - - - - - - - - - - - - - - - - - - - - ); - } - - render() { - const { - selectedSavedObjects, - page, - perPage, - savedObjects, - filteredItemCount, - isSearching, - savedObjectCounts, - } = this.state; - - const selectionConfig = { - onSelectionChange: this.onSelectionChanged, - }; - - const filterOptions = this.savedObjectTypes.map(type => ({ - value: type, - name: type, - view: `${type} (${savedObjectCounts[type] || 0})`, - })); - - return ( - - {this.renderFlyout()} - {this.renderRelationships()} - {this.renderDeleteConfirmModal()} - {this.renderExportAllOptionsModal()} -
this.setState({ isShowingExportAllOptionsModal: true })} - onImport={this.showImportFlyout} - onRefresh={this.refreshData} - filteredCount={filteredItemCount} - /> - -
- - ); - } -} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/index.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/index.js deleted file mode 100644 index 3965c42ac088d..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/index.js +++ /dev/null @@ -1,36 +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 { i18n } from '@kbn/i18n'; -import { management } from 'ui/management'; -import './_view'; -import './_objects'; -import 'ace'; -import { uiModules } from 'ui/modules'; - -// add the module deps to this module -uiModules.get('apps/management'); - -management.getSection('kibana').register('objects', { - display: i18n.translate('kbn.management.objects.savedObjectsSectionLabel', { - defaultMessage: 'Saved Objects', - }), - order: 10, - url: '#/management/kibana/objects', -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/find_objects.ts b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/find_objects.ts deleted file mode 100644 index 24e08f0524f62..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/find_objects.ts +++ /dev/null @@ -1,32 +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 { kfetch } from 'ui/kfetch'; -import { SavedObjectsFindOptions } from 'src/core/public'; -import { keysToCamelCaseShallow } from './case_conversion'; - -export async function findObjects(findOptions: SavedObjectsFindOptions) { - const response = await kfetch({ - method: 'GET', - pathname: '/api/kibana/management/saved_objects/_find', - query: findOptions as Record, - }); - - return keysToCamelCaseShallow(response); -} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/get_relationships.test.ts b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/get_relationships.test.ts deleted file mode 100644 index b45b51b4de293..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/get_relationships.test.ts +++ /dev/null @@ -1,60 +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 { getRelationships } from './get_relationships'; - -describe('getRelationships', () => { - it('should make an http request', async () => { - const $http = jest.fn() as any; - const basePath = 'test'; - - await getRelationships('dashboard', '1', ['search', 'index-pattern'], $http, basePath); - expect($http.mock.calls.length).toBe(1); - }); - - it('should handle successful responses', async () => { - const $http = jest.fn().mockImplementation(() => ({ data: [1, 2] })) as any; - const basePath = 'test'; - - const response = await getRelationships( - 'dashboard', - '1', - ['search', 'index-pattern'], - $http, - basePath - ); - expect(response).toEqual([1, 2]); - }); - - it('should handle errors', async () => { - const $http = jest.fn().mockImplementation(() => { - const err = new Error(); - (err as any).data = { - error: 'Test error', - statusCode: 500, - }; - throw err; - }) as any; - const basePath = 'test'; - - await expect( - getRelationships('dashboard', '1', ['search', 'index-pattern'], $http, basePath) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"Test error"`); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/get_relationships.ts b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/get_relationships.ts deleted file mode 100644 index 07bdf2db68fa2..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/get_relationships.ts +++ /dev/null @@ -1,54 +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 { IHttpService } from 'angular'; -import { get } from 'lodash'; -import { SavedObjectRelation } from '../types'; - -export async function getRelationships( - type: string, - id: string, - savedObjectTypes: string[], - $http: IHttpService, - basePath: string -): Promise { - const url = `${basePath}/api/kibana/management/saved_objects/relationships/${encodeURIComponent( - type - )}/${encodeURIComponent(id)}`; - const options = { - method: 'GET', - url, - params: { - savedObjectTypes, - }, - }; - - try { - const response = await $http(options); - return response?.data; - } catch (resp) { - const respBody = get(resp, 'data', {}) as any; - const err = new Error(respBody.message || respBody.error || `${resp.status} Response`); - - (err as any).statusCode = respBody.statusCode || resp.status; - (err as any).body = respBody; - - throw err; - } -} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/get_saved_object_counts.ts b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/get_saved_object_counts.ts deleted file mode 100644 index d4dda1190bc43..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/get_saved_object_counts.ts +++ /dev/null @@ -1,34 +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 { IHttpService } from 'angular'; -import chrome from 'ui/chrome'; - -const apiBase = chrome.addBasePath('/api/kibana/management/saved_objects/scroll'); -export async function getSavedObjectCounts( - $http: IHttpService, - typesToInclude: string[], - searchString: string -): Promise> { - const results = await $http.post>(`${apiBase}/counts`, { - typesToInclude, - searchString, - }); - return results.data; -} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/index.ts b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/index.ts deleted file mode 100644 index ecdfa6549a54e..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/index.ts +++ /dev/null @@ -1,45 +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. - */ - -export { fetchExportByTypeAndSearch } from './fetch_export_by_type_and_search'; -export { fetchExportObjects } from './fetch_export_objects'; -export { canViewInApp } from './in_app_url'; -export { getRelationships } from './get_relationships'; -export { getSavedObjectCounts } from './get_saved_object_counts'; -export { getSavedObjectLabel } from './get_saved_object_label'; -export { importFile } from './import_file'; -export { importLegacyFile } from './import_legacy_file'; -export { parseQuery } from './parse_query'; -export { resolveImportErrors } from './resolve_import_errors'; -export { - resolveIndexPatternConflicts, - resolveSavedObjects, - resolveSavedSearches, - saveObject, - saveObjects, -} from './resolve_saved_objects'; -export { logLegacyImport } from './log_legacy_import'; -export { - processImportResponse, - ProcessedImportResponse, - FailedImport, -} from './process_import_response'; -export { getDefaultTitle } from './get_default_title'; -export { findObjects } from './find_objects'; -export { extractExportDetails, SavedObjectsExportResultDetails } from './extract_export_details'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/types.ts b/src/legacy/core_plugins/kibana/public/management/sections/objects/types.ts deleted file mode 100644 index 6a89142bc9798..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/types.ts +++ /dev/null @@ -1,56 +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 { SavedObject, SavedObjectReference } from 'src/core/public'; - -export interface SavedObjectMetadata { - icon?: string; - title?: string; - editUrl?: string; - inAppUrl?: { path: string; uiCapabilitiesPath: string }; -} - -export type SavedObjectWithMetadata = SavedObject & { - meta: SavedObjectMetadata; -}; - -export interface SavedObjectRelation { - id: string; - type: string; - relationship: 'child' | 'parent'; - meta: SavedObjectMetadata; -} - -export interface ObjectField { - type: FieldType; - name: string; - value: any; -} - -export type FieldType = 'text' | 'number' | 'boolean' | 'array' | 'json'; - -export interface FieldState { - value?: any; - invalid?: boolean; -} - -export interface SubmittedFormData { - attributes: any; - references: SavedObjectReference[]; -} diff --git a/src/legacy/core_plugins/kibana/public/visualize/_index.scss b/src/legacy/core_plugins/kibana/public/visualize/_index.scss deleted file mode 100644 index 079d82936bb57..0000000000000 --- a/src/legacy/core_plugins/kibana/public/visualize/_index.scss +++ /dev/null @@ -1,5 +0,0 @@ -// Visualize plugin styles -@import 'np_ready/index'; - -// should be removed while moving the visualize into NP -@import '../../../../../plugins/vis_default_editor/public/index' diff --git a/src/legacy/core_plugins/kibana/public/visualize/index.ts b/src/legacy/core_plugins/kibana/public/visualize/index.ts deleted file mode 100644 index c3ae39d9fde25..0000000000000 --- a/src/legacy/core_plugins/kibana/public/visualize/index.ts +++ /dev/null @@ -1,29 +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 { PluginInitializerContext } from 'kibana/public'; -import { VisualizePlugin } from './plugin'; - -export * from './np_ready/visualize_constants'; -export { VisualizeConstants, createVisualizeEditUrl } from './np_ready/visualize_constants'; - -// Core will be looking for this when loading our plugin in the new platform -export const plugin = (context: PluginInitializerContext) => { - return new VisualizePlugin(context); -}; diff --git a/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts b/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts deleted file mode 100644 index d5440c4677d8a..0000000000000 --- a/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts +++ /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 { - ChromeStart, - CoreStart, - SavedObjectsClientContract, - ToastsStart, - IUiSettingsClient, - I18nStart, - PluginInitializerContext, -} from 'kibana/public'; - -import { NavigationPublicPluginStart as NavigationStart } from '../../../../../plugins/navigation/public'; -import { Storage } from '../../../../../plugins/kibana_utils/public'; -import { EmbeddableStart } from '../../../../../plugins/embeddable/public'; -import { SharePluginStart } from '../../../../../plugins/share/public'; -import { DataPublicPluginStart, IndexPatternsContract } from '../../../../../plugins/data/public'; -import { VisualizationsStart } from '../../../../../plugins/visualizations/public'; -import { SavedVisualizations } from './np_ready/types'; -import { UsageCollectionSetup } from '../../../../../plugins/usage_collection/public'; -import { KibanaLegacyStart } from '../../../../../plugins/kibana_legacy/public'; -import { DefaultEditorController } from '../../../../../plugins/vis_default_editor/public'; - -export interface VisualizeKibanaServices { - pluginInitializerContext: PluginInitializerContext; - addBasePath: (url: string) => string; - chrome: ChromeStart; - core: CoreStart; - data: DataPublicPluginStart; - embeddable: EmbeddableStart; - indexPatterns: IndexPatternsContract; - localStorage: Storage; - navigation: NavigationStart; - toastNotifications: ToastsStart; - savedObjectsClient: SavedObjectsClientContract; - savedQueryService: DataPublicPluginStart['query']['savedQueries']; - savedVisualizations: SavedVisualizations; - share: SharePluginStart; - uiSettings: IUiSettingsClient; - config: KibanaLegacyStart['config']; - visualizeCapabilities: any; - visualizations: VisualizationsStart; - usageCollection?: UsageCollectionSetup; - I18nContext: I18nStart['Context']; - setActiveUrl: (newUrl: string) => void; - DefaultVisualizationEditor: typeof DefaultEditorController; - createVisEmbeddableFromObject: VisualizationsStart['__LEGACY']['createVisEmbeddableFromObject']; -} - -let services: VisualizeKibanaServices | null = null; -export function setServices(newServices: VisualizeKibanaServices) { - services = newServices; -} - -export function getServices() { - if (!services) { - throw new Error( - 'Kibana services not set - are you trying to import this module from outside of the visualize app?' - ); - } - return services; -} - -export function clearServices() { - services = null; -} diff --git a/src/legacy/core_plugins/kibana/public/visualize/legacy.ts b/src/legacy/core_plugins/kibana/public/visualize/legacy.ts deleted file mode 100644 index 4ef2c93689714..0000000000000 --- a/src/legacy/core_plugins/kibana/public/visualize/legacy.ts +++ /dev/null @@ -1,28 +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 { PluginInitializerContext } from 'kibana/public'; -import { npSetup, npStart } from 'ui/new_platform'; -import { plugin } from './index'; - -const instance = plugin({ - env: npSetup.plugins.kibanaLegacy.env, -} as PluginInitializerContext); -instance.setup(npSetup.core, npSetup.plugins); -instance.start(npStart.core, npStart.plugins); diff --git a/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts b/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts deleted file mode 100644 index f6d73b987912d..0000000000000 --- a/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts +++ /dev/null @@ -1,36 +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. - */ - -/** - * The imports in this file are static functions and types which still live in legacy folders and are used - * within dashboard. To consolidate them all in one place, they are re-exported from this file. Eventually - * this list should become empty. Imports from the top level of shimmed or moved plugins can be imported - * directly where they are needed. - */ - -export { DashboardConstants } from '../../../../../plugins/dashboard/public'; -export { - VisSavedObject, - VISUALIZE_EMBEDDABLE_TYPE, -} from '../../../../../plugins/visualizations/public/'; -export { - configureAppAngularModule, - migrateLegacyQuery, - subscribeWithScope, -} from '../../../../../plugins/kibana_legacy/public'; diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/url_helper.test.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/url_helper.test.ts deleted file mode 100644 index e6974af9af832..0000000000000 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/url_helper.test.ts +++ /dev/null @@ -1,48 +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 { addEmbeddableToDashboardUrl } from './url_helper'; - -jest.mock('../../../legacy_imports', () => ({ - DashboardConstants: { - ADD_EMBEDDABLE_ID: 'addEmbeddableId', - ADD_EMBEDDABLE_TYPE: 'addEmbeddableType', - CREATE_NEW_DASHBOARD_URL: '/dashboard', - }, - VISUALIZE_EMBEDDABLE_TYPE: 'visualization', -})); - -describe('', () => { - it('addEmbeddableToDashboardUrl when dashboard is not saved', () => { - const id = '123eb456cd'; - const url = - "/pep/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!())"; - expect(addEmbeddableToDashboardUrl(url, id)).toEqual( - `/dashboard?_a=%28description%3A%27%27%2Cfilters%3A%21%28%29%29&_g=%28refreshInterval%3A%28pause%3A%21t%2Cvalue%3A0%29%2Ctime%3A%28from%3Anow-15m%2Cto%3Anow%29%29&addEmbeddableId=${id}&addEmbeddableType=visualization` - ); - }); - it('addEmbeddableToDashboardUrl when dashboard is saved', () => { - const id = '123eb456cd'; - const url = - "/pep/app/kibana#/dashboard/9b780cd0-3dd3-11e8-b2b9-5d5dc1715159?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!())"; - expect(addEmbeddableToDashboardUrl(url, id)).toEqual( - `/dashboard/9b780cd0-3dd3-11e8-b2b9-5d5dc1715159?_a=%28description%3A%27%27%2Cfilters%3A%21%28%29%29&_g=%28refreshInterval%3A%28pause%3A%21t%2Cvalue%3A0%29%2Ctime%3A%28from%3Anow-15m%2Cto%3Anow%29%29&addEmbeddableId=${id}&addEmbeddableType=visualization` - ); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/url_helper.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/url_helper.ts deleted file mode 100644 index c7937c856184a..0000000000000 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/url_helper.ts +++ /dev/null @@ -1,39 +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 { parseUrl, stringify } from 'query-string'; -import { DashboardConstants, VISUALIZE_EMBEDDABLE_TYPE } from '../../../legacy_imports'; - -/** * - * Returns relative dashboard URL with added embeddableType and embeddableId query params - * eg. - * input: url: lol/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)), embeddableId: 12345 - * output: /dashboard?addEmbeddableType=visualization&addEmbeddableId=12345&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)) - * @param url dasbhoard absolute url - * @param embeddableId id of the saved visualization - */ -export function addEmbeddableToDashboardUrl(dashboardUrl: string, embeddableId: string) { - const { url, query } = parseUrl(dashboardUrl); - const [, dashboardId] = url.split(DashboardConstants.CREATE_NEW_DASHBOARD_URL); - - query[DashboardConstants.ADD_EMBEDDABLE_TYPE] = VISUALIZE_EMBEDDABLE_TYPE; - query[DashboardConstants.ADD_EMBEDDABLE_ID] = embeddableId; - - return `${DashboardConstants.CREATE_NEW_DASHBOARD_URL}${dashboardId}?${stringify(query)}`; -} diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts deleted file mode 100644 index e376b4f2bbacf..0000000000000 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts +++ /dev/null @@ -1,78 +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 { - TimeRange, - Query, - Filter, - DataPublicPluginStart, - SavedQuery, -} from 'src/plugins/data/public'; -import { EmbeddableStart } from 'src/plugins/embeddable/public'; -import { PersistedState } from 'src/plugins/visualizations/public'; -import { LegacyCoreStart } from 'kibana/public'; -import { VisSavedObject } from '../legacy_imports'; -import { SavedVisState } from '../../../../../../plugins/visualizations/public'; -import { SavedSearch } from '../../../../../../plugins/discover/public'; - -export type PureVisState = SavedVisState; - -export interface VisualizeAppState { - filters: Filter[]; - uiState: PersistedState; - vis: PureVisState; - query: Query; - savedQuery?: string; - linked: boolean; -} - -export interface VisualizeAppStateTransitions { - set: ( - state: VisualizeAppState - ) => ( - prop: T, - value: VisualizeAppState[T] - ) => VisualizeAppState; - setVis: (state: VisualizeAppState) => (vis: Partial) => VisualizeAppState; - removeSavedQuery: (state: VisualizeAppState) => (defaultQuery: Query) => VisualizeAppState; - unlinkSavedSearch: ( - state: VisualizeAppState - ) => ({ query, parentFilters }: { query?: Query; parentFilters?: Filter[] }) => VisualizeAppState; - updateVisState: (state: VisualizeAppState) => (vis: PureVisState) => VisualizeAppState; - updateFromSavedQuery: (state: VisualizeAppState) => (savedQuery: SavedQuery) => VisualizeAppState; -} - -export interface EditorRenderProps { - core: LegacyCoreStart; - data: DataPublicPluginStart; - filters: Filter[]; - timeRange: TimeRange; - query?: Query; - savedSearch?: SavedSearch; - uiState: PersistedState; - /** - * Flag to determine if visualiztion is linked to the saved search - */ - linked: boolean; -} - -export interface SavedVisualizations { - urlFor: (id: string) => string; - get: (id: string) => Promise; -} diff --git a/src/legacy/core_plugins/kibana/public/visualize/plugin.ts b/src/legacy/core_plugins/kibana/public/visualize/plugin.ts deleted file mode 100644 index 4ffbc307c69a8..0000000000000 --- a/src/legacy/core_plugins/kibana/public/visualize/plugin.ts +++ /dev/null @@ -1,205 +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 { BehaviorSubject } from 'rxjs'; -import { i18n } from '@kbn/i18n'; -import { filter, map } from 'rxjs/operators'; - -import { - AppMountParameters, - CoreSetup, - CoreStart, - Plugin, - PluginInitializerContext, - SavedObjectsClientContract, -} from 'kibana/public'; - -import { Storage, createKbnUrlTracker } from '../../../../../plugins/kibana_utils/public'; -import { - DataPublicPluginStart, - DataPublicPluginSetup, - esFilters, -} from '../../../../../plugins/data/public'; -import { EmbeddableStart } from '../../../../../plugins/embeddable/public'; -import { NavigationPublicPluginStart as NavigationStart } from '../../../../../plugins/navigation/public'; -import { SharePluginStart } from '../../../../../plugins/share/public'; -import { - KibanaLegacySetup, - AngularRenderedAppUpdater, -} from '../../../../../plugins/kibana_legacy/public'; -import { VisualizationsStart } from '../../../../../plugins/visualizations/public'; -import { VisualizeConstants } from './np_ready/visualize_constants'; -import { setServices, VisualizeKibanaServices } from './kibana_services'; -import { - FeatureCatalogueCategory, - HomePublicPluginSetup, -} from '../../../../../plugins/home/public'; -import { UsageCollectionSetup } from '../../../../../plugins/usage_collection/public'; -import { DefaultEditorController } from '../../../../../plugins/vis_default_editor/public'; - -export interface VisualizePluginStartDependencies { - data: DataPublicPluginStart; - embeddable: EmbeddableStart; - navigation: NavigationStart; - share: SharePluginStart; - visualizations: VisualizationsStart; -} - -export interface VisualizePluginSetupDependencies { - home: HomePublicPluginSetup; - kibanaLegacy: KibanaLegacySetup; - usageCollection?: UsageCollectionSetup; - data: DataPublicPluginSetup; -} - -export class VisualizePlugin implements Plugin { - private startDependencies: { - data: DataPublicPluginStart; - embeddable: EmbeddableStart; - navigation: NavigationStart; - savedObjectsClient: SavedObjectsClientContract; - share: SharePluginStart; - visualizations: VisualizationsStart; - } | null = null; - private appStateUpdater = new BehaviorSubject(() => ({})); - private stopUrlTracking: (() => void) | undefined = undefined; - - constructor(private initializerContext: PluginInitializerContext) {} - - public async setup( - core: CoreSetup, - { home, kibanaLegacy, usageCollection, data }: VisualizePluginSetupDependencies - ) { - const { appMounted, appUnMounted, stop: stopUrlTracker, setActiveUrl } = createKbnUrlTracker({ - baseUrl: core.http.basePath.prepend('/app/kibana'), - defaultSubUrl: '#/visualize', - storageKey: `lastUrl:${core.http.basePath.get()}:visualize`, - navLinkUpdater$: this.appStateUpdater, - toastNotifications: core.notifications.toasts, - stateParams: [ - { - kbnUrlKey: '_g', - stateUpdate$: data.query.state$.pipe( - filter( - ({ changes }) => !!(changes.globalFilters || changes.time || changes.refreshInterval) - ), - map(({ state }) => ({ - ...state, - filters: state.filters?.filter(esFilters.isFilterPinned), - })) - ), - }, - ], - }); - this.stopUrlTracking = () => { - stopUrlTracker(); - }; - - kibanaLegacy.registerLegacyApp({ - id: 'visualize', - title: 'Visualize', - updater$: this.appStateUpdater.asObservable(), - navLinkId: 'kibana:visualize', - mount: async (params: AppMountParameters) => { - const [coreStart] = await core.getStartServices(); - - if (this.startDependencies === null) { - throw new Error('not started yet'); - } - - appMounted(); - const { - savedObjectsClient, - embeddable, - navigation, - visualizations, - data: dataStart, - share, - } = this.startDependencies; - - const deps: VisualizeKibanaServices = { - pluginInitializerContext: this.initializerContext, - addBasePath: coreStart.http.basePath.prepend, - core: coreStart, - chrome: coreStart.chrome, - data: dataStart, - embeddable, - indexPatterns: dataStart.indexPatterns, - localStorage: new Storage(localStorage), - navigation, - savedObjectsClient, - savedVisualizations: visualizations.savedVisualizationsLoader, - savedQueryService: dataStart.query.savedQueries, - share, - toastNotifications: coreStart.notifications.toasts, - uiSettings: coreStart.uiSettings, - config: kibanaLegacy.config, - visualizeCapabilities: coreStart.application.capabilities.visualize, - visualizations, - usageCollection, - I18nContext: coreStart.i18n.Context, - setActiveUrl, - DefaultVisualizationEditor: DefaultEditorController, - createVisEmbeddableFromObject: visualizations.__LEGACY.createVisEmbeddableFromObject, - }; - setServices(deps); - - const { renderApp } = await import('./np_ready/application'); - const unmount = renderApp(params.element, params.appBasePath, deps); - return () => { - unmount(); - appUnMounted(); - }; - }, - }); - - home.featureCatalogue.register({ - id: 'visualize', - title: 'Visualize', - description: i18n.translate('kbn.visualize.visualizeDescription', { - defaultMessage: - 'Create visualizations and aggregate data stores in your Elasticsearch indices.', - }), - icon: 'visualizeApp', - path: `/app/kibana#${VisualizeConstants.LANDING_PAGE_PATH}`, - showOnHomePage: true, - category: FeatureCatalogueCategory.DATA, - }); - } - - public start( - core: CoreStart, - { embeddable, navigation, data, share, visualizations }: VisualizePluginStartDependencies - ) { - this.startDependencies = { - data, - embeddable, - navigation, - savedObjectsClient: core.savedObjects.client, - share, - visualizations, - }; - } - - stop() { - if (this.stopUrlTracking) { - this.stopUrlTracking(); - } - } -} diff --git a/src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js b/src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js index 3880f42d52561..6e1b0b7160941 100644 --- a/src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js +++ b/src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js @@ -23,12 +23,18 @@ import _ from 'lodash'; import ChoroplethLayer from '../choropleth_layer'; import { ImageComparator } from 'test_utils/image_comparator'; import worldJson from './world.json'; -import EMS_CATALOGUE from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_manifest.json'; -import EMS_FILES from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_files.json'; -import EMS_TILES from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_tiles.json'; -import EMS_STYLE_ROAD_MAP_BRIGHT from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_style_bright'; -import EMS_STYLE_ROAD_MAP_DESATURATED from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_style_desaturated'; -import EMS_STYLE_DARK_MAP from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_style_dark'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import EMS_CATALOGUE from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_manifest.json'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import EMS_FILES from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_files.json'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import EMS_TILES from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_tiles.json'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import EMS_STYLE_ROAD_MAP_BRIGHT from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_style_bright'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import EMS_STYLE_ROAD_MAP_DESATURATED from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_style_desaturated'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import EMS_STYLE_DARK_MAP from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_style_dark'; import initialPng from './initial.png'; import toiso3Png from './toiso3.png'; @@ -44,6 +50,10 @@ import { createRegionMapTypeDefinition } from '../region_map_type'; import { ExprVis } from '../../../../../plugins/visualizations/public/expressions/vis'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { BaseVisType } from '../../../../../plugins/visualizations/public/vis_types/base_vis_type'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { setInjectedVarFunc } from '../../../../../plugins/maps_legacy/public/kibana_services'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ServiceSettings } from '../../../../../plugins/maps_legacy/public/map/service_settings'; const THRESHOLD = 0.45; const PIXEL_DIFF = 96; @@ -92,7 +102,31 @@ describe('RegionMapsVisualizationTests', function() { let getManifestStub; beforeEach( ngMock.inject((Private, $injector) => { - const serviceSettings = $injector.get('serviceSettings'); + setInjectedVarFunc(injectedVar => { + switch (injectedVar) { + case 'mapConfig': + return { + emsFileApiUrl: '', + emsTileApiUrl: '', + emsLandingPageUrl: '', + }; + case 'tilemapsConfig': + return { + deprecated: { + config: { + options: { + attribution: '123', + }, + }, + }, + }; + case 'version': + return '123'; + default: + return 'not found'; + } + }); + const serviceSettings = new ServiceSettings(); const uiSettings = $injector.get('config'); const regionmapsConfig = { includeElasticMapsService: true, diff --git a/src/legacy/core_plugins/region_map/public/choropleth_layer.js b/src/legacy/core_plugins/region_map/public/choropleth_layer.js index e637a217bfbc3..4ea9cc1f7bfbf 100644 --- a/src/legacy/core_plugins/region_map/public/choropleth_layer.js +++ b/src/legacy/core_plugins/region_map/public/choropleth_layer.js @@ -22,11 +22,9 @@ import L from 'leaflet'; import _ from 'lodash'; import d3 from 'd3'; import { i18n } from '@kbn/i18n'; -import { KibanaMapLayer } from 'ui/vis/map/kibana_map_layer'; import * as topojson from 'topojson-client'; import { toastNotifications } from 'ui/notify'; -import * as colorUtil from 'ui/vis/map/color_util'; - +import { colorUtil, KibanaMapLayer } from '../../../../plugins/maps_legacy/public'; import { truncatedColorMaps } from '../../../../plugins/charts/public'; const EMPTY_STYLE = { diff --git a/src/legacy/core_plugins/region_map/public/components/region_map_options.tsx b/src/legacy/core_plugins/region_map/public/components/region_map_options.tsx index 61cfbf00ded9e..31a27c4da7fcf 100644 --- a/src/legacy/core_plugins/region_map/public/components/region_map_options.tsx +++ b/src/legacy/core_plugins/region_map/public/components/region_map_options.tsx @@ -21,10 +21,17 @@ import React, { useCallback, useMemo } from 'react'; import { EuiIcon, EuiLink, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; - -import { FileLayerField, VectorLayer, ServiceSettings } from 'ui/vis/map/service_settings'; import { VisOptionsProps } from 'src/plugins/vis_default_editor/public'; -import { NumberInputOption, SelectOption, SwitchOption } from '../../../vis_type_vislib/public'; +import { + FileLayerField, + VectorLayer, + IServiceSettings, +} from '../../../../../plugins/maps_legacy/public'; +import { + NumberInputOption, + SelectOption, + SwitchOption, +} from '../../../../../plugins/charts/public'; import { WmsOptions } from '../../../tile_map/public/components/wms_options'; import { RegionMapVisParams } from '../types'; @@ -39,7 +46,7 @@ const mapFieldForOption = ({ description, name }: FileLayerField) => ({ }); export type RegionMapOptionsProps = { - serviceSettings: ServiceSettings; + serviceSettings: IServiceSettings; } & VisOptionsProps; function RegionMapOptions(props: RegionMapOptionsProps) { diff --git a/src/legacy/core_plugins/region_map/public/legacy.ts b/src/legacy/core_plugins/region_map/public/legacy.ts index 08615946affa2..b0cc767a044e8 100644 --- a/src/legacy/core_plugins/region_map/public/legacy.ts +++ b/src/legacy/core_plugins/region_map/public/legacy.ts @@ -20,21 +20,18 @@ import { PluginInitializerContext } from 'kibana/public'; import { npSetup, npStart } from 'ui/new_platform'; -import { RegionMapPluginSetupDependencies, RegionMapsConfig } from './plugin'; +import { RegionMapPluginSetupDependencies } from './plugin'; import { LegacyDependenciesPlugin } from './shim'; import { plugin } from '.'; -const regionmapsConfig = npSetup.core.injectedMetadata.getInjectedVar( - 'regionmap' -) as RegionMapsConfig; - const plugins: Readonly = { expressions: npSetup.plugins.expressions, visualizations: npSetup.plugins.visualizations, + mapsLegacy: npSetup.plugins.mapsLegacy, // Temporary solution // It will be removed when all dependent services are migrated to the new platform. - __LEGACY: new LegacyDependenciesPlugin(regionmapsConfig), + __LEGACY: new LegacyDependenciesPlugin(), }; const pluginInstance = plugin({} as PluginInitializerContext); diff --git a/src/legacy/core_plugins/region_map/public/plugin.ts b/src/legacy/core_plugins/region_map/public/plugin.ts index cae569f8fd26d..1453c2155e2d6 100644 --- a/src/legacy/core_plugins/region_map/public/plugin.ts +++ b/src/legacy/core_plugins/region_map/public/plugin.ts @@ -32,10 +32,14 @@ import { LegacyDependenciesPlugin, LegacyDependenciesPluginSetup } from './shim' import { createRegionMapFn } from './region_map_fn'; // @ts-ignore import { createRegionMapTypeDefinition } from './region_map_type'; +import { IServiceSettings, MapsLegacyPluginSetup } from '../../../../plugins/maps_legacy/public'; /** @private */ interface RegionMapVisualizationDependencies extends LegacyDependenciesPluginSetup { uiSettings: IUiSettingsClient; + regionmapsConfig: RegionMapsConfig; + serviceSettings: IServiceSettings; + notificationService: any; } /** @internal */ @@ -43,6 +47,7 @@ export interface RegionMapPluginSetupDependencies { expressions: ReturnType; visualizations: VisualizationsSetup; __LEGACY: LegacyDependenciesPlugin; + mapsLegacy: MapsLegacyPluginSetup; } /** @internal */ @@ -61,10 +66,13 @@ export class RegionMapPlugin implements Plugin, void> { public async setup( core: CoreSetup, - { expressions, visualizations, __LEGACY }: RegionMapPluginSetupDependencies + { expressions, visualizations, mapsLegacy, __LEGACY }: RegionMapPluginSetupDependencies ) { const visualizationDependencies: Readonly = { uiSettings: core.uiSettings, + regionmapsConfig: core.injectedMetadata.getInjectedVar('regionmap') as RegionMapsConfig, + serviceSettings: mapsLegacy.serviceSettings, + notificationService: core.notifications.toasts, ...(await __LEGACY.setup()), }; diff --git a/src/legacy/core_plugins/region_map/public/region_map_visualization.js b/src/legacy/core_plugins/region_map/public/region_map_visualization.js index 72f9d66e7d2bf..f08d53ee35c8d 100644 --- a/src/legacy/core_plugins/region_map/public/region_map_visualization.js +++ b/src/legacy/core_plugins/region_map/public/region_map_visualization.js @@ -28,8 +28,16 @@ import { truncatedColorMaps } from '../../../../plugins/charts/public'; // TODO: reference to TILE_MAP plugin should be removed import { BaseMapsVisualizationProvider } from '../../tile_map/public/base_maps_visualization'; -export function createRegionMapVisualization({ serviceSettings, $injector, uiSettings }) { - const BaseMapsVisualization = new BaseMapsVisualizationProvider(serviceSettings); +export function createRegionMapVisualization({ + serviceSettings, + $injector, + uiSettings, + notificationService, +}) { + const BaseMapsVisualization = new BaseMapsVisualizationProvider( + serviceSettings, + notificationService + ); const tooltipFormatter = new TileMapTooltipFormatter($injector); return class RegionMapsVisualization extends BaseMapsVisualization { diff --git a/src/legacy/core_plugins/region_map/public/shim/legacy_dependencies_plugin.ts b/src/legacy/core_plugins/region_map/public/shim/legacy_dependencies_plugin.ts index c47fc40fbacd7..3a7615e83f281 100644 --- a/src/legacy/core_plugins/region_map/public/shim/legacy_dependencies_plugin.ts +++ b/src/legacy/core_plugins/region_map/public/shim/legacy_dependencies_plugin.ts @@ -19,31 +19,20 @@ import chrome from 'ui/chrome'; import { CoreStart, Plugin } from 'kibana/public'; -import 'ui/vis/map/service_settings'; -import { RegionMapsConfig } from '../plugin'; /** @internal */ export interface LegacyDependenciesPluginSetup { $injector: any; serviceSettings: any; - regionmapsConfig: RegionMapsConfig; } export class LegacyDependenciesPlugin implements Plugin, void> { - constructor(private readonly regionmapsConfig: RegionMapsConfig) {} - public async setup() { const $injector = await chrome.dangerouslyGetActiveInjector(); return { $injector, - regionmapsConfig: this.regionmapsConfig, - // Settings for EMSClient. - // EMSClient, which currently lives in the tile_map vis, - // will probably end up being exposed from the future vis_type_maps plugin, - // which would register both the tile_map and the region_map vis plugins. - serviceSettings: $injector.get('serviceSettings'), } as LegacyDependenciesPluginSetup; } diff --git a/src/legacy/core_plugins/region_map/public/types.ts b/src/legacy/core_plugins/region_map/public/types.ts index 2097aebd27ce0..8585bf720e0cf 100644 --- a/src/legacy/core_plugins/region_map/public/types.ts +++ b/src/legacy/core_plugins/region_map/public/types.ts @@ -17,7 +17,7 @@ * under the License. */ -import { VectorLayer, FileLayerField } from 'ui/vis/map/service_settings'; +import { VectorLayer, FileLayerField } from '../../../../plugins/maps_legacy/public'; import { WMSOptions } from '../../tile_map/public/types'; export interface RegionMapVisParams { diff --git a/src/legacy/core_plugins/region_map/public/util.ts b/src/legacy/core_plugins/region_map/public/util.ts index 69a7a1815bc8e..24c721da1f31a 100644 --- a/src/legacy/core_plugins/region_map/public/util.ts +++ b/src/legacy/core_plugins/region_map/public/util.ts @@ -17,7 +17,7 @@ * under the License. */ -import { FileLayer, VectorLayer } from 'ui/vis/map/service_settings'; +import { FileLayer, VectorLayer } from '../../../../plugins/maps_legacy/public'; // TODO: reference to TILE_MAP plugin should be removed import { ORIGIN } from '../../../../legacy/core_plugins/tile_map/common/origin'; diff --git a/src/legacy/core_plugins/tile_map/public/__tests__/coordinate_maps_visualization.js b/src/legacy/core_plugins/tile_map/public/__tests__/coordinate_maps_visualization.js index 2c142b19d9096..3904c43707906 100644 --- a/src/legacy/core_plugins/tile_map/public/__tests__/coordinate_maps_visualization.js +++ b/src/legacy/core_plugins/tile_map/public/__tests__/coordinate_maps_visualization.js @@ -25,12 +25,18 @@ import initial from './initial.png'; import blues from './blues.png'; import shadedGeohashGrid from './shadedGeohashGrid.png'; import heatmapRaw from './heatmap_raw.png'; -import EMS_CATALOGUE from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_manifest.json'; -import EMS_FILES from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_files.json'; -import EMS_TILES from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_tiles.json'; -import EMS_STYLE_ROAD_MAP_BRIGHT from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_style_bright'; -import EMS_STYLE_ROAD_MAP_DESATURATED from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_style_desaturated'; -import EMS_STYLE_DARK_MAP from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_style_dark'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import EMS_CATALOGUE from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_manifest.json'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import EMS_FILES from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_files.json'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import EMS_TILES from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_tiles.json'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import EMS_STYLE_ROAD_MAP_BRIGHT from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_style_bright'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import EMS_STYLE_ROAD_MAP_DESATURATED from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_style_desaturated'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import EMS_STYLE_DARK_MAP from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_style_dark'; import { createTileMapVisualization } from '../tile_map_visualization'; import { createTileMapTypeDefinition } from '../tile_map_type'; @@ -38,6 +44,15 @@ import { createTileMapTypeDefinition } from '../tile_map_type'; import { ExprVis } from '../../../../../plugins/visualizations/public/expressions/vis'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { BaseVisType } from '../../../../../plugins/visualizations/public/vis_types/base_vis_type'; +import { + getPrecision, + getZoomPrecision, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../plugins/maps_legacy/public/map/precision'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ServiceSettings } from '../../../../../plugins/maps_legacy/public/map/service_settings'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { setInjectedVarFunc } from '../../../../../plugins/maps_legacy/public/kibana_services'; function mockRawData() { const stack = [dummyESResponse]; @@ -75,13 +90,39 @@ describe('CoordinateMapsVisualizationTest', function() { beforeEach(ngMock.module('kibana')); beforeEach( ngMock.inject((Private, $injector) => { - const serviceSettings = $injector.get('serviceSettings'); + setInjectedVarFunc(injectedVar => { + switch (injectedVar) { + case 'mapConfig': + return { + emsFileApiUrl: '', + emsTileApiUrl: '', + emsLandingPageUrl: '', + }; + case 'tilemapsConfig': + return { + deprecated: { + config: { + options: { + attribution: '123', + }, + }, + }, + }; + case 'version': + return '123'; + default: + return 'not found'; + } + }); + const serviceSettings = new ServiceSettings(); const uiSettings = $injector.get('config'); dependencies = { serviceSettings, uiSettings, $injector, + getPrecision, + getZoomPrecision, }; visType = new BaseVisType(createTileMapTypeDefinition(dependencies)); diff --git a/src/legacy/core_plugins/tile_map/public/__tests__/geohash_layer.js b/src/legacy/core_plugins/tile_map/public/__tests__/geohash_layer.js index 857432079e376..fc029d6bccb6e 100644 --- a/src/legacy/core_plugins/tile_map/public/__tests__/geohash_layer.js +++ b/src/legacy/core_plugins/tile_map/public/__tests__/geohash_layer.js @@ -18,13 +18,13 @@ */ import expect from '@kbn/expect'; -import { KibanaMap } from 'ui/vis/map/kibana_map'; import { GeohashLayer } from '../geohash_layer'; // import heatmapPng from './heatmap.png'; import scaledCircleMarkersPng from './scaledCircleMarkers.png'; // import shadedCircleMarkersPng from './shadedCircleMarkers.png'; import { ImageComparator } from 'test_utils/image_comparator'; import GeoHashSampleData from './dummy_es_response.json'; +import { KibanaMap } from '../../../../../plugins/maps_legacy/public'; describe('geohash_layer', function() { let domNode; diff --git a/src/legacy/core_plugins/tile_map/public/base_maps_visualization.js b/src/legacy/core_plugins/tile_map/public/base_maps_visualization.js index d38159c91ef9f..1dac4607280cc 100644 --- a/src/legacy/core_plugins/tile_map/public/base_maps_visualization.js +++ b/src/legacy/core_plugins/tile_map/public/base_maps_visualization.js @@ -19,22 +19,25 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { KibanaMap } from 'ui/vis/map/kibana_map'; +import { KibanaMap } from '../../../../plugins/maps_legacy/public'; import * as Rx from 'rxjs'; import { filter, first } from 'rxjs/operators'; -import 'ui/vis/map/service_settings'; import { toastNotifications } from 'ui/notify'; import chrome from 'ui/chrome'; const WMS_MINZOOM = 0; const WMS_MAXZOOM = 22; //increase this to 22. Better for WMS -export function BaseMapsVisualizationProvider(serviceSettings) { +export function BaseMapsVisualizationProvider(mapServiceSettings, notificationService) { /** * Abstract base class for a visualization consisting of a map with a single baselayer. * @class BaseMapsVisualization * @constructor */ + + const serviceSettings = mapServiceSettings; + const toastService = notificationService; + return class BaseMapsVisualization { constructor(element, vis) { this.vis = vis; @@ -94,8 +97,9 @@ export function BaseMapsVisualizationProvider(serviceSettings) { const centerFromUIState = uiState.get('mapCenter'); options.zoom = !isNaN(zoomFromUiState) ? zoomFromUiState : this.vis.params.mapZoom; options.center = centerFromUIState ? centerFromUIState : this.vis.params.mapCenter; + const services = { toastService }; - this._kibanaMap = new KibanaMap(this._container, options); + this._kibanaMap = new KibanaMap(this._container, options, services); this._kibanaMap.setMinZoom(WMS_MINZOOM); //use a default this._kibanaMap.setMaxZoom(WMS_MAXZOOM); //use a default diff --git a/src/legacy/core_plugins/tile_map/public/components/tile_map_options.tsx b/src/legacy/core_plugins/tile_map/public/components/tile_map_options.tsx index 9169647aa2aef..9ca42fe3e4074 100644 --- a/src/legacy/core_plugins/tile_map/public/components/tile_map_options.tsx +++ b/src/legacy/core_plugins/tile_map/public/components/tile_map_options.tsx @@ -27,7 +27,7 @@ import { RangeOption, SelectOption, SwitchOption, -} from '../../../vis_type_vislib/public'; +} from '../../../../../plugins/charts/public'; import { WmsOptions } from './wms_options'; import { TileMapVisParams } from '../types'; import { MapTypes } from '../map_types'; diff --git a/src/legacy/core_plugins/tile_map/public/components/wms_internal_options.tsx b/src/legacy/core_plugins/tile_map/public/components/wms_internal_options.tsx index b81667400303d..47f5b8f31e62b 100644 --- a/src/legacy/core_plugins/tile_map/public/components/wms_internal_options.tsx +++ b/src/legacy/core_plugins/tile_map/public/components/wms_internal_options.tsx @@ -21,7 +21,7 @@ import React from 'react'; import { EuiLink, EuiSpacer, EuiText, EuiScreenReaderOnly } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { TextInputOption } from '../../../vis_type_vislib/public'; +import { TextInputOption } from '../../../../../plugins/charts/public'; import { WMSOptions } from '../types'; interface WmsInternalOptions { diff --git a/src/legacy/core_plugins/tile_map/public/components/wms_options.tsx b/src/legacy/core_plugins/tile_map/public/components/wms_options.tsx index 27127b781cd4d..e74c260d3b8e5 100644 --- a/src/legacy/core_plugins/tile_map/public/components/wms_options.tsx +++ b/src/legacy/core_plugins/tile_map/public/components/wms_options.tsx @@ -21,11 +21,10 @@ import React, { useMemo } from 'react'; import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; - -import { TmsLayer } from 'ui/vis/map/service_settings'; +import { TmsLayer } from '../../../../../plugins/maps_legacy/public'; import { Vis } from '../../../../../plugins/visualizations/public'; import { RegionMapVisParams } from '../../../region_map/public/types'; -import { SelectOption, SwitchOption } from '../../../vis_type_vislib/public'; +import { SelectOption, SwitchOption } from '../../../../../plugins/charts/public'; import { WmsInternalOptions } from './wms_internal_options'; import { WMSOptions, TileMapVisParams } from '../types'; diff --git a/src/legacy/core_plugins/tile_map/public/geohash_layer.js b/src/legacy/core_plugins/tile_map/public/geohash_layer.js index a604e02be7c8c..b9acf1a15208f 100644 --- a/src/legacy/core_plugins/tile_map/public/geohash_layer.js +++ b/src/legacy/core_plugins/tile_map/public/geohash_layer.js @@ -20,8 +20,7 @@ import L from 'leaflet'; import { min, isEqual } from 'lodash'; import { i18n } from '@kbn/i18n'; - -import { KibanaMapLayer } from 'ui/vis/map/kibana_map_layer'; +import { KibanaMapLayer } from '../../../../plugins/maps_legacy/public'; import { HeatmapMarkers } from './markers/heatmap'; import { ScaledCirclesMarkers } from './markers/scaled_circles'; import { ShadedCirclesMarkers } from './markers/shaded_circles'; diff --git a/src/legacy/core_plugins/tile_map/public/legacy.ts b/src/legacy/core_plugins/tile_map/public/legacy.ts index 7b1f916076f61..741e118750f32 100644 --- a/src/legacy/core_plugins/tile_map/public/legacy.ts +++ b/src/legacy/core_plugins/tile_map/public/legacy.ts @@ -27,6 +27,7 @@ import { plugin } from '.'; const plugins: Readonly = { expressions: npSetup.plugins.expressions, visualizations: npSetup.plugins.visualizations, + mapsLegacy: npSetup.plugins.mapsLegacy, // Temporary solution // It will be removed when all dependent services are migrated to the new platform. diff --git a/src/legacy/core_plugins/tile_map/public/markers/scaled_circles.js b/src/legacy/core_plugins/tile_map/public/markers/scaled_circles.js index 88d6db82946c7..f39de6ca7d179 100644 --- a/src/legacy/core_plugins/tile_map/public/markers/scaled_circles.js +++ b/src/legacy/core_plugins/tile_map/public/markers/scaled_circles.js @@ -22,8 +22,7 @@ import _ from 'lodash'; import d3 from 'd3'; import $ from 'jquery'; import { EventEmitter } from 'events'; -import * as colorUtil from 'ui/vis/map/color_util'; - +import { colorUtil } from '../../../../../plugins/maps_legacy/public'; import { truncatedColorMaps } from '../../../../../plugins/charts/public'; export class ScaledCirclesMarkers extends EventEmitter { diff --git a/src/legacy/core_plugins/tile_map/public/plugin.ts b/src/legacy/core_plugins/tile_map/public/plugin.ts index f2addbe3ab872..2b97407b17b38 100644 --- a/src/legacy/core_plugins/tile_map/public/plugin.ts +++ b/src/legacy/core_plugins/tile_map/public/plugin.ts @@ -32,16 +32,22 @@ import { LegacyDependenciesPlugin, LegacyDependenciesPluginSetup } from './shim' import { createTileMapFn } from './tile_map_fn'; // @ts-ignore import { createTileMapTypeDefinition } from './tile_map_type'; +import { IServiceSettings, MapsLegacyPluginSetup } from '../../../../plugins/maps_legacy/public'; /** @private */ interface TileMapVisualizationDependencies extends LegacyDependenciesPluginSetup { + serviceSettings: IServiceSettings; uiSettings: IUiSettingsClient; + getZoomPrecision: any; + getPrecision: any; + notificationService: any; } /** @internal */ export interface TileMapPluginSetupDependencies { expressions: ReturnType; visualizations: VisualizationsSetup; + mapsLegacy: MapsLegacyPluginSetup; __LEGACY: LegacyDependenciesPlugin; } @@ -55,9 +61,14 @@ export class TileMapPlugin implements Plugin, void> { public async setup( core: CoreSetup, - { expressions, visualizations, __LEGACY }: TileMapPluginSetupDependencies + { expressions, visualizations, mapsLegacy, __LEGACY }: TileMapPluginSetupDependencies ) { + const { getZoomPrecision, getPrecision, serviceSettings } = mapsLegacy; const visualizationDependencies: Readonly = { + serviceSettings, + getZoomPrecision, + getPrecision, + notificationService: core.notifications.toasts, uiSettings: core.uiSettings, ...(await __LEGACY.setup()), }; diff --git a/src/legacy/core_plugins/tile_map/public/shim/legacy_dependencies_plugin.ts b/src/legacy/core_plugins/tile_map/public/shim/legacy_dependencies_plugin.ts index 063b12bf0a2db..5296e98b09efe 100644 --- a/src/legacy/core_plugins/tile_map/public/shim/legacy_dependencies_plugin.ts +++ b/src/legacy/core_plugins/tile_map/public/shim/legacy_dependencies_plugin.ts @@ -18,12 +18,12 @@ */ import chrome from 'ui/chrome'; -import 'ui/vis/map/service_settings'; import { CoreStart, Plugin } from 'kibana/public'; +// TODO: Determine why visualizations don't populate without this +import 'angular-sanitize'; /** @internal */ export interface LegacyDependenciesPluginSetup { - serviceSettings: any; $injector: any; } @@ -34,11 +34,6 @@ export class LegacyDependenciesPlugin return { $injector, - // Settings for EMSClient. - // EMSClient, which currently lives in the tile_map vis, - // will probably end up being exposed from the future vis_type_maps plugin, - // which would register both the tile_map and the region_map vis plugins. - serviceSettings: $injector.get('serviceSettings'), } as LegacyDependenciesPluginSetup; } diff --git a/src/legacy/core_plugins/tile_map/public/tile_map_fn.js b/src/legacy/core_plugins/tile_map/public/tile_map_fn.js index 2f54d23590c33..5ad4a2c33db25 100644 --- a/src/legacy/core_plugins/tile_map/public/tile_map_fn.js +++ b/src/legacy/core_plugins/tile_map/public/tile_map_fn.js @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { convertToGeoJson } from 'ui/vis/map/convert_to_geojson'; +import { convertToGeoJson } from '../../../../plugins/maps_legacy/public'; import { i18n } from '@kbn/i18n'; export const createTileMapFn = () => ({ diff --git a/src/legacy/core_plugins/tile_map/public/tile_map_type.js b/src/legacy/core_plugins/tile_map/public/tile_map_type.js index fe82ad5c7352b..ae3a839b600e9 100644 --- a/src/legacy/core_plugins/tile_map/public/tile_map_type.js +++ b/src/legacy/core_plugins/tile_map/public/tile_map_type.js @@ -19,9 +19,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; - -import { convertToGeoJson } from 'ui/vis/map/convert_to_geojson'; - +import { convertToGeoJson } from '../../../../plugins/maps_legacy/public'; import { Schemas } from '../../../../plugins/vis_default_editor/public'; import { createTileMapVisualization } from './tile_map_visualization'; import { TileMapOptions } from './components/tile_map_options'; diff --git a/src/legacy/core_plugins/tile_map/public/tile_map_visualization.js b/src/legacy/core_plugins/tile_map/public/tile_map_visualization.js index 910def8a0c78e..fdce8bc51fe86 100644 --- a/src/legacy/core_plugins/tile_map/public/tile_map_visualization.js +++ b/src/legacy/core_plugins/tile_map/public/tile_map_visualization.js @@ -23,15 +23,19 @@ import { BaseMapsVisualizationProvider } from './base_maps_visualization'; import { TileMapTooltipFormatterProvider } from './editors/_tooltip_formatter'; import { npStart } from 'ui/new_platform'; import { getFormat } from '../../../ui/public/visualize/loader/pipeline_helpers/utilities'; -import { - scaleBounds, - zoomPrecision, - getPrecision, - geoContains, -} from '../../../ui/public/vis/map/decode_geo_hash'; +import { scaleBounds, geoContains } from '../../../../plugins/maps_legacy/public'; -export const createTileMapVisualization = ({ serviceSettings, $injector }) => { - const BaseMapsVisualization = new BaseMapsVisualizationProvider(serviceSettings); +export const createTileMapVisualization = ({ + serviceSettings, + $injector, + getZoomPrecision, + getPrecision, + notificationService, +}) => { + const BaseMapsVisualization = new BaseMapsVisualizationProvider( + serviceSettings, + notificationService + ); const tooltipFormatter = new TileMapTooltipFormatterProvider($injector); return class CoordinateMapsVisualization extends BaseMapsVisualization { @@ -59,6 +63,7 @@ export const createTileMapVisualization = ({ serviceSettings, $injector }) => { updateVarsObject.data.boundingBox = geohashAgg.aggConfigParams.boundingBox; } // todo: autoPrecision should be vis parameter, not aggConfig one + const zoomPrecision = getZoomPrecision(); updateVarsObject.data.precision = geohashAgg.aggConfigParams.autoPrecision ? zoomPrecision[this.vis.getUiState().get('mapZoom')] : getPrecision(geohashAgg.aggConfigParams.precision); diff --git a/src/legacy/core_plugins/tile_map/public/tilemap_fn.test.js b/src/legacy/core_plugins/tile_map/public/tilemap_fn.test.js index 0913d6fc92e8a..6da37f4c5ef86 100644 --- a/src/legacy/core_plugins/tile_map/public/tilemap_fn.test.js +++ b/src/legacy/core_plugins/tile_map/public/tilemap_fn.test.js @@ -22,7 +22,7 @@ import { functionWrapper } from '../../../../plugins/expressions/common/expressi import { createTileMapFn } from './tile_map_fn'; jest.mock('ui/new_platform'); -jest.mock('ui/vis/map/convert_to_geojson', () => ({ +jest.mock('../../../../plugins/maps_legacy/public', () => ({ convertToGeoJson: jest.fn().mockReturnValue({ featureCollection: { type: 'FeatureCollection', @@ -37,7 +37,7 @@ jest.mock('ui/vis/map/convert_to_geojson', () => ({ }), })); -import { convertToGeoJson } from 'ui/vis/map/convert_to_geojson'; +import { convertToGeoJson } from '../../../../plugins/maps_legacy/public'; describe('interpreter/functions#tilemap', () => { const fn = functionWrapper(createTileMapFn()); diff --git a/src/legacy/core_plugins/tile_map/public/types.ts b/src/legacy/core_plugins/tile_map/public/types.ts index 5f1c3f9b03c9e..e1b4c27319123 100644 --- a/src/legacy/core_plugins/tile_map/public/types.ts +++ b/src/legacy/core_plugins/tile_map/public/types.ts @@ -17,7 +17,7 @@ * under the License. */ -import { TmsLayer } from 'ui/vis/map/service_settings'; +import { TmsLayer } from '../../../../plugins/maps_legacy/public'; import { MapTypes } from './map_types'; export interface WMSOptions { diff --git a/src/legacy/core_plugins/timelion/public/shim/timelion_legacy_module.ts b/src/legacy/core_plugins/timelion/public/shim/timelion_legacy_module.ts index 9de8477e3978c..8fadf223e1807 100644 --- a/src/legacy/core_plugins/timelion/public/shim/timelion_legacy_module.ts +++ b/src/legacy/core_plugins/timelion/public/shim/timelion_legacy_module.ts @@ -21,7 +21,6 @@ import 'ngreact'; import 'brace/mode/hjson'; import 'brace/ext/searchbox'; import 'ui/accessibility/kbn_ui_ace_keyboard_mode'; -import 'ui/vis/map/service_settings'; import { once } from 'lodash'; // @ts-ignore diff --git a/src/legacy/core_plugins/vis_type_markdown/index.ts b/src/legacy/core_plugins/vis_type_markdown/index.ts deleted file mode 100644 index 3c00420e57d55..0000000000000 --- a/src/legacy/core_plugins/vis_type_markdown/index.ts +++ /dev/null @@ -1,44 +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 { resolve } from 'path'; -import { Legacy } from 'kibana'; - -import { LegacyPluginApi, LegacyPluginInitializer } from '../../../../src/legacy/types'; - -const markdownPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => - new Plugin({ - id: 'markdown_vis', - require: ['kibana', 'elasticsearch'], - publicDir: resolve(__dirname, 'public'), - uiExports: { - styleSheetPaths: resolve(__dirname, 'public/index.scss'), - hacks: [resolve(__dirname, 'public/legacy')], - injectDefaultVars: server => ({}), - }, - init: (server: Legacy.Server) => ({}), - config(Joi: any) { - return Joi.object({ - enabled: Joi.boolean().default(true), - }).default(); - }, - } as Legacy.PluginSpecOptions); - -// eslint-disable-next-line import/no-default-export -export default markdownPluginInitializer; diff --git a/src/legacy/core_plugins/vis_type_markdown/package.json b/src/legacy/core_plugins/vis_type_markdown/package.json deleted file mode 100644 index 5c233d82fe506..0000000000000 --- a/src/legacy/core_plugins/vis_type_markdown/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "markdown_vis", - "version": "kibana" -} diff --git a/src/legacy/core_plugins/vis_type_markdown/public/index.scss b/src/legacy/core_plugins/vis_type_markdown/public/index.scss deleted file mode 100644 index ebae2d1936a9e..0000000000000 --- a/src/legacy/core_plugins/vis_type_markdown/public/index.scss +++ /dev/null @@ -1,10 +0,0 @@ -@import 'src/legacy/ui/public/styles/styling_constants'; - -// Prefix all styles with "mkd" to avoid conflicts. -// Examples -// mkdChart -// mkdChart__legend -// mkdChart__legend--small -// mkdChart__legend-isLoading - -@import './markdown_vis'; diff --git a/src/legacy/core_plugins/vis_type_markdown/public/index.ts b/src/legacy/core_plugins/vis_type_markdown/public/index.ts deleted file mode 100644 index 22dd61b6bda9c..0000000000000 --- a/src/legacy/core_plugins/vis_type_markdown/public/index.ts +++ /dev/null @@ -1,25 +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 { PluginInitializerContext } from '../../../../core/public'; -import { MarkdownPlugin as Plugin } from './plugin'; - -export function plugin(initializerContext: PluginInitializerContext) { - return new Plugin(initializerContext); -} diff --git a/src/legacy/core_plugins/vis_type_markdown/public/legacy.ts b/src/legacy/core_plugins/vis_type_markdown/public/legacy.ts deleted file mode 100644 index 1cfc583f6e005..0000000000000 --- a/src/legacy/core_plugins/vis_type_markdown/public/legacy.ts +++ /dev/null @@ -1,33 +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 { PluginInitializerContext } from 'kibana/public'; -import { npSetup, npStart } from 'ui/new_platform'; -import { MarkdownPluginSetupDependencies } from './plugin'; -import { plugin } from '.'; - -const plugins: Readonly = { - expressions: npSetup.plugins.expressions, - visualizations: npSetup.plugins.visualizations, -}; - -const pluginInstance = plugin({} as PluginInitializerContext); - -export const setup = pluginInstance.setup(npSetup.core, plugins); -export const start = pluginInstance.start(npStart.core); diff --git a/src/legacy/core_plugins/vis_type_markdown/public/plugin.ts b/src/legacy/core_plugins/vis_type_markdown/public/plugin.ts deleted file mode 100644 index 0445d270c9330..0000000000000 --- a/src/legacy/core_plugins/vis_type_markdown/public/plugin.ts +++ /dev/null @@ -1,49 +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 { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../../core/public'; -import { Plugin as ExpressionsPublicPlugin } from '../../../../plugins/expressions/public'; -import { VisualizationsSetup } from '../../../../plugins/visualizations/public'; - -import { markdownVisDefinition } from './markdown_vis'; -import { createMarkdownVisFn } from './markdown_fn'; - -/** @internal */ -export interface MarkdownPluginSetupDependencies { - expressions: ReturnType; - visualizations: VisualizationsSetup; -} - -/** @internal */ -export class MarkdownPlugin implements Plugin { - initializerContext: PluginInitializerContext; - - constructor(initializerContext: PluginInitializerContext) { - this.initializerContext = initializerContext; - } - - public setup(core: CoreSetup, { expressions, visualizations }: MarkdownPluginSetupDependencies) { - visualizations.createReactVisualization(markdownVisDefinition); - expressions.registerFunction(createMarkdownVisFn); - } - - public start(core: CoreStart) { - // nothing to do here yet - } -} diff --git a/src/legacy/core_plugins/vis_type_metric/index.ts b/src/legacy/core_plugins/vis_type_metric/index.ts deleted file mode 100644 index 8e6654e40f0fc..0000000000000 --- a/src/legacy/core_plugins/vis_type_metric/index.ts +++ /dev/null @@ -1,44 +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 { resolve } from 'path'; -import { Legacy } from 'kibana'; - -import { LegacyPluginApi, LegacyPluginInitializer } from '../../../../src/legacy/types'; - -const metricPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => - new Plugin({ - id: 'metric_vis', - require: ['kibana', 'elasticsearch'], - publicDir: resolve(__dirname, 'public'), - uiExports: { - styleSheetPaths: resolve(__dirname, 'public/index.scss'), - hacks: [resolve(__dirname, 'public/legacy')], - injectDefaultVars: server => ({}), - }, - init: (server: Legacy.Server) => ({}), - config(Joi: any) { - return Joi.object({ - enabled: Joi.boolean().default(true), - }).default(); - }, - } as Legacy.PluginSpecOptions); - -// eslint-disable-next-line import/no-default-export -export default metricPluginInitializer; diff --git a/src/legacy/core_plugins/vis_type_metric/package.json b/src/legacy/core_plugins/vis_type_metric/package.json deleted file mode 100644 index e570261fc30dd..0000000000000 --- a/src/legacy/core_plugins/vis_type_metric/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "metric_vis", - "version": "kibana" -} diff --git a/src/legacy/core_plugins/vis_type_metric/public/index.scss b/src/legacy/core_plugins/vis_type_metric/public/index.scss deleted file mode 100644 index e4116b69bf0f1..0000000000000 --- a/src/legacy/core_plugins/vis_type_metric/public/index.scss +++ /dev/null @@ -1,10 +0,0 @@ -@import 'src/legacy/ui/public/styles/styling_constants'; - -// Prefix all styles with "mtr" to avoid conflicts. -// Examples -// mtrChart -// mtrChart__legend -// mtrChart__legend--small -// mtrChart__legend-isLoading - -@import './metric_vis'; diff --git a/src/legacy/core_plugins/vis_type_metric/public/index.ts b/src/legacy/core_plugins/vis_type_metric/public/index.ts deleted file mode 100644 index 7499babef58a7..0000000000000 --- a/src/legacy/core_plugins/vis_type_metric/public/index.ts +++ /dev/null @@ -1,25 +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 { PluginInitializerContext } from '../../../../core/public'; -import { MetricVisPlugin as Plugin } from './plugin'; - -export function plugin(initializerContext: PluginInitializerContext) { - return new Plugin(initializerContext); -} diff --git a/src/legacy/core_plugins/vis_type_metric/public/legacy.ts b/src/legacy/core_plugins/vis_type_metric/public/legacy.ts deleted file mode 100644 index ba883601e5d65..0000000000000 --- a/src/legacy/core_plugins/vis_type_metric/public/legacy.ts +++ /dev/null @@ -1,34 +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 { PluginInitializerContext } from 'kibana/public'; -import { npSetup, npStart } from 'ui/new_platform'; -import { MetricVisPluginSetupDependencies } from './plugin'; -import { plugin } from '.'; - -const plugins: Readonly = { - expressions: npSetup.plugins.expressions, - visualizations: npSetup.plugins.visualizations, - charts: npSetup.plugins.charts, -}; - -const pluginInstance = plugin({} as PluginInitializerContext); - -export const setup = pluginInstance.setup(npSetup.core, plugins); -export const start = pluginInstance.start(npStart.core, { data: npStart.plugins.data }); diff --git a/src/legacy/core_plugins/vis_type_metric/public/plugin.ts b/src/legacy/core_plugins/vis_type_metric/public/plugin.ts deleted file mode 100644 index cb65d5cafbdd2..0000000000000 --- a/src/legacy/core_plugins/vis_type_metric/public/plugin.ts +++ /dev/null @@ -1,61 +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 { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../../core/public'; -import { Plugin as ExpressionsPublicPlugin } from '../../../../plugins/expressions/public'; -import { VisualizationsSetup } from '../../../../plugins/visualizations/public'; - -import { createMetricVisFn } from './metric_vis_fn'; -import { createMetricVisTypeDefinition } from './metric_vis_type'; -import { ChartsPluginSetup } from '../../../../plugins/charts/public'; -import { DataPublicPluginStart } from '../../../../plugins/data/public'; -import { setFormatService } from './services'; - -/** @internal */ -export interface MetricVisPluginSetupDependencies { - expressions: ReturnType; - visualizations: VisualizationsSetup; - charts: ChartsPluginSetup; -} - -/** @internal */ -export interface MetricVisPluginStartDependencies { - data: DataPublicPluginStart; -} - -/** @internal */ -export class MetricVisPlugin implements Plugin { - initializerContext: PluginInitializerContext; - - constructor(initializerContext: PluginInitializerContext) { - this.initializerContext = initializerContext; - } - - public setup( - core: CoreSetup, - { expressions, visualizations, charts }: MetricVisPluginSetupDependencies - ) { - expressions.registerFunction(createMetricVisFn); - visualizations.createReactVisualization(createMetricVisTypeDefinition()); - } - - public start(core: CoreStart, { data }: MetricVisPluginStartDependencies) { - setFormatService(data.fieldFormats); - } -} diff --git a/src/legacy/core_plugins/vis_type_metric/public/services.ts b/src/legacy/core_plugins/vis_type_metric/public/services.ts deleted file mode 100644 index b303ccd5aeed2..0000000000000 --- a/src/legacy/core_plugins/vis_type_metric/public/services.ts +++ /dev/null @@ -1,25 +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 { createGetterSetter } from '../../../../plugins/kibana_utils/public'; -import { DataPublicPluginStart } from '../../../../plugins/data/public'; - -export const [getFormatService, setFormatService] = createGetterSetter< - DataPublicPluginStart['fieldFormats'] ->('metric data.fieldFormats'); diff --git a/src/legacy/core_plugins/vis_type_metric/public/types.ts b/src/legacy/core_plugins/vis_type_metric/public/types.ts deleted file mode 100644 index cae18dd8a2ab1..0000000000000 --- a/src/legacy/core_plugins/vis_type_metric/public/types.ts +++ /dev/null @@ -1,58 +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 { Range } from '../../../../plugins/expressions/public'; -import { SchemaConfig } from '../../../../plugins/visualizations/public'; -import { ColorModes, Labels, Style } from '../../vis_type_vislib/public'; -import { ColorSchemas } from '../../../../plugins/charts/public'; - -export const visType = 'metric'; - -export interface DimensionsVisParam { - metrics: SchemaConfig[]; - bucket?: SchemaConfig; -} - -export interface MetricVisParam { - percentageMode: boolean; - useRanges: boolean; - colorSchema: ColorSchemas; - metricColorMode: ColorModes; - colorsRange: Range[]; - labels: Labels; - invertColors: boolean; - style: Style; -} - -export interface VisParams { - addTooltip: boolean; - addLegend: boolean; - dimensions: DimensionsVisParam; - metric: MetricVisParam; - type: typeof visType; -} - -export interface MetricVisMetric { - value: any; - label: string; - color?: string; - bgColor?: string; - lightText: boolean; - rowIndex: number; -} diff --git a/src/legacy/core_plugins/vis_type_table/public/components/table_vis_options.tsx b/src/legacy/core_plugins/vis_type_table/public/components/table_vis_options.tsx index d01ab31e0a843..265528f33f9cd 100644 --- a/src/legacy/core_plugins/vis_type_table/public/components/table_vis_options.tsx +++ b/src/legacy/core_plugins/vis_type_table/public/components/table_vis_options.tsx @@ -25,7 +25,11 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { VisOptionsProps } from 'src/plugins/vis_default_editor/public'; import { search } from '../../../../../plugins/data/public'; -import { NumberInputOption, SwitchOption, SelectOption } from '../../../vis_type_vislib/public'; +import { + SwitchOption, + SelectOption, + NumberInputOption, +} from '../../../../../plugins/charts/public'; import { TableVisParams } from '../types'; import { totalAggregations } from './utils'; diff --git a/src/legacy/core_plugins/vis_type_tagcloud/public/components/tag_cloud_options.tsx b/src/legacy/core_plugins/vis_type_tagcloud/public/components/tag_cloud_options.tsx index 80e4e1de7ddab..7a64549edd747 100644 --- a/src/legacy/core_plugins/vis_type_tagcloud/public/components/tag_cloud_options.tsx +++ b/src/legacy/core_plugins/vis_type_tagcloud/public/components/tag_cloud_options.tsx @@ -22,7 +22,7 @@ import { EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { VisOptionsProps } from 'src/plugins/vis_default_editor/public'; import { ValidatedDualRange } from '../../../../../../src/plugins/kibana_react/public'; -import { SelectOption, SwitchOption } from '../../../vis_type_vislib/public'; +import { SelectOption, SwitchOption } from '../../../../../plugins/charts/public'; import { TagCloudVisParams } from '../types'; function TagCloudOptions({ stateParams, setValue, vis }: VisOptionsProps) { diff --git a/src/legacy/core_plugins/vis_type_timelion/public/timelion_options.tsx b/src/legacy/core_plugins/vis_type_timelion/public/timelion_options.tsx index afffcf7ccaf7a..dfe017d3a273f 100644 --- a/src/legacy/core_plugins/vis_type_timelion/public/timelion_options.tsx +++ b/src/legacy/core_plugins/vis_type_timelion/public/timelion_options.tsx @@ -24,7 +24,9 @@ import { VisOptionsProps } from 'src/plugins/vis_default_editor/public'; import { VisParams } from './timelion_vis_fn'; import { TimelionInterval, TimelionExpressionInput } from './components'; -function TimelionOptions({ stateParams, setValue, setValidity }: VisOptionsProps) { +export type TimelionOptionsProps = VisOptionsProps; + +function TimelionOptions({ stateParams, setValue, setValidity }: TimelionOptionsProps) { const setInterval = useCallback((value: VisParams['interval']) => setValue('interval', value), [ setValue, ]); diff --git a/src/legacy/core_plugins/vis_type_timelion/public/timelion_vis_type.tsx b/src/legacy/core_plugins/vis_type_timelion/public/timelion_vis_type.tsx index 5be77b3e51a6a..0900b7d898ede 100644 --- a/src/legacy/core_plugins/vis_type_timelion/public/timelion_vis_type.tsx +++ b/src/legacy/core_plugins/vis_type_timelion/public/timelion_vis_type.tsx @@ -24,7 +24,7 @@ import { KibanaContextProvider } from '../../../../plugins/kibana_react/public'; import { DefaultEditorSize } from '../../../../plugins/vis_default_editor/public'; import { getTimelionRequestHandler } from './helpers/timelion_request_handler'; import { TimelionVisComponent, TimelionVisComponentProp } from './components'; -import { TimelionOptions } from './timelion_options'; +import { TimelionOptions, TimelionOptionsProps } from './timelion_options'; import { TimelionVisDependencies } from './plugin'; export const TIMELION_VIS_NAME = 'timelion'; @@ -53,7 +53,11 @@ export function getTimelionVisDefinition(dependencies: TimelionVisDependencies) ), }, editorConfig: { - optionsTemplate: TimelionOptions, + optionsTemplate: (props: TimelionOptionsProps) => ( + + + + ), defaultSize: DefaultEditorSize.MEDIUM, }, requestHandler: timelionRequestHandler, diff --git a/src/legacy/core_plugins/vis_type_timeseries/index.ts b/src/legacy/core_plugins/vis_type_timeseries/index.ts deleted file mode 100644 index 596fd5b581a71..0000000000000 --- a/src/legacy/core_plugins/vis_type_timeseries/index.ts +++ /dev/null @@ -1,45 +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 { resolve } from 'path'; -import { Legacy } from 'kibana'; - -import { LegacyPluginApi, LegacyPluginInitializer } from '../../../../src/legacy/types'; - -const metricsPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => - new Plugin({ - id: 'metrics', - require: ['kibana', 'elasticsearch'], - publicDir: resolve(__dirname, 'public'), - uiExports: { - styleSheetPaths: resolve(__dirname, 'public/index.scss'), - hacks: [resolve(__dirname, 'public/legacy')], - injectDefaultVars: server => ({}), - }, - config(Joi: any) { - return Joi.object({ - enabled: Joi.boolean().default(true), - chartResolution: Joi.number().default(150), - minimumBucketSize: Joi.number().default(10), - }).default(); - }, - } as Legacy.PluginSpecOptions); - -// eslint-disable-next-line import/no-default-export -export default metricsPluginInitializer; diff --git a/src/legacy/core_plugins/vis_type_timeseries/package.json b/src/legacy/core_plugins/vis_type_timeseries/package.json deleted file mode 100644 index 6b4874dfe6a68..0000000000000 --- a/src/legacy/core_plugins/vis_type_timeseries/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "author": "Chris Cowan", - "name": "metrics", - "version": "kibana" -} - diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/table/vis.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/table/vis.js deleted file mode 100644 index 1fe9358cbfea9..0000000000000 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/table/vis.js +++ /dev/null @@ -1,257 +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 _, { isArray, last, get } from 'lodash'; -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { createTickFormatter } from '../../lib/tick_formatter'; -import { calculateLabel } from '../../../../../../../plugins/vis_type_timeseries/common/calculate_label'; -import { isSortable } from './is_sortable'; -import { EuiToolTip, EuiIcon } from '@elastic/eui'; -import { replaceVars } from '../../lib/replace_vars'; -import { fieldFormats } from '../../../../../../../plugins/data/public'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { getFieldFormats } from '../../../services'; - -import { METRIC_TYPES } from '../../../../../../../plugins/vis_type_timeseries/common/metric_types'; - -function getColor(rules, colorKey, value) { - let color; - if (rules) { - rules.forEach(rule => { - if (rule.operator && rule.value != null) { - if (_[rule.operator](value, rule.value)) { - color = rule[colorKey]; - } - } - }); - } - return color; -} - -export class TableVis extends Component { - constructor(props) { - super(props); - - const fieldFormatsService = getFieldFormats(); - const DateFormat = fieldFormatsService.getType(fieldFormats.FIELD_FORMAT_IDS.DATE); - - this.dateFormatter = new DateFormat({}, this.props.getConfig); - } - - get visibleSeries() { - return get(this.props, 'model.series', []).filter(series => !series.hidden); - } - - renderRow = row => { - const { model } = this.props; - let rowDisplay = model.pivot_type === 'date' ? this.dateFormatter.convert(row.key) : row.key; - if (model.drilldown_url) { - const url = replaceVars(model.drilldown_url, {}, { key: row.key }); - rowDisplay = {rowDisplay}; - } - const columns = row.series - .filter(item => item) - .map(item => { - const column = this.visibleSeries.find(c => c.id === item.id); - if (!column) return null; - const formatter = createTickFormatter( - column.formatter, - column.value_template, - this.props.getConfig - ); - const value = formatter(item.last); - let trend; - if (column.trend_arrows) { - const trendIcon = item.slope > 0 ? 'sortUp' : 'sortDown'; - trend = ( - -   - - ); - } - const style = { color: getColor(column.color_rules, 'text', item.last) }; - return ( - - ); - }); - return ( - - - {columns} - - ); - }; - - renderHeader() { - const { model, uiState, onUiState } = this.props; - const stateKey = `${model.type}.sort`; - const sort = uiState.get(stateKey, { - column: '_default_', - order: 'asc', - }); - - const calculateHeaderLabel = (metric, item) => { - const defaultLabel = item.label || calculateLabel(metric, item.metrics); - - switch (metric.type) { - case METRIC_TYPES.PERCENTILE: - return `${defaultLabel} (${last(metric.percentiles).value || 0})`; - case METRIC_TYPES.PERCENTILE_RANK: - return `${defaultLabel} (${last(metric.values) || 0})`; - default: - return defaultLabel; - } - }; - - const columns = this.visibleSeries.map(item => { - const metric = last(item.metrics); - const label = calculateHeaderLabel(metric, item); - - const handleClick = () => { - if (!isSortable(metric)) return; - let order; - if (sort.column === item.id) { - order = sort.order === 'asc' ? 'desc' : 'asc'; - } else { - order = 'asc'; - } - onUiState(stateKey, { column: item.id, order }); - }; - let sortComponent; - if (isSortable(metric)) { - let sortIcon; - if (sort.column === item.id) { - sortIcon = sort.order === 'asc' ? 'sortUp' : 'sortDown'; - } else { - sortIcon = 'empty'; - } - sortComponent = ; - } - let headerContent = ( - - {label} {sortComponent} - - ); - if (!isSortable(metric)) { - headerContent = ( - - } - > - {headerContent} - - ); - } - - return ( - - ); - }); - const label = model.pivot_label || model.pivot_field || model.pivot_id; - let sortIcon; - if (sort.column === '_default_') { - sortIcon = sort.order === 'asc' ? 'sortUp' : 'sortDown'; - } else { - sortIcon = 'empty'; - } - const sortComponent = ; - const handleSortClick = () => { - let order; - if (sort.column === '_default_') { - order = sort.order === 'asc' ? 'desc' : 'asc'; - } else { - order = 'asc'; - } - onUiState(stateKey, { column: '_default_', order }); - }; - return ( - - - {columns} - - ); - } - - render() { - const { visData, model } = this.props; - const header = this.renderHeader(); - let rows; - - if (isArray(visData.series) && visData.series.length) { - rows = visData.series.map(this.renderRow); - } else { - const message = model.pivot_id ? ( - - ) : ( - - ); - rows = ( - - - - ); - } - return ( -
-
- {value} - {trend} -
{rowDisplay}
- {headerContent} -
- {label} {sortComponent} -
{message}
- {header} - {rows} -
-
- ); - } -} - -TableVis.defaultProps = { - sort: {}, -}; - -TableVis.propTypes = { - visData: PropTypes.object, - model: PropTypes.object, - backgroundColor: PropTypes.string, - onPaginate: PropTypes.func, - onUiState: PropTypes.func, - uiState: PropTypes.object, - pageNumber: PropTypes.number, - getConfig: PropTypes.func, -}; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js deleted file mode 100644 index f559bc38b6c58..0000000000000 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js +++ /dev/null @@ -1,260 +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 PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import reactCSS from 'reactcss'; - -import { startsWith, get, cloneDeep, map } from 'lodash'; -import { htmlIdGenerator } from '@elastic/eui'; -import { ScaleType } from '@elastic/charts'; - -import { createTickFormatter } from '../../lib/tick_formatter'; -import { TimeSeries } from '../../../visualizations/views/timeseries'; -import { MarkdownSimple } from '../../../../../../../plugins/kibana_react/public'; -import { replaceVars } from '../../lib/replace_vars'; -import { getAxisLabelString } from '../../lib/get_axis_label_string'; -import { getInterval } from '../../lib/get_interval'; -import { areFieldsDifferent } from '../../lib/charts'; -import { createXaxisFormatter } from '../../lib/create_xaxis_formatter'; -import { STACKED_OPTIONS } from '../../../visualizations/constants'; -import { getCoreStart, getUISettings } from '../../../services'; - -export class TimeseriesVisualization extends Component { - static propTypes = { - model: PropTypes.object, - onBrush: PropTypes.func, - visData: PropTypes.object, - dateFormat: PropTypes.string, - getConfig: PropTypes.func, - }; - - xAxisFormatter = interval => val => { - const { scaledDataFormat, dateFormat } = this.props.visData; - - if (!scaledDataFormat || !dateFormat) { - return val; - } - - const formatter = createXaxisFormatter(interval, scaledDataFormat, dateFormat); - - return formatter(val); - }; - - yAxisStackedByPercentFormatter = val => { - const n = Number(val) * 100; - - return `${(Number.isNaN(n) ? 0 : n).toFixed(0)}%`; - }; - - applyDocTo = template => doc => { - const vars = replaceVars(template, null, doc); - - if (vars instanceof Error) { - this.showToastNotification = vars.error.caused_by; - - return template; - } - - return vars; - }; - - static getYAxisDomain = model => { - const axisMin = get(model, 'axis_min', '').toString(); - const axisMax = get(model, 'axis_max', '').toString(); - const fit = model.series - ? model.series.filter(({ hidden }) => !hidden).every(({ fill }) => fill === '0') - : model.fill === '0'; - - return { - min: axisMin.length ? Number(axisMin) : undefined, - max: axisMax.length ? Number(axisMax) : undefined, - fit, - }; - }; - - static addYAxis = (yAxis, { id, groupId, position, tickFormatter, domain, hide }) => { - yAxis.push({ - id, - groupId, - position, - tickFormatter, - domain, - hide, - }); - }; - - static getAxisScaleType = model => - get(model, 'axis_scale') === 'log' ? ScaleType.Log : ScaleType.Linear; - - static getTickFormatter = (model, getConfig) => - createTickFormatter(get(model, 'formatter'), get(model, 'value_template'), getConfig); - - componentDidUpdate() { - const toastNotifications = getCoreStart().notifications.toasts; - if ( - this.showToastNotification && - this.notificationReason !== this.showToastNotification.reason - ) { - if (this.notification) { - toastNotifications.remove(this.notification); - } - - this.notificationReason = this.showToastNotification.reason; - this.notification = toastNotifications.addDanger({ - title: this.showToastNotification.title, - text: {this.showToastNotification.reason}, - }); - } - - if (!this.showToastNotification && this.notification) { - toastNotifications.remove(this.notification); - this.notificationReason = null; - this.notification = null; - } - } - - prepareAnnotations = () => { - const { model, visData } = this.props; - - return map(model.annotations, ({ id, color, icon, template }) => { - const annotationData = get(visData, `${model.id}.annotations.${id}`, []); - const applyDocToTemplate = this.applyDocTo(template); - - return { - id, - color, - icon, - data: annotationData.map(({ docs, ...rest }) => ({ - ...rest, - docs: docs.map(applyDocToTemplate), - })), - }; - }); - }; - - render() { - const { model, visData, onBrush } = this.props; - const styles = reactCSS({ - default: { - tvbVis: { - backgroundColor: get(model, 'background_color'), - }, - }, - }); - const series = get(visData, `${model.id}.series`, []); - const interval = getInterval(visData, model); - const yAxisIdGenerator = htmlIdGenerator('yaxis'); - const mainAxisGroupId = yAxisIdGenerator('main_group'); - - const seriesModel = model.series.filter(s => !s.hidden).map(s => cloneDeep(s)); - const enableHistogramMode = areFieldsDifferent('chart_type')(seriesModel); - const firstSeries = seriesModel.find(s => s.formatter && !s.separate_axis); - - const mainAxisScaleType = TimeseriesVisualization.getAxisScaleType(model); - const mainAxisDomain = TimeseriesVisualization.getYAxisDomain(model); - const tickFormatter = TimeseriesVisualization.getTickFormatter( - firstSeries, - this.props.getConfig - ); - const yAxis = []; - let mainDomainAdded = false; - - this.showToastNotification = null; - - seriesModel.forEach(seriesGroup => { - const isStackedWithinSeries = seriesGroup.stacked === STACKED_OPTIONS.STACKED_WITHIN_SERIES; - const hasSeparateAxis = Boolean(seriesGroup.separate_axis); - const groupId = hasSeparateAxis || isStackedWithinSeries ? seriesGroup.id : mainAxisGroupId; - const domain = hasSeparateAxis - ? TimeseriesVisualization.getYAxisDomain(seriesGroup) - : undefined; - const isCustomDomain = groupId !== mainAxisGroupId; - const seriesGroupTickFormatter = TimeseriesVisualization.getTickFormatter( - seriesGroup, - this.props.getConfig - ); - const yScaleType = hasSeparateAxis - ? TimeseriesVisualization.getAxisScaleType(seriesGroup) - : mainAxisScaleType; - - if (seriesGroup.stacked === STACKED_OPTIONS.PERCENT) { - seriesGroup.separate_axis = true; - seriesGroup.axisFormatter = 'percent'; - seriesGroup.axis_min = seriesGroup.axis_min || 0; - seriesGroup.axis_max = seriesGroup.axis_max || 1; - seriesGroup.axis_position = model.axis_position; - } - - series - .filter(r => startsWith(r.id, seriesGroup.id)) - .forEach(seriesDataRow => { - seriesDataRow.tickFormatter = seriesGroupTickFormatter; - seriesDataRow.groupId = groupId; - seriesDataRow.yScaleType = yScaleType; - seriesDataRow.hideInLegend = Boolean(seriesGroup.hide_in_legend); - seriesDataRow.useDefaultGroupDomain = !isCustomDomain; - }); - - if (isCustomDomain) { - TimeseriesVisualization.addYAxis(yAxis, { - domain, - groupId, - id: yAxisIdGenerator(seriesGroup.id), - position: seriesGroup.axis_position, - hide: isStackedWithinSeries, - tickFormatter: - seriesGroup.stacked === STACKED_OPTIONS.PERCENT - ? this.yAxisStackedByPercentFormatter - : seriesGroupTickFormatter, - }); - } else if (!mainDomainAdded) { - TimeseriesVisualization.addYAxis(yAxis, { - tickFormatter, - id: yAxisIdGenerator('main'), - groupId: mainAxisGroupId, - position: model.axis_position, - domain: mainAxisDomain, - }); - - mainDomainAdded = true; - } - }); - - const darkMode = getUISettings().get('theme:darkMode'); - return ( -
- -
- ); - } -} diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/index.scss b/src/legacy/core_plugins/vis_type_timeseries/public/index.scss deleted file mode 100644 index 86fbfb52dbe64..0000000000000 --- a/src/legacy/core_plugins/vis_type_timeseries/public/index.scss +++ /dev/null @@ -1,20 +0,0 @@ -@import 'src/legacy/ui/public/styles/styling_constants'; - -// Prefix all styles with "tvb" to avoid conflicts. -// Examples -// tvbChart -// tvbChart__legend -// tvbChart__legend--small -// tvbChart__legend-isLoading - -@import './variables'; -@import './mixins'; - -// Library overrides -@import './tvb_editor'; - -// Components -@import './components/index'; - -// Visualizations -@import './visualizations/views/index'; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/index.ts b/src/legacy/core_plugins/vis_type_timeseries/public/index.ts deleted file mode 100644 index 16b099ba16ae9..0000000000000 --- a/src/legacy/core_plugins/vis_type_timeseries/public/index.ts +++ /dev/null @@ -1,25 +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 { PluginInitializerContext } from '../../../../core/public'; -import { MetricsPlugin as Plugin } from './plugin'; - -export function plugin(initializerContext: PluginInitializerContext) { - return new Plugin(initializerContext); -} diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/legacy.ts b/src/legacy/core_plugins/vis_type_timeseries/public/legacy.ts deleted file mode 100644 index 42f116701be51..0000000000000 --- a/src/legacy/core_plugins/vis_type_timeseries/public/legacy.ts +++ /dev/null @@ -1,33 +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 { PluginInitializerContext } from 'kibana/public'; -import { npSetup, npStart } from 'ui/new_platform'; -import { MetricsPluginSetupDependencies } from './plugin'; -import { plugin } from '.'; - -const plugins: Readonly = { - expressions: npSetup.plugins.expressions, - visualizations: npSetup.plugins.visualizations, -}; - -const pluginInstance = plugin({} as PluginInitializerContext); - -export const setup = pluginInstance.setup(npSetup.core, plugins); -export const start = pluginInstance.start(npStart.core, npStart.plugins); diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/plugin.ts b/src/legacy/core_plugins/vis_type_timeseries/public/plugin.ts deleted file mode 100644 index 0310ecf6cfd87..0000000000000 --- a/src/legacy/core_plugins/vis_type_timeseries/public/plugin.ts +++ /dev/null @@ -1,70 +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 { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'kibana/public'; -import { Plugin as ExpressionsPublicPlugin } from '../../../../plugins/expressions/public'; -import { VisualizationsSetup } from '../../../../plugins/visualizations/public'; - -import { createMetricsFn } from './metrics_fn'; -import { metricsVisDefinition } from './metrics_type'; -import { - setSavedObjectsClient, - setUISettings, - setI18n, - setFieldFormats, - setCoreStart, - setDataStart, -} from './services'; -import { DataPublicPluginStart } from '../../../../plugins/data/public'; - -/** @internal */ -export interface MetricsPluginSetupDependencies { - expressions: ReturnType; - visualizations: VisualizationsSetup; -} - -/** @internal */ -export interface MetricsPluginStartDependencies { - data: DataPublicPluginStart; -} - -/** @internal */ -export class MetricsPlugin implements Plugin, void> { - initializerContext: PluginInitializerContext; - - constructor(initializerContext: PluginInitializerContext) { - this.initializerContext = initializerContext; - } - - public async setup( - core: CoreSetup, - { expressions, visualizations }: MetricsPluginSetupDependencies - ) { - expressions.registerFunction(createMetricsFn); - setUISettings(core.uiSettings); - visualizations.createReactVisualization(metricsVisDefinition); - } - - public start(core: CoreStart, { data }: MetricsPluginStartDependencies) { - setSavedObjectsClient(core.savedObjects); - setI18n(core.i18n); - setFieldFormats(data.fieldFormats); - setDataStart(data); - setCoreStart(core); - } -} diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/services.ts b/src/legacy/core_plugins/vis_type_timeseries/public/services.ts deleted file mode 100644 index 64ee897247b89..0000000000000 --- a/src/legacy/core_plugins/vis_type_timeseries/public/services.ts +++ /dev/null @@ -1,38 +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 { I18nStart, SavedObjectsStart, IUiSettingsClient, CoreStart } from 'src/core/public'; -import { createGetterSetter } from '../../../../plugins/kibana_utils/public'; -import { DataPublicPluginStart } from '../../../../plugins/data/public'; - -export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); - -export const [getFieldFormats, setFieldFormats] = createGetterSetter< - DataPublicPluginStart['fieldFormats'] ->('FieldFormats'); - -export const [getSavedObjectsClient, setSavedObjectsClient] = createGetterSetter( - 'SavedObjectsClient' -); - -export const [getCoreStart, setCoreStart] = createGetterSetter('CoreStart'); - -export const [getDataStart, setDataStart] = createGetterSetter('DataStart'); - -export const [getI18n, setI18n] = createGetterSetter('I18n'); diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js deleted file mode 100644 index 3ce3aae2649e1..0000000000000 --- a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js +++ /dev/null @@ -1,267 +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 React, { useEffect, useRef } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; - -import { - Axis, - Chart, - Position, - Settings, - AnnotationDomainTypes, - LineAnnotation, - TooltipType, -} from '@elastic/charts'; -import { EuiIcon } from '@elastic/eui'; -import { getTimezone } from '../../../lib/get_timezone'; -import { eventBus, ACTIVE_CURSOR } from '../../lib/active_cursor'; -import { getUISettings } from '../../../services'; -import { GRID_LINE_CONFIG, ICON_TYPES_MAP, STACKED_OPTIONS } from '../../constants'; -import { AreaSeriesDecorator } from './decorators/area_decorator'; -import { BarSeriesDecorator } from './decorators/bar_decorator'; -import { getStackAccessors } from './utils/stack_format'; -import { getTheme, getChartClasses } from './utils/theme'; - -const generateAnnotationData = (values, formatter) => - values.map(({ key, docs }) => ({ - dataValue: key, - details: docs[0], - header: formatter({ - value: key, - }), - })); - -const decorateFormatter = formatter => ({ value }) => formatter(value); - -const handleCursorUpdate = cursor => { - eventBus.trigger(ACTIVE_CURSOR, cursor); -}; - -export const TimeSeries = ({ - darkMode, - backgroundColor, - showGrid, - legend, - legendPosition, - xAxisLabel, - series, - yAxis, - onBrush, - xAxisFormatter, - annotations, - enableHistogramMode, -}) => { - const chartRef = useRef(); - const updateCursor = (_, cursor) => { - if (chartRef.current) { - chartRef.current.dispatchExternalPointerEvent(cursor); - } - }; - - useEffect(() => { - eventBus.on(ACTIVE_CURSOR, updateCursor); - - return () => { - eventBus.off(ACTIVE_CURSOR, undefined, updateCursor); - }; - }, []); // eslint-disable-line - - const tooltipFormatter = decorateFormatter(xAxisFormatter); - const uiSettings = getUISettings(); - const timeZone = getTimezone(uiSettings); - const hasBarChart = series.some(({ bars }) => bars.show); - - // compute the theme based on the bg color - const theme = getTheme(darkMode, backgroundColor); - // apply legend style change if bgColor is configured - const classes = classNames('tvbVisTimeSeries', getChartClasses(backgroundColor)); - - return ( - - - - {annotations.map(({ id, data, icon, color }) => { - const dataValues = generateAnnotationData(data, tooltipFormatter); - const style = { line: { stroke: color } }; - - return ( - } - hideLinesTooltips={true} - style={style} - /> - ); - })} - - {series.map( - ( - { - id, - label, - bars, - lines, - data, - hideInLegend, - xScaleType, - yScaleType, - groupId, - color, - stack, - points, - useDefaultGroupDomain, - y1AccessorFormat, - y0AccessorFormat, - }, - sortIndex - ) => { - const stackAccessors = getStackAccessors(stack); - const isPercentage = stack === STACKED_OPTIONS.PERCENT; - const key = `${id}-${label}`; - - if (bars.show) { - return ( - - ); - } - - if (lines.show) { - return ( - - ); - } - - return null; - } - )} - - {yAxis.map(({ id, groupId, position, tickFormatter, domain, hide }) => ( - - ))} - - - - ); -}; - -TimeSeries.defaultProps = { - showGrid: true, - legend: true, - legendPosition: 'right', -}; - -TimeSeries.propTypes = { - darkMode: PropTypes.bool, - backgroundColor: PropTypes.string, - showGrid: PropTypes.bool, - legend: PropTypes.bool, - legendPosition: PropTypes.string, - xAxisLabel: PropTypes.string, - series: PropTypes.array, - yAxis: PropTypes.array, - onBrush: PropTypes.func, - xAxisFormatter: PropTypes.func, - annotations: PropTypes.array, - enableHistogramMode: PropTypes.bool.isRequired, -}; diff --git a/src/legacy/core_plugins/vis_type_vega/public/__tests__/vega_visualization.js b/src/legacy/core_plugins/vis_type_vega/public/__tests__/vega_visualization.js index c7fbc0815b07c..6412d8a569b2a 100644 --- a/src/legacy/core_plugins/vis_type_vega/public/__tests__/vega_visualization.js +++ b/src/legacy/core_plugins/vis_type_vega/public/__tests__/vega_visualization.js @@ -49,6 +49,10 @@ import { BaseVisType } from '../../../../../plugins/visualizations/public/vis_ty // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ExprVis } from '../../../../../plugins/visualizations/public/expressions/vis'; import { setInjectedVars } from '../services'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { setInjectedVarFunc } from '../../../../../plugins/maps_legacy/public/kibana_services'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ServiceSettings } from '../../../../../plugins/maps_legacy/public/map/service_settings'; const THRESHOLD = 0.1; const PIXEL_DIFF = 30; @@ -69,9 +73,34 @@ describe('VegaVisualizations', () => { beforeEach(ngMock.module('kibana')); beforeEach( - ngMock.inject($injector => { + ngMock.inject(() => { + setInjectedVarFunc(injectedVar => { + switch (injectedVar) { + case 'mapConfig': + return { + emsFileApiUrl: '', + emsTileApiUrl: '', + emsLandingPageUrl: '', + }; + case 'tilemapsConfig': + return { + deprecated: { + config: { + options: { + attribution: '123', + }, + }, + }, + }; + case 'version': + return '123'; + default: + return 'not found'; + } + }); + const serviceSettings = new ServiceSettings(); vegaVisualizationDependencies = { - serviceSettings: $injector.get('serviceSettings'), + serviceSettings, core: { uiSettings: npStart.core.uiSettings, }, diff --git a/src/legacy/core_plugins/vis_type_vega/public/legacy.ts b/src/legacy/core_plugins/vis_type_vega/public/legacy.ts index b2c73894d978d..450af4a6f253e 100644 --- a/src/legacy/core_plugins/vis_type_vega/public/legacy.ts +++ b/src/legacy/core_plugins/vis_type_vega/public/legacy.ts @@ -20,16 +20,12 @@ import { PluginInitializerContext } from 'kibana/public'; import { npSetup, npStart } from 'ui/new_platform'; import { VegaPluginSetupDependencies, VegaPluginStartDependencies } from './plugin'; -import { LegacyDependenciesPlugin } from './shim'; import { plugin } from '.'; const setupPlugins: Readonly = { ...npSetup.plugins, visualizations: npSetup.plugins.visualizations, - - // Temporary solution - // It will be removed when all dependent services are migrated to the new platform. - __LEGACY: new LegacyDependenciesPlugin(), + mapsLegacy: npSetup.plugins.mapsLegacy, }; const startPlugins: Readonly = { diff --git a/src/legacy/core_plugins/vis_type_vega/public/legacy_imports.ts b/src/legacy/core_plugins/vis_type_vega/public/legacy_imports.ts deleted file mode 100644 index b868321d6310f..0000000000000 --- a/src/legacy/core_plugins/vis_type_vega/public/legacy_imports.ts +++ /dev/null @@ -1,23 +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. - */ - -// @ts-ignore -export { KibanaMapLayer } from 'ui/vis/map/kibana_map_layer'; -// @ts-ignore -export { KibanaMap } from 'ui/vis/map/kibana_map'; diff --git a/src/legacy/core_plugins/vis_type_vega/public/plugin.ts b/src/legacy/core_plugins/vis_type_vega/public/plugin.ts index 38b92a40cd99a..9fa77d28fbbfa 100644 --- a/src/legacy/core_plugins/vis_type_vega/public/plugin.ts +++ b/src/legacy/core_plugins/vis_type_vega/public/plugin.ts @@ -17,7 +17,6 @@ * under the License. */ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../../core/public'; -import { LegacyDependenciesPlugin, LegacyDependenciesPluginSetup } from './shim'; import { Plugin as ExpressionsPublicPlugin } from '../../../../plugins/expressions/public'; import { Plugin as DataPublicPlugin } from '../../../../plugins/data/public'; import { VisualizationsSetup } from '../../../../plugins/visualizations/public'; @@ -32,13 +31,15 @@ import { import { createVegaFn } from './vega_fn'; import { createVegaTypeDefinition } from './vega_type'; import { VisTypeVegaSetup } from '../../../../plugins/vis_type_vega/public'; +import { IServiceSettings } from '../../../../plugins/maps_legacy/public'; /** @internal */ -export interface VegaVisualizationDependencies extends LegacyDependenciesPluginSetup { +export interface VegaVisualizationDependencies { core: CoreSetup; plugins: { data: ReturnType; }; + serviceSettings: IServiceSettings; } /** @internal */ @@ -47,7 +48,7 @@ export interface VegaPluginSetupDependencies { visualizations: VisualizationsSetup; data: ReturnType; visTypeVega: VisTypeVegaSetup; - __LEGACY: LegacyDependenciesPlugin; + mapsLegacy: any; } /** @internal */ @@ -65,7 +66,7 @@ export class VegaPlugin implements Plugin, void> { public async setup( core: CoreSetup, - { data, expressions, visualizations, visTypeVega, __LEGACY }: VegaPluginSetupDependencies + { data, expressions, visualizations, visTypeVega, mapsLegacy }: VegaPluginSetupDependencies ) { setInjectedVars({ enableExternalUrls: visTypeVega.config.enableExternalUrls, @@ -79,7 +80,7 @@ export class VegaPlugin implements Plugin, void> { plugins: { data, }, - ...(await __LEGACY.setup()), + serviceSettings: mapsLegacy.serviceSettings, }; expressions.registerFunction(() => createVegaFn(visualizationDependencies)); diff --git a/src/legacy/core_plugins/vis_type_vega/public/shim/index.ts b/src/legacy/core_plugins/vis_type_vega/public/shim/index.ts deleted file mode 100644 index cfc7b62ff4f86..0000000000000 --- a/src/legacy/core_plugins/vis_type_vega/public/shim/index.ts +++ /dev/null @@ -1,20 +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. - */ - -export * from './legacy_dependencies_plugin'; diff --git a/src/legacy/core_plugins/vis_type_vega/public/shim/legacy_dependencies_plugin.ts b/src/legacy/core_plugins/vis_type_vega/public/shim/legacy_dependencies_plugin.ts deleted file mode 100644 index 8925f76cffa43..0000000000000 --- a/src/legacy/core_plugins/vis_type_vega/public/shim/legacy_dependencies_plugin.ts +++ /dev/null @@ -1,49 +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. - */ - -// TODO remove this file as soon as serviceSettings is exposed in the new platform -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import chrome from 'ui/chrome'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import 'ui/vis/map/service_settings'; -import { CoreStart, Plugin } from 'kibana/public'; - -/** @internal */ -export interface LegacyDependenciesPluginSetup { - serviceSettings: any; -} - -export class LegacyDependenciesPlugin - implements Plugin, void> { - public async setup() { - const $injector = await chrome.dangerouslyGetActiveInjector(); - - return { - // Settings for EMSClient. - // EMSClient, which currently lives in the tile_map vis, - // will probably end up being exposed from the future vis_type_maps plugin, - // which would register both the tile_map and the region_map vis plugins. - serviceSettings: $injector.get('serviceSettings'), - } as LegacyDependenciesPluginSetup; - } - - public start(core: CoreStart) { - // nothing to do here yet - } -} diff --git a/src/legacy/core_plugins/vis_type_vega/public/vega_view/vega_map_layer.js b/src/legacy/core_plugins/vis_type_vega/public/vega_view/vega_map_layer.js index 38540e9f218fb..d43eb9c3351ea 100644 --- a/src/legacy/core_plugins/vis_type_vega/public/vega_view/vega_map_layer.js +++ b/src/legacy/core_plugins/vis_type_vega/public/vega_view/vega_map_layer.js @@ -19,7 +19,7 @@ import L from 'leaflet'; import 'leaflet-vega'; -import { KibanaMapLayer } from '../legacy_imports'; +import { KibanaMapLayer } from '../../../../../plugins/maps_legacy/public'; export class VegaMapLayer extends KibanaMapLayer { constructor(spec, options) { diff --git a/src/legacy/core_plugins/vis_type_vega/public/vega_view/vega_map_view.js b/src/legacy/core_plugins/vis_type_vega/public/vega_view/vega_map_view.js index 487c90d01ada3..03aef29dc5739 100644 --- a/src/legacy/core_plugins/vis_type_vega/public/vega_view/vega_map_view.js +++ b/src/legacy/core_plugins/vis_type_vega/public/vega_view/vega_map_view.js @@ -21,10 +21,15 @@ import * as vega from 'vega-lib'; import { i18n } from '@kbn/i18n'; import { VegaBaseView } from './vega_base_view'; import { VegaMapLayer } from './vega_map_layer'; -import { KibanaMap } from '../legacy_imports'; +import { KibanaMap } from '../../../../../plugins/maps_legacy/public'; import { getEmsTileLayerId, getUISettings } from '../services'; export class VegaMapView extends VegaBaseView { + constructor(opts, services) { + super(opts); + this.services = services; + } + async _initViewCustomizations() { const mapConfig = this._parser.mapConfig; let baseMapOpts; @@ -102,14 +107,18 @@ export class VegaMapView extends VegaBaseView { // maxBounds = L.latLngBounds(L.latLng(b[1], b[0]), L.latLng(b[3], b[2])); // } - this._kibanaMap = new KibanaMap(this._$container.get(0), { - zoom, - minZoom, - maxZoom, - center: [mapConfig.latitude, mapConfig.longitude], - zoomControl: mapConfig.zoomControl, - scrollWheelZoom: mapConfig.scrollWheelZoom, - }); + this._kibanaMap = new KibanaMap( + this._$container.get(0), + { + zoom, + minZoom, + maxZoom, + center: [mapConfig.latitude, mapConfig.longitude], + zoomControl: mapConfig.zoomControl, + scrollWheelZoom: mapConfig.scrollWheelZoom, + }, + this.services + ); if (baseMapOpts) { this._kibanaMap.setBaseLayer({ diff --git a/src/legacy/core_plugins/vis_type_vega/public/vega_visualization.js b/src/legacy/core_plugins/vis_type_vega/public/vega_visualization.js index 96835ef3b10bc..a6e911de7f0cb 100644 --- a/src/legacy/core_plugins/vis_type_vega/public/vega_visualization.js +++ b/src/legacy/core_plugins/vis_type_vega/public/vega_visualization.js @@ -116,7 +116,8 @@ export const createVegaVisualization = ({ serviceSettings }) => }; if (vegaParser.useMap) { - this._vegaView = new VegaMapView(vegaViewParams); + const services = { toastService: getNotifications().toasts }; + this._vegaView = new VegaMapView(vegaViewParams, services); } else { this._vegaView = new VegaView(vegaViewParams); } diff --git a/src/legacy/core_plugins/vis_type_vislib/public/area.ts b/src/legacy/core_plugins/vis_type_vislib/public/area.ts index 68decacaaa040..8a196da64fc4b 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/area.ts +++ b/src/legacy/core_plugins/vis_type_vislib/public/area.ts @@ -33,13 +33,13 @@ import { AxisTypes, ScaleTypes, AxisModes, - Rotates, ThresholdLineStyles, getConfigCollections, } from './utils/collections'; import { getAreaOptionTabs, countLabel } from './utils/common_config'; import { createVislibVisController } from './vis_controller'; import { VisTypeVislibDependencies } from './plugin'; +import { Rotates } from '../../../../plugins/charts/public'; export const createAreaVisTypeDefinition = (deps: VisTypeVislibDependencies) => ({ name: 'area', diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/common/index.ts b/src/legacy/core_plugins/vis_type_vislib/public/components/common/index.ts index 05972d101f576..f0bec3167cb7c 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/common/index.ts +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/common/index.ts @@ -17,13 +17,5 @@ * under the License. */ -export { BasicOptions } from './basic_options'; -export { ColorRanges } from './color_ranges'; -export { ColorSchemaOptions, SetColorSchemaOptionsValue } from './color_schema'; -export { NumberInputOption } from './number_input'; -export { RangeOption } from './range'; -export { SelectOption } from './select'; -export { SwitchOption } from './switch'; -export { TextInputOption } from './text_input'; export { TruncateLabelsOption } from './truncate_labels'; export { ValidationWrapper, ValidationVisOptionsProps } from './validation_wrapper'; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/common/utils.ts b/src/legacy/core_plugins/vis_type_vislib/public/components/common/utils.ts deleted file mode 100644 index d51631106dda7..0000000000000 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/common/utils.ts +++ /dev/null @@ -1,34 +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 { useEffect } from 'react'; - -function useValidation( - setValidity: (paramName: ParamName, isValid: boolean) => void, - paramName: ParamName, - isValid: boolean -) { - useEffect(() => { - setValidity(paramName, isValid); - - return () => setValidity(paramName, true); - }, [isValid, paramName, setValidity]); -} - -export { useValidation }; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/gauge/labels_panel.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/gauge/labels_panel.tsx index b9bae1cad35e7..3fca9dc8adc08 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/gauge/labels_panel.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/gauge/labels_panel.tsx @@ -22,7 +22,7 @@ import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { SwitchOption, TextInputOption } from '../../common'; +import { SwitchOption, TextInputOption } from '../../../../../../../plugins/charts/public'; import { GaugeOptionsInternalProps } from '.'; function LabelsPanel({ stateParams, setValue, setGaugeValue }: GaugeOptionsInternalProps) { diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/gauge/ranges_panel.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/gauge/ranges_panel.tsx index 7de64e5888096..433cc4edeb47b 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/gauge/ranges_panel.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/gauge/ranges_panel.tsx @@ -22,12 +22,16 @@ import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ColorRanges, ColorSchemaOptions, SwitchOption } from '../../common'; +import { + ColorRanges, + ColorSchemaOptions, + ColorSchemaParams, + SetColorRangeValue, + SwitchOption, + ColorSchemas, +} from '../../../../../../../plugins/charts/public'; import { GaugeOptionsInternalProps } from '.'; -import { ColorSchemaVislibParams } from '../../../types'; import { Gauge } from '../../../gauge'; -import { SetColorRangeValue } from '../../common/color_ranges'; -import { ColorSchemas } from '../../../../../../../plugins/charts/public'; function RangesPanel({ setGaugeValue, @@ -39,7 +43,7 @@ function RangesPanel({ vis, }: GaugeOptionsInternalProps) { const setColorSchemaOptions = useCallback( - (paramName: T, value: ColorSchemaVislibParams[T]) => { + (paramName: T, value: ColorSchemaParams[T]) => { setGaugeValue(paramName, value as Gauge[T]); // set outline if color schema is changed to greys // if outline wasn't set explicitly yet diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/gauge/style_panel.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/gauge/style_panel.tsx index 9254c3c18347c..48711de7d171a 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/gauge/style_panel.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/gauge/style_panel.tsx @@ -22,7 +22,7 @@ import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { SelectOption } from '../../common'; +import { SelectOption } from '../../../../../../../plugins/charts/public'; import { GaugeOptionsInternalProps } from '.'; import { AggGroupNames } from '../../../../../../../plugins/data/public'; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/heatmap/index.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/heatmap/index.tsx index 715b5902b69da..dc207ad89286f 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/heatmap/index.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/heatmap/index.tsx @@ -31,12 +31,12 @@ import { NumberInputOption, SelectOption, SwitchOption, -} from '../../common'; -import { SetColorSchemaOptionsValue } from '../../common/color_schema'; + SetColorSchemaOptionsValue, + SetColorRangeValue, +} from '../../../../../../../plugins/charts/public'; import { HeatmapVisParams } from '../../../heatmap'; import { ValueAxis } from '../../../types'; import { LabelsPanel } from './labels_panel'; -import { SetColorRangeValue } from '../../common/color_ranges'; function HeatmapOptions(props: VisOptionsProps) { const { stateParams, vis, uiState, setValue, setValidity, setTouched } = props; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/heatmap/labels_panel.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/heatmap/labels_panel.tsx index 38811bd836459..3d1629740df2c 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/heatmap/labels_panel.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/heatmap/labels_panel.tsx @@ -26,7 +26,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { VisOptionsProps } from 'src/plugins/vis_default_editor/public'; import { ValueAxis } from '../../../types'; import { HeatmapVisParams } from '../../../heatmap'; -import { SwitchOption } from '../../common'; +import { SwitchOption } from '../../../../../../../plugins/charts/public'; const VERTICAL_ROTATION = 270; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/category_axis_panel.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/category_axis_panel.tsx index 915885388640c..246c20a14807c 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/category_axis_panel.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/category_axis_panel.tsx @@ -25,7 +25,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { VisOptionsProps } from 'src/plugins/vis_default_editor/public'; import { Axis } from '../../../types'; -import { SelectOption, SwitchOption } from '../../common'; +import { SelectOption, SwitchOption } from '../../../../../../../plugins/charts/public'; import { LabelOptions, SetAxisLabel } from './label_options'; import { Positions } from '../../../utils/collections'; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/chart_options.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/chart_options.tsx index ec7a325ba43d1..89aab3a19c589 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/chart_options.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/chart_options.tsx @@ -25,7 +25,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { Vis } from '../../../../../../../plugins/visualizations/public'; import { SeriesParam, ValueAxis } from '../../../types'; import { ChartTypes } from '../../../utils/collections'; -import { SelectOption } from '../../common'; +import { SelectOption } from '../../../../../../../plugins/charts/public'; import { LineOptions } from './line_options'; import { SetParamByIndex, ChangeValueAxis } from './'; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/custom_extents_options.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/custom_extents_options.tsx index 53b2ffa55a941..a3a97df9e35ae 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/custom_extents_options.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/custom_extents_options.tsx @@ -21,7 +21,7 @@ import React, { useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { ValueAxis } from '../../../types'; -import { NumberInputOption, SwitchOption } from '../../common'; +import { NumberInputOption, SwitchOption } from '../../../../../../../plugins/charts/public'; import { YExtents } from './y_extents'; import { SetScale } from './value_axis_options'; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/label_options.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/label_options.tsx index b6b54193e9f4a..bc687e56646f6 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/label_options.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/label_options.tsx @@ -24,8 +24,9 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { Axis } from '../../../types'; -import { SelectOption, SwitchOption, TruncateLabelsOption } from '../../common'; +import { TruncateLabelsOption } from '../../common'; import { getRotateOptions } from '../../../utils/collections'; +import { SelectOption, SwitchOption } from '../../../../../../../plugins/charts/public'; export type SetAxisLabel = ( paramName: T, diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/line_options.test.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/line_options.test.tsx index 1d29d39bfcb7f..5354bc9c033e6 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/line_options.test.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/line_options.test.tsx @@ -20,7 +20,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { LineOptions, LineOptionsParams } from './line_options'; -import { NumberInputOption } from '../../common'; +import { NumberInputOption } from '../../../../../../../plugins/charts/public'; import { seriesParam, vis } from './mocks'; jest.mock('ui/new_platform'); diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/line_options.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/line_options.tsx index 01a69a6fac70b..76f95bd93caf8 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/line_options.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/line_options.tsx @@ -24,7 +24,11 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { Vis } from '../../../../../../../plugins/visualizations/public'; import { SeriesParam } from '../../../types'; -import { NumberInputOption, SelectOption, SwitchOption } from '../../common'; +import { + NumberInputOption, + SelectOption, + SwitchOption, +} from '../../../../../../../plugins/charts/public'; import { SetChart } from './chart_options'; export interface LineOptionsParams { diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/mocks.ts b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/mocks.ts index 0d9fa8c25a4f7..a296281375dac 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/mocks.ts +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/mocks.ts @@ -18,7 +18,7 @@ */ import { Vis } from '../../../../../../../plugins/visualizations/public'; -import { Axis, ValueAxis, SeriesParam, Style } from '../../../types'; +import { Axis, ValueAxis, SeriesParam } from '../../../types'; import { ChartTypes, ChartModes, @@ -31,6 +31,7 @@ import { getPositions, getInterpolationModes, } from '../../../utils/collections'; +import { Style } from '../../../../../../../plugins/charts/public'; const defaultValueAxisId = 'ValueAxis-1'; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/value_axis_options.test.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/value_axis_options.test.tsx index 955867e66d09f..876a6917ee0b4 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/value_axis_options.test.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/value_axis_options.test.tsx @@ -21,7 +21,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { ValueAxisOptions, ValueAxisOptionsParams } from './value_axis_options'; import { ValueAxis } from '../../../types'; -import { TextInputOption } from '../../common'; +import { TextInputOption } from '../../../../../../../plugins/charts/public'; import { LabelOptions } from './label_options'; import { ScaleTypes, Positions } from '../../../utils/collections'; import { valueAxis, vis } from './mocks'; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/value_axis_options.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/value_axis_options.tsx index 8f0327e78c7ab..1b89a766d0591 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/value_axis_options.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/value_axis_options.tsx @@ -24,7 +24,11 @@ import { EuiSpacer, EuiAccordion, EuiHorizontalRule } from '@elastic/eui'; import { Vis } from '../../../../../../../plugins/visualizations/public'; import { ValueAxis } from '../../../types'; import { Positions } from '../../../utils/collections'; -import { SelectOption, SwitchOption, TextInputOption } from '../../common'; +import { + SelectOption, + SwitchOption, + TextInputOption, +} from '../../../../../../../plugins/charts/public'; import { LabelOptions, SetAxisLabel } from './label_options'; import { CustomExtentsOptions } from './custom_extents_options'; import { isAxisHorizontal } from './utils'; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/y_extents.test.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/y_extents.test.tsx index 17c47b35b20dc..b5ed475ca8e31 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/y_extents.test.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/y_extents.test.tsx @@ -21,7 +21,7 @@ import React from 'react'; import { mount, shallow } from 'enzyme'; import { YExtents, YExtentsProps } from './y_extents'; import { ScaleTypes } from '../../../utils/collections'; -import { NumberInputOption } from '../../common'; +import { NumberInputOption } from '../../../../../../../plugins/charts/public'; jest.mock('ui/new_platform'); diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/y_extents.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/y_extents.tsx index c0db58a612e71..faeb6069b5126 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/y_extents.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/y_extents.tsx @@ -23,7 +23,7 @@ import { i18n } from '@kbn/i18n'; import { Scale } from '../../../types'; import { ScaleTypes } from '../../../utils/collections'; -import { NumberInputOption } from '../../common'; +import { NumberInputOption } from '../../../../../../../plugins/charts/public'; import { SetScale } from './value_axis_options'; const rangeError = i18n.translate('visTypeVislib.controls.pointSeries.valueAxes.minErrorMessage', { diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/pie.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/pie.tsx index 4c0be456aad64..f6be9cd0bd8fe 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/pie.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/pie.tsx @@ -23,7 +23,8 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { VisOptionsProps } from 'src/plugins/vis_default_editor/public'; -import { BasicOptions, TruncateLabelsOption, SwitchOption } from '../common'; +import { TruncateLabelsOption } from '../common'; +import { BasicOptions, SwitchOption } from '../../../../../../plugins/charts/public'; import { PieVisParams } from '../../pie'; function PieOptions(props: VisOptionsProps) { diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/point_series/grid_panel.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/point_series/grid_panel.tsx index bb2b3f8fddb49..392d180d2c5f2 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/point_series/grid_panel.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/point_series/grid_panel.tsx @@ -23,7 +23,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { VisOptionsProps } from 'src/plugins/vis_default_editor/public'; -import { SelectOption, SwitchOption } from '../../common'; +import { SelectOption, SwitchOption } from '../../../../../../../plugins/charts/public'; import { BasicVislibParams, ValueAxis } from '../../../types'; function GridPanel({ stateParams, setValue, hasHistogramAgg }: VisOptionsProps) { diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/point_series/point_series.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/point_series/point_series.tsx index b9872ab94bd0b..903c1917751d9 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/point_series/point_series.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/point_series/point_series.tsx @@ -21,7 +21,8 @@ import { EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { BasicOptions, SwitchOption, ValidationVisOptionsProps } from '../../common'; +import { ValidationVisOptionsProps } from '../../common'; +import { BasicOptions, SwitchOption } from '../../../../../../../plugins/charts/public'; import { GridPanel } from './grid_panel'; import { ThresholdPanel } from './threshold_panel'; import { BasicVislibParams } from '../../../types'; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/point_series/threshold_panel.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/point_series/threshold_panel.tsx index 7866ad74ede7f..12f058ec7dd1f 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/point_series/threshold_panel.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/point_series/threshold_panel.tsx @@ -21,8 +21,12 @@ import { EuiPanel, EuiTitle, EuiColorPicker, EuiFormRow, EuiSpacer } from '@elas import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { SelectOption, SwitchOption, ValidationVisOptionsProps } from '../../common'; -import { NumberInputOption } from '../../common/required_number_input'; +import { ValidationVisOptionsProps } from '../../common'; +import { + SelectOption, + SwitchOption, + RequiredNumberInputOption, +} from '../../../../../../../plugins/charts/public'; import { BasicVislibParams } from '../../../types'; function ThresholdPanel({ @@ -73,7 +77,7 @@ function ThresholdPanel({ {stateParams.thresholdLine.show && ( <> - - ({ name: 'histogram', diff --git a/src/legacy/core_plugins/vis_type_vislib/public/horizontal_bar.ts b/src/legacy/core_plugins/vis_type_vislib/public/horizontal_bar.ts index dc47252ccd44f..6f73271726660 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/horizontal_bar.ts +++ b/src/legacy/core_plugins/vis_type_vislib/public/horizontal_bar.ts @@ -32,13 +32,13 @@ import { AxisTypes, ScaleTypes, AxisModes, - Rotates, ThresholdLineStyles, getConfigCollections, } from './utils/collections'; import { getAreaOptionTabs, countLabel } from './utils/common_config'; import { createVislibVisController } from './vis_controller'; import { VisTypeVislibDependencies } from './plugin'; +import { Rotates } from '../../../../plugins/charts/public'; export const createHorizontalBarVisTypeDefinition = (deps: VisTypeVislibDependencies) => ({ name: 'horizontal_bar', diff --git a/src/legacy/core_plugins/vis_type_vislib/public/index.ts b/src/legacy/core_plugins/vis_type_vislib/public/index.ts index 1f773c4efcb02..4d7091ffb204b 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/index.ts +++ b/src/legacy/core_plugins/vis_type_vislib/public/index.ts @@ -24,18 +24,4 @@ export function plugin(initializerContext: PluginInitializerContext) { return new Plugin(initializerContext); } -export { - BasicOptions, - RangeOption, - ColorRanges, - SelectOption, - SetColorSchemaOptionsValue, - ColorSchemaOptions, - NumberInputOption, - SwitchOption, - TextInputOption, -} from './components'; - -export { ColorModes } from './utils/collections'; - export * from './types'; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/legacy.ts b/src/legacy/core_plugins/vis_type_vislib/public/legacy.ts index aa11e0ef41fba..579caa1cb88f6 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/legacy.ts +++ b/src/legacy/core_plugins/vis_type_vislib/public/legacy.ts @@ -30,6 +30,7 @@ const setupPlugins: Readonly = { expressions: npSetup.plugins.expressions, visualizations: npSetup.plugins.visualizations, charts: npSetup.plugins.charts, + visTypeXy: npSetup.plugins.visTypeXy, }; const startPlugins: Readonly = { diff --git a/src/legacy/core_plugins/vis_type_vislib/public/line.ts b/src/legacy/core_plugins/vis_type_vislib/public/line.ts index 885ab295d11e1..1f9a8d77398e6 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/line.ts +++ b/src/legacy/core_plugins/vis_type_vislib/public/line.ts @@ -32,7 +32,6 @@ import { AxisTypes, ScaleTypes, AxisModes, - Rotates, ThresholdLineStyles, InterpolationModes, getConfigCollections, @@ -40,6 +39,7 @@ import { import { getAreaOptionTabs, countLabel } from './utils/common_config'; import { createVislibVisController } from './vis_controller'; import { VisTypeVislibDependencies } from './plugin'; +import { Rotates } from '../../../../plugins/charts/public'; export const createLineVisTypeDefinition = (deps: VisTypeVislibDependencies) => ({ name: 'line', diff --git a/src/legacy/core_plugins/vis_type_vislib/public/plugin.ts b/src/legacy/core_plugins/vis_type_vislib/public/plugin.ts index 2731fb6f5fbe6..ef3f664252856 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/plugin.ts +++ b/src/legacy/core_plugins/vis_type_vislib/public/plugin.ts @@ -24,6 +24,7 @@ import { PluginInitializerContext, } from 'kibana/public'; +import { VisTypeXyPluginSetup } from 'src/plugins/vis_type_xy/public'; import { Plugin as ExpressionsPublicPlugin } from '../../../../plugins/expressions/public'; import { VisualizationsSetup } from '../../../../plugins/visualizations/public'; import { createVisTypeVislibVisFn } from './vis_type_vislib_vis_fn'; @@ -39,7 +40,6 @@ import { createGoalVisTypeDefinition, } from './vis_type_vislib_vis_types'; import { ChartsPluginSetup } from '../../../../plugins/charts/public'; -import { ConfigSchema as VisTypeXyConfigSchema } from '../../vis_type_xy'; import { DataPublicPluginStart } from '../../../../plugins/data/public'; import { setFormatService, setDataActions } from './services'; @@ -53,6 +53,7 @@ export interface VisTypeVislibPluginSetupDependencies { expressions: ReturnType; visualizations: VisualizationsSetup; charts: ChartsPluginSetup; + visTypeXy?: VisTypeXyPluginSetup; } /** @internal */ @@ -68,7 +69,7 @@ export class VisTypeVislibPlugin implements Plugin { public async setup( core: VisTypeVislibCoreSetup, - { expressions, visualizations, charts }: VisTypeVislibPluginSetupDependencies + { expressions, visualizations, charts, visTypeXy }: VisTypeVislibPluginSetupDependencies ) { const visualizationDependencies: Readonly = { uiSettings: core.uiSettings, @@ -86,12 +87,8 @@ export class VisTypeVislibPlugin implements Plugin { ]; const vislibFns = [createVisTypeVislibVisFn(), createPieVisFn()]; - const visTypeXy = core.injectedMetadata.getInjectedVar('visTypeXy') as - | VisTypeXyConfigSchema['visTypeXy'] - | undefined; - // if visTypeXy plugin is disabled it's config will be undefined - if (!visTypeXy || !visTypeXy.enabled) { + if (!visTypeXy) { const convertedTypes: any[] = []; const convertedFns: any[] = []; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/types.ts b/src/legacy/core_plugins/vis_type_vislib/public/types.ts index f33b42483c53e..25c6ae5439fe8 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/types.ts +++ b/src/legacy/core_plugins/vis_type_vislib/public/types.ts @@ -25,39 +25,16 @@ import { AxisModes, AxisTypes, InterpolationModes, - Rotates, ScaleTypes, ThresholdLineStyles, } from './utils/collections'; -import { ColorSchemas } from '../../../../plugins/charts/public'; +import { Labels, Style } from '../../../../plugins/charts/public'; export interface CommonVislibParams { addTooltip: boolean; legendPosition: Positions; } -export interface ColorSchemaVislibParams { - colorSchema: ColorSchemas; - invertColors: boolean; -} - -export interface Labels { - color?: string; - filter?: boolean; - overwriteColor?: boolean; - rotate?: Rotates; - show: boolean; - truncate?: number | null; -} - -export interface Style { - bgFill: string; - bgColor: boolean; - labelColor: boolean; - subText: string; - fontSize: number; -} - export interface Scale { boundsMargin?: number | ''; defaultYExtents?: boolean; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/utils/collections.ts b/src/legacy/core_plugins/vis_type_vislib/public/utils/collections.ts index f32b765cd6e57..2024c43dd1c8b 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/utils/collections.ts +++ b/src/legacy/core_plugins/vis_type_vislib/public/utils/collections.ts @@ -20,7 +20,7 @@ import { i18n } from '@kbn/i18n'; import { $Values } from '@kbn/utility-types'; -import { colorSchemas } from '../../../../../plugins/charts/public'; +import { colorSchemas, Rotates } from '../../../../../plugins/charts/public'; export const Positions = Object.freeze({ RIGHT: 'right' as 'right', @@ -203,13 +203,6 @@ const getAxisModes = () => [ }, ]; -export const Rotates = Object.freeze({ - HORIZONTAL: 0, - VERTICAL: 90, - ANGLED: 75, -}); -export type Rotates = $Values; - export const ThresholdLineStyles = Object.freeze({ FULL: 'full' as 'full', DASHED: 'dashed' as 'dashed', @@ -265,13 +258,6 @@ export const GaugeTypes = Object.freeze({ }); export type GaugeTypes = $Values; -export const ColorModes = Object.freeze({ - BACKGROUND: 'Background' as 'Background', - LABELS: 'Labels' as 'Labels', - NONE: 'None' as 'None', -}); -export type ColorModes = $Values; - const getGaugeTypes = () => [ { text: i18n.translate('visTypeVislib.gauge.gaugeTypes.arcText', { diff --git a/src/legacy/core_plugins/vis_type_xy/index.ts b/src/legacy/core_plugins/vis_type_xy/index.ts deleted file mode 100644 index 58d2e425eef40..0000000000000 --- a/src/legacy/core_plugins/vis_type_xy/index.ts +++ /dev/null @@ -1,56 +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 { resolve } from 'path'; -import { Legacy } from 'kibana'; - -import { LegacyPluginApi, LegacyPluginInitializer } from '../../types'; - -export interface ConfigSchema { - visTypeXy: { - enabled: boolean; - }; -} - -const visTypeXyPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => - new Plugin({ - id: 'visTypeXy', - require: ['kibana', 'elasticsearch', 'visualizations', 'interpreter'], - publicDir: resolve(__dirname, 'public'), - uiExports: { - hacks: [resolve(__dirname, 'public/legacy')], - injectDefaultVars(server): ConfigSchema { - const config = server.config(); - - return { - visTypeXy: { - enabled: config.get('visTypeXy.enabled') as boolean, - }, - }; - }, - }, - config(Joi: any) { - return Joi.object({ - enabled: Joi.boolean().default(false), - }).default(); - }, - } as Legacy.PluginSpecOptions); - -// eslint-disable-next-line import/no-default-export -export default visTypeXyPluginInitializer; diff --git a/src/legacy/core_plugins/vis_type_xy/package.json b/src/legacy/core_plugins/vis_type_xy/package.json deleted file mode 100644 index 920f7dcb44e87..0000000000000 --- a/src/legacy/core_plugins/vis_type_xy/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "visTypeXy", - "version": "kibana" -} diff --git a/src/legacy/core_plugins/vis_type_xy/public/index.ts b/src/legacy/core_plugins/vis_type_xy/public/index.ts deleted file mode 100644 index 218dc8aa8a683..0000000000000 --- a/src/legacy/core_plugins/vis_type_xy/public/index.ts +++ /dev/null @@ -1,25 +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 { PluginInitializerContext } from '../../../../core/public'; -import { VisTypeXyPlugin as Plugin } from './plugin'; - -export function plugin(initializerContext: PluginInitializerContext) { - return new Plugin(initializerContext); -} diff --git a/src/legacy/core_plugins/vis_type_xy/public/legacy.ts b/src/legacy/core_plugins/vis_type_xy/public/legacy.ts deleted file mode 100644 index 740ceeaac6a7d..0000000000000 --- a/src/legacy/core_plugins/vis_type_xy/public/legacy.ts +++ /dev/null @@ -1,40 +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 { npSetup, npStart } from 'ui/new_platform'; -import { PluginInitializerContext } from 'kibana/public'; - -import { plugin } from '.'; -import { VisTypeXyPluginSetupDependencies, VisTypeXyPluginStartDependencies } from './plugin'; - -const setupPlugins: Readonly = { - expressions: npSetup.plugins.expressions, - visualizations: npSetup.plugins.visualizations, - charts: npSetup.plugins.charts, -}; - -const startPlugins: Readonly = { - expressions: npStart.plugins.expressions, - visualizations: npStart.plugins.visualizations, -}; - -const pluginInstance = plugin({} as PluginInitializerContext); - -export const setup = pluginInstance.setup(npSetup.core, setupPlugins); -export const start = pluginInstance.start(npStart.core, startPlugins); diff --git a/src/legacy/core_plugins/vis_type_xy/public/plugin.ts b/src/legacy/core_plugins/vis_type_xy/public/plugin.ts deleted file mode 100644 index ab01b6b3153fb..0000000000000 --- a/src/legacy/core_plugins/vis_type_xy/public/plugin.ts +++ /dev/null @@ -1,85 +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 { - CoreSetup, - CoreStart, - Plugin, - IUiSettingsClient, - PluginInitializerContext, -} from 'kibana/public'; - -import { Plugin as ExpressionsPublicPlugin } from '../../../../plugins/expressions/public'; -import { - VisualizationsSetup, - VisualizationsStart, -} from '../../../../plugins/visualizations/public'; -import { ChartsPluginSetup } from '../../../../plugins/charts/public'; - -export interface VisTypeXyDependencies { - uiSettings: IUiSettingsClient; - charts: ChartsPluginSetup; -} - -/** @internal */ -export interface VisTypeXyPluginSetupDependencies { - expressions: ReturnType; - visualizations: VisualizationsSetup; - charts: ChartsPluginSetup; -} - -/** @internal */ -export interface VisTypeXyPluginStartDependencies { - expressions: ReturnType; - visualizations: VisualizationsStart; -} - -type VisTypeXyCoreSetup = CoreSetup; - -/** @internal */ -export class VisTypeXyPlugin implements Plugin { - constructor(public initializerContext: PluginInitializerContext) {} - - public async setup( - core: VisTypeXyCoreSetup, - { expressions, visualizations, charts }: VisTypeXyPluginSetupDependencies - ) { - // eslint-disable-next-line no-console - console.warn( - 'The visTypeXy plugin is enabled\n\n', - 'This may negatively alter existing vislib visualization configurations if saved.' - ); - const visualizationDependencies: Readonly = { - uiSettings: core.uiSettings, - charts, - }; - - const visTypeDefinitions: any[] = []; - const visFunctions: any = []; - - visFunctions.forEach((fn: any) => expressions.registerFunction(fn)); - visTypeDefinitions.forEach((vis: any) => - visualizations.createBaseVisualization(vis(visualizationDependencies)) - ); - } - - public start(core: CoreStart, deps: VisTypeXyPluginStartDependencies) { - // nothing to do here - } -} diff --git a/src/legacy/server/kbn_server.d.ts b/src/legacy/server/kbn_server.d.ts index a9b8c29374854..0d2f3528c9019 100644 --- a/src/legacy/server/kbn_server.d.ts +++ b/src/legacy/server/kbn_server.d.ts @@ -41,10 +41,11 @@ import { // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { LegacyConfig, ILegacyService, ILegacyInternals } from '../../core/server/legacy'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { UiPlugins } from '../../core/server/plugins'; import { ApmOssPlugin } from '../core_plugins/apm_oss'; import { CallClusterWithRequest, ElasticsearchPlugin } from '../core_plugins/elasticsearch'; import { UsageCollectionSetup } from '../../plugins/usage_collection/server'; -import { Capabilities } from '../../core/server'; import { UiSettingsServiceFactoryOptions } from '../../legacy/ui/ui_settings/ui_settings_service_factory'; import { HomeServerPluginSetup } from '../../plugins/home/server'; @@ -111,7 +112,7 @@ export interface KibanaCore { kibanaMigrator: LegacyServiceStartDeps['core']['savedObjects']['migrator']; legacy: ILegacyInternals; rendering: LegacyServiceSetupDeps['core']['rendering']; - uiPlugins: LegacyServiceSetupDeps['core']['plugins']['uiPlugins']; + uiPlugins: UiPlugins; uiSettings: LegacyServiceSetupDeps['core']['uiSettings']; savedObjectsClientProvider: LegacyServiceStartDeps['core']['savedObjects']['clientProvider']; }; diff --git a/src/legacy/server/saved_objects/saved_objects_mixin.js b/src/legacy/server/saved_objects/saved_objects_mixin.js index bcf766231dc9c..3e71e1989ae7a 100644 --- a/src/legacy/server/saved_objects/saved_objects_mixin.js +++ b/src/legacy/server/saved_objects/saved_objects_mixin.js @@ -78,13 +78,8 @@ export function savedObjectsMixin(kbnServer, server) { const provider = kbnServer.newPlatform.__internals.savedObjectsClientProvider; - const importAndExportableTypes = typeRegistry - .getImportableAndExportableTypes() - .map(type => type.name); - const service = { types: visibleTypes, - importAndExportableTypes, SavedObjectsClient, SavedObjectsRepository, getSavedObjectsRepository: createRepository, diff --git a/src/legacy/ui/public/_index.scss b/src/legacy/ui/public/_index.scss index 87006d9347de4..aaed52f8b120a 100644 --- a/src/legacy/ui/public/_index.scss +++ b/src/legacy/ui/public/_index.scss @@ -17,9 +17,3 @@ @import './field_editor/index'; @import './style_compile/index'; @import '../../../plugins/management/public/components/index'; - -// The following are prefixed with "vis" - -// Can't import vis folder here because of cascading issues, it's imported in core_plugins/kibana -// @import './vis/index'; -@import './visualize/index'; diff --git a/src/legacy/ui/public/field_editor/field_editor.js b/src/legacy/ui/public/field_editor/field_editor.js index 43461c4c689be..e90cb110ac304 100644 --- a/src/legacy/ui/public/field_editor/field_editor.js +++ b/src/legacy/ui/public/field_editor/field_editor.js @@ -66,7 +66,7 @@ import { ScriptingHelpFlyout } from './components/scripting_help'; import { FieldFormatEditor } from './components/field_format_editor'; import { FIELD_TYPES_BY_LANG, DEFAULT_FIELD_TYPES } from './constants'; -import { copyField, executeScript, isScriptValid } from './lib'; +import { executeScript, isScriptValid } from './lib'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -100,7 +100,6 @@ export class FieldEditor extends PureComponent { indexPattern: PropTypes.object.isRequired, field: PropTypes.object.isRequired, helpers: PropTypes.shape({ - Field: PropTypes.func.isRequired, getConfig: PropTypes.func.isRequired, $http: PropTypes.func.isRequired, fieldFormatEditors: PropTypes.object.isRequired, @@ -111,11 +110,7 @@ export class FieldEditor extends PureComponent { constructor(props) { super(props); - const { - field, - indexPattern, - helpers: { Field }, - } = props; + const { field, indexPattern } = props; this.state = { isReady: false, @@ -125,7 +120,7 @@ export class FieldEditor extends PureComponent { fieldTypes: [], fieldTypeFormats: [], existingFieldNames: indexPattern.fields.map(f => f.name), - field: copyField(field, indexPattern, Field), + field: { ...field, format: field.format }, fieldFormatId: undefined, fieldFormatParams: {}, showScriptingHelp: false, @@ -730,7 +725,7 @@ export class FieldEditor extends PureComponent { }; saveField = async () => { - const field = this.state.field.toActualField(); + const field = this.state.field; const { indexPattern } = this.props; const { fieldFormatId } = this.state; diff --git a/src/legacy/ui/public/field_editor/lib/__tests__/copy_field.test.js b/src/legacy/ui/public/field_editor/lib/__tests__/copy_field.test.js deleted file mode 100644 index 2cee45742ab81..0000000000000 --- a/src/legacy/ui/public/field_editor/lib/__tests__/copy_field.test.js +++ /dev/null @@ -1,48 +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 { copyField } from '../copy_field'; - -const field = { - name: 'test_field', - scripted: true, - type: 'number', - lang: 'painless', -}; - -describe('copyField', () => { - it('should copy a field', () => { - const copiedField = copyField(field, {}, {}); - copiedField.name = 'test_name_change'; - - // Check that copied field has `toActualField()` method - expect(typeof copiedField.toActualField).toEqual('function'); - - // Check that we did not modify the original field object when - // modifying copied field - expect(field.toActualField).toEqual(undefined); - expect(field.name).toEqual('test_field'); - - expect(copiedField).not.toEqual(field); - expect(copiedField.name).toEqual('test_name_change'); - expect(copiedField.scripted).toEqual(field.scripted); - expect(copiedField.type).toEqual(field.type); - expect(copiedField.lang).toEqual(field.lang); - }); -}); diff --git a/src/legacy/ui/public/field_editor/lib/copy_field.js b/src/legacy/ui/public/field_editor/lib/copy_field.js deleted file mode 100644 index bfc1cb8480d5d..0000000000000 --- a/src/legacy/ui/public/field_editor/lib/copy_field.js +++ /dev/null @@ -1,82 +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 { has } from 'lodash'; - -/** - * Fully clones a Field object, so that modifications can be performed on - * the copy without affecting original field. Field objects contain - * enumerable and non-eumerable properties that may or may not be writable. - * The function copies all properties as property descriptors into - * `newFieldProps`, overrides getter and setter, and returns a new object - * created from that. - * - * @param {object} field - Field object to copy - * @param {object} indexPattern - index pattern object the field belongs to - * @param {object} Field - Field object type - * @return {object} the cloned object - */ -export const copyField = (field, indexPattern, Field) => { - const changes = {}; - const newFieldProps = { - // When we are ready to save the copied field back into the index pattern, - // we use `toActualField()` to retrieve an actual `Field` type object, using - // its original properties with our "changes" applied. - toActualField: { - value: () => { - return new Field(indexPattern, { - ...field.$$spec, - ...changes, - }); - }, - }, - }; - - // Index pattern `Field` objects are created with custom property - // descriptors using `ObjDefine`. - // - // Each property of a `Field` type object could be enumerable/non-enumerable, - // writable/not writable, configurable/not configurable, and have custom - // getter and setter. We can't use the original `field` object directly for - // creating a new field or editing a new field, since we need all the - // properties to be editable. - // - // A normal copy of `field` (i.e. `const newField = { ...field }`) will only - // copy enumerable properties and copy each property's descriptors (not - // writable, etc). - // - // So we copy `field`'s **property descriptors** into `newFieldProps` - // and modify them so that they are "writable" with a getter/setter that - // stores and retrieves changes into/from another object (`changes`). - Object.getOwnPropertyNames(field).forEach(function(prop) { - const desc = Object.getOwnPropertyDescriptor(field, prop); - - newFieldProps[prop] = { - enumerable: desc.enumerable, - get: function() { - return has(changes, prop) ? changes[prop] : field[prop]; - }, - set: function(v) { - changes[prop] = v; - }, - }; - }); - - return Object.create(null, newFieldProps); -}; diff --git a/src/legacy/ui/public/field_editor/lib/index.js b/src/legacy/ui/public/field_editor/lib/index.js index c74bb0cc2ef8a..c9dd9d03b74f7 100644 --- a/src/legacy/ui/public/field_editor/lib/index.js +++ b/src/legacy/ui/public/field_editor/lib/index.js @@ -17,5 +17,4 @@ * under the License. */ -export { copyField } from './copy_field'; export { executeScript, isScriptValid } from './validate_script'; diff --git a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js index 0779d6472671c..f577a29ce90b9 100644 --- a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js +++ b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js @@ -309,6 +309,12 @@ export const npSetup = { registerAlias: sinon.fake(), hideTypes: sinon.fake(), }, + + mapsLegacy: { + serviceSettings: sinon.fake(), + getPrecision: sinon.fake(), + getZoomPrecision: sinon.fake(), + }, }, }; @@ -447,6 +453,7 @@ export const npStart = { createAggConfigs: (indexPattern, configStates = []) => { return new AggConfigs(indexPattern, configStates, { typesRegistry: aggTypesRegistry.start(), + fieldFormats: getFieldFormatsRegistry(mockCoreStart), }); }, types: aggTypesRegistry.start(), diff --git a/src/legacy/ui/public/new_platform/new_platform.ts b/src/legacy/ui/public/new_platform/new_platform.ts index cdd7e1a994912..80fb837258d4c 100644 --- a/src/legacy/ui/public/new_platform/new_platform.ts +++ b/src/legacy/ui/public/new_platform/new_platform.ts @@ -22,6 +22,7 @@ import { IScope } from 'angular'; import { UiActionsStart, UiActionsSetup } from 'src/plugins/ui_actions/public'; import { EmbeddableStart, EmbeddableSetup } from 'src/plugins/embeddable/public'; import { createBrowserHistory } from 'history'; +import { VisTypeXyPluginSetup } from 'src/plugins/vis_type_xy/public'; import { DashboardStart } from '../../../../plugins/dashboard/public'; import { setSetupServices, setStartServices } from './set_services'; import { @@ -68,6 +69,7 @@ import { VisualizationsSetup, VisualizationsStart, } from '../../../../plugins/visualizations/public'; +import { MapsLegacyPluginSetup } from '../../../../plugins/maps_legacy/public'; export interface PluginsSetup { bfetch: BfetchPublicSetup; @@ -90,7 +92,9 @@ export interface PluginsSetup { visualizations: VisualizationsSetup; telemetry?: TelemetryPluginSetup; savedObjectsManagement: SavedObjectsManagementPluginSetup; + mapsLegacy: MapsLegacyPluginSetup; indexPatternManagement: IndexPatternManagementSetup; + visTypeXy?: VisTypeXyPluginSetup; } export interface PluginsStart { diff --git a/src/legacy/ui/public/scripting_languages/index.js b/src/legacy/ui/public/scripting_languages/index.js deleted file mode 100644 index 2f43a44d66068..0000000000000 --- a/src/legacy/ui/public/scripting_languages/index.js +++ /dev/null @@ -1,46 +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 chrome from '../chrome'; -import { toastNotifications } from '../notify'; -import { i18n } from '@kbn/i18n'; - -export function getSupportedScriptingLanguages() { - return ['painless']; -} - -export function getDeprecatedScriptingLanguages() { - return []; -} - -export function GetEnabledScriptingLanguagesProvider($http) { - return () => { - return $http - .get(chrome.addBasePath('/api/kibana/scripts/languages')) - .then(res => res.data) - .catch(() => { - toastNotifications.addDanger( - i18n.translate('common.ui.scriptingLanguages.errorFetchingToastDescription', { - defaultMessage: 'Error getting available scripting languages from Elasticsearch', - }) - ); - return []; - }); - }; -} diff --git a/src/legacy/ui/public/scripting_languages/index.ts b/src/legacy/ui/public/scripting_languages/index.ts new file mode 100644 index 0000000000000..283a3273a2a5d --- /dev/null +++ b/src/legacy/ui/public/scripting_languages/index.ts @@ -0,0 +1,48 @@ +/* + * 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 { IHttpService } from 'angular'; +import { i18n } from '@kbn/i18n'; + +import chrome from '../chrome'; +import { toastNotifications } from '../notify'; + +export function getSupportedScriptingLanguages(): string[] { + return ['painless']; +} + +export function getDeprecatedScriptingLanguages(): string[] { + return []; +} + +export function GetEnabledScriptingLanguagesProvider($http: IHttpService) { + return () => { + return $http + .get(chrome.addBasePath('/api/kibana/scripts/languages')) + .then((res: any) => res.data) + .catch(() => { + toastNotifications.addDanger( + i18n.translate('common.ui.scriptingLanguages.errorFetchingToastDescription', { + defaultMessage: 'Error getting available scripting languages from Elasticsearch', + }) + ); + return []; + }); + }; +} diff --git a/src/legacy/ui/public/vis/__tests__/map/service_settings.js b/src/legacy/ui/public/vis/__tests__/map/service_settings.js deleted file mode 100644 index 61925760457c6..0000000000000 --- a/src/legacy/ui/public/vis/__tests__/map/service_settings.js +++ /dev/null @@ -1,348 +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 expect from '@kbn/expect'; -import ngMock from 'ng_mock'; -import url from 'url'; - -import EMS_FILES from './ems_mocks/sample_files.json'; -import EMS_TILES from './ems_mocks/sample_tiles.json'; -import EMS_STYLE_ROAD_MAP_BRIGHT from './ems_mocks/sample_style_bright'; -import EMS_STYLE_ROAD_MAP_DESATURATED from './ems_mocks/sample_style_desaturated'; -import EMS_STYLE_DARK_MAP from './ems_mocks/sample_style_dark'; -import { ORIGIN } from '../../../../../core_plugins/tile_map/common/origin'; - -describe('service_settings (FKA tilemaptest)', function() { - let serviceSettings; - let mapConfig; - let tilemapsConfig; - - const emsFileApiUrl = 'https://files.foobar'; - const emsTileApiUrl = 'https://tiles.foobar'; - - const emsTileApiUrl2 = 'https://tiles_override.foobar'; - const emsFileApiUrl2 = 'https://files_override.foobar'; - - beforeEach( - ngMock.module('kibana', $provide => { - $provide.decorator('mapConfig', () => { - return { - emsFileApiUrl, - emsTileApiUrl, - includeElasticMapsService: true, - emsTileLayerId: { - bright: 'road_map', - desaturated: 'road_map_desaturated', - dark: 'dark_map', - }, - }; - }); - }) - ); - - let emsTileApiUrlOriginal; - let emsFileApiUrlOriginal; - let tilemapsConfigDeprecatedOriginal; - let getManifestStub; - beforeEach( - ngMock.inject(function($injector, $rootScope) { - serviceSettings = $injector.get('serviceSettings'); - getManifestStub = serviceSettings.__debugStubManifestCalls(async url => { - //simulate network calls - if (url.startsWith('https://tiles.foobar')) { - if (url.includes('/manifest')) { - return EMS_TILES; - } else if (url.includes('osm-bright-desaturated.json')) { - return EMS_STYLE_ROAD_MAP_DESATURATED; - } else if (url.includes('osm-bright.json')) { - return EMS_STYLE_ROAD_MAP_BRIGHT; - } else if (url.includes('dark-matter.json')) { - return EMS_STYLE_DARK_MAP; - } - } else if (url.startsWith('https://files.foobar')) { - return EMS_FILES; - } - }); - mapConfig = $injector.get('mapConfig'); - tilemapsConfig = $injector.get('tilemapsConfig'); - - emsTileApiUrlOriginal = mapConfig.emsTileApiUrl; - emsFileApiUrlOriginal = mapConfig.emsFileApiUrl; - - tilemapsConfigDeprecatedOriginal = tilemapsConfig.deprecated; - $rootScope.$digest(); - }) - ); - - afterEach(function() { - getManifestStub.removeStub(); - mapConfig.emsTileApiUrl = emsTileApiUrlOriginal; - mapConfig.emsFileApiUrl = emsFileApiUrlOriginal; - tilemapsConfig.deprecated = tilemapsConfigDeprecatedOriginal; - }); - - describe('TMS', function() { - it('should NOT get url from the config', async function() { - const tmsServices = await serviceSettings.getTMSServices(); - const tmsService = tmsServices[0]; - expect(typeof tmsService.url === 'undefined').to.equal(true); - }); - - it('should get url by resolving dynamically', async function() { - const tmsServices = await serviceSettings.getTMSServices(); - const tmsService = tmsServices[0]; - expect(typeof tmsService.url === 'undefined').to.equal(true); - - const attrs = await serviceSettings.getAttributesForTMSLayer(tmsService); - expect(attrs.url).to.contain('{x}'); - expect(attrs.url).to.contain('{y}'); - expect(attrs.url).to.contain('{z}'); - - const urlObject = url.parse(attrs.url, true); - expect(urlObject.hostname).to.be('tiles.foobar'); - expect(urlObject.query).to.have.property('my_app_name', 'kibana'); - expect(urlObject.query).to.have.property('elastic_tile_service_tos', 'agree'); - expect(urlObject.query).to.have.property('my_app_version'); - }); - - it('should get options', async function() { - const tmsServices = await serviceSettings.getTMSServices(); - const tmsService = tmsServices[0]; - expect(tmsService).to.have.property('minZoom'); - expect(tmsService).to.have.property('maxZoom'); - expect(tmsService) - .to.have.property('attribution') - .contain('OpenStreetMap'); - }); - - describe('modify - url', function() { - let tilemapServices; - - async function assertQuery(expected) { - const attrs = await serviceSettings.getAttributesForTMSLayer(tilemapServices[0]); - const urlObject = url.parse(attrs.url, true); - Object.keys(expected).forEach(key => { - expect(urlObject.query).to.have.property(key, expected[key]); - }); - } - - it('accepts an object', async () => { - serviceSettings.addQueryParams({ foo: 'bar' }); - tilemapServices = await serviceSettings.getTMSServices(); - await assertQuery({ foo: 'bar' }); - }); - - it('merged additions with previous values', async () => { - // ensure that changes are always additive - serviceSettings.addQueryParams({ foo: 'bar' }); - serviceSettings.addQueryParams({ bar: 'stool' }); - tilemapServices = await serviceSettings.getTMSServices(); - await assertQuery({ foo: 'bar', bar: 'stool' }); - }); - - it('overwrites conflicting previous values', async () => { - // ensure that conflicts are overwritten - serviceSettings.addQueryParams({ foo: 'bar' }); - serviceSettings.addQueryParams({ bar: 'stool' }); - serviceSettings.addQueryParams({ foo: 'tstool' }); - tilemapServices = await serviceSettings.getTMSServices(); - await assertQuery({ foo: 'tstool', bar: 'stool' }); - }); - - it('when overridden, should continue to work', async () => { - mapConfig.emsFileApiUrl = emsFileApiUrl2; - mapConfig.emsTileApiUrl = emsTileApiUrl2; - serviceSettings.addQueryParams({ foo: 'bar' }); - tilemapServices = await serviceSettings.getTMSServices(); - await assertQuery({ foo: 'bar' }); - }); - - it('should merge in tilemap url', async () => { - tilemapsConfig.deprecated = { - isOverridden: true, - config: { - url: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', - options: { minZoom: 0, maxZoom: 20 }, - }, - }; - - tilemapServices = await serviceSettings.getTMSServices(); - const expected = [ - { - attribution: '', - url: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', - id: 'TMS in config/kibana.yml', - }, - { - id: 'road_map', - name: 'Road Map - Bright', - url: - 'https://tiles.foobar/raster/styles/osm-bright/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana&my_app_version=1.2.3', - minZoom: 0, - maxZoom: 10, - attribution: - 'OpenStreetMap contributors | OpenMapTiles | MapTiler | Elastic Maps Service', - subdomains: [], - }, - ]; - - const assertions = tilemapServices.map(async (actualService, index) => { - const expectedService = expected[index]; - expect(actualService.id).to.equal(expectedService.id); - expect(actualService.attribution).to.equal(expectedService.attribution); - const attrs = await serviceSettings.getAttributesForTMSLayer(actualService); - expect(attrs.url).to.equal(expectedService.url); - }); - - return Promise.all(assertions); - }); - - it('should load appropriate EMS attributes for desaturated and dark theme', async () => { - tilemapServices = await serviceSettings.getTMSServices(); - const roadMapService = tilemapServices.find(service => service.id === 'road_map'); - - const desaturationFalse = await serviceSettings.getAttributesForTMSLayer( - roadMapService, - false, - false - ); - const desaturationTrue = await serviceSettings.getAttributesForTMSLayer( - roadMapService, - true, - false - ); - const darkThemeDesaturationFalse = await serviceSettings.getAttributesForTMSLayer( - roadMapService, - false, - true - ); - const darkThemeDesaturationTrue = await serviceSettings.getAttributesForTMSLayer( - roadMapService, - true, - true - ); - - expect(desaturationFalse.url).to.equal( - 'https://tiles.foobar/raster/styles/osm-bright/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana&my_app_version=1.2.3' - ); - expect(desaturationFalse.maxZoom).to.equal(10); - expect(desaturationTrue.url).to.equal( - 'https://tiles.foobar/raster/styles/osm-bright-desaturated/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana&my_app_version=1.2.3' - ); - expect(desaturationTrue.maxZoom).to.equal(18); - expect(darkThemeDesaturationFalse.url).to.equal( - 'https://tiles.foobar/raster/styles/dark-matter/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana&my_app_version=1.2.3' - ); - expect(darkThemeDesaturationFalse.maxZoom).to.equal(22); - expect(darkThemeDesaturationTrue.url).to.equal( - 'https://tiles.foobar/raster/styles/dark-matter/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana&my_app_version=1.2.3' - ); - expect(darkThemeDesaturationTrue.maxZoom).to.equal(22); - }); - - it('should exclude EMS', async () => { - tilemapsConfig.deprecated = { - isOverridden: true, - config: { - url: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', - options: { minZoom: 0, maxZoom: 20 }, - }, - }; - mapConfig.includeElasticMapsService = false; - - tilemapServices = await serviceSettings.getTMSServices(); - const expected = [ - { - attribution: '', - url: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', - id: 'TMS in config/kibana.yml', - }, - ]; - expect(tilemapServices.length).to.eql(1); - expect(tilemapServices[0].attribution).to.eql(expected[0].attribution); - expect(tilemapServices[0].id).to.eql(expected[0].id); - const attrs = await serviceSettings.getAttributesForTMSLayer(tilemapServices[0]); - expect(attrs.url).to.equal(expected[0].url); - }); - - it('should exclude all when not configured', async () => { - mapConfig.includeElasticMapsService = false; - tilemapServices = await serviceSettings.getTMSServices(); - const expected = []; - expect(tilemapServices).to.eql(expected); - }); - }); - }); - - describe('File layers', function() { - it('should load manifest (all props)', async function() { - serviceSettings.addQueryParams({ foo: 'bar' }); - const fileLayers = await serviceSettings.getFileLayers(); - expect(fileLayers.length).to.be(18); - const assertions = fileLayers.map(async function(fileLayer) { - expect(fileLayer.origin).to.be(ORIGIN.EMS); - const fileUrl = await serviceSettings.getUrlForRegionLayer(fileLayer); - const urlObject = url.parse(fileUrl, true); - Object.keys({ foo: 'bar', elastic_tile_service_tos: 'agree' }).forEach(key => { - expect(urlObject.query).to.have.property(key); - }); - }); - - return Promise.all(assertions); - }); - - it('should load manifest (individual props)', async () => { - const expected = { - attribution: - 'Made with NaturalEarth | Elastic Maps Service', - format: 'geojson', - fields: [ - { type: 'id', name: 'iso2', description: 'ISO 3166-1 alpha-2 code' }, - { type: 'id', name: 'iso3', description: 'ISO 3166-1 alpha-3 code' }, - { type: 'property', name: 'name', description: 'name' }, - ], - created_at: '2017-04-26T17:12:15.978370', //not present in 6.6 - name: 'World Countries', - }; - - const fileLayers = await serviceSettings.getFileLayers(); - const actual = fileLayers[0]; - - expect(expected.attribution).to.eql(actual.attribution); - expect(expected.format).to.eql(actual.format); - expect(expected.fields).to.eql(actual.fields); - expect(expected.name).to.eql(actual.name); - - expect(expected.created_at).to.eql(actual.created_at); - }); - - it('should exclude all when not configured', async () => { - mapConfig.includeElasticMapsService = false; - const fileLayers = await serviceSettings.getFileLayers(); - const expected = []; - expect(fileLayers).to.eql(expected); - }); - - it('should get hotlink', async () => { - const fileLayers = await serviceSettings.getFileLayers(); - const hotlink = await serviceSettings.getEMSHotLink(fileLayers[0]); - expect(hotlink).to.eql('?locale=en#file/world_countries'); //url host undefined becuase emsLandingPageUrl is set at kibana-load - }); - }); -}); diff --git a/src/legacy/ui/public/vis/map/service_settings.js b/src/legacy/ui/public/vis/map/service_settings.js deleted file mode 100644 index a014aeb182c67..0000000000000 --- a/src/legacy/ui/public/vis/map/service_settings.js +++ /dev/null @@ -1,256 +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 { uiModules } from '../../modules'; -import _ from 'lodash'; -import MarkdownIt from 'markdown-it'; -import { ORIGIN } from '../../../../core_plugins/tile_map/common/origin'; -import { EMSClient } from '@elastic/ems-client'; -import { i18n } from '@kbn/i18n'; -import 'angular-sanitize'; - -const markdownIt = new MarkdownIt({ - html: false, - linkify: true, -}); - -const TMS_IN_YML_ID = 'TMS in config/kibana.yml'; - -uiModules - .get('kibana', ['ngSanitize']) - .service('serviceSettings', function($sanitize, mapConfig, tilemapsConfig, kbnVersion) { - const attributionFromConfig = $sanitize( - markdownIt.render(tilemapsConfig.deprecated.config.options.attribution || '') - ); - const tmsOptionsFromConfig = _.assign({}, tilemapsConfig.deprecated.config.options, { - attribution: attributionFromConfig, - }); - - class ServiceSettings { - constructor() { - this._showZoomMessage = true; - this._emsClient = new EMSClient({ - language: i18n.getLocale(), - appVersion: kbnVersion, - appName: 'kibana', - fileApiUrl: mapConfig.emsFileApiUrl, - tileApiUrl: mapConfig.emsTileApiUrl, - htmlSanitizer: $sanitize, - landingPageUrl: mapConfig.emsLandingPageUrl, - // Wrap to avoid errors passing window fetch - fetchFunction: function(...args) { - return fetch(...args); - }, - }); - } - - shouldShowZoomMessage({ origin }) { - return origin === ORIGIN.EMS && this._showZoomMessage; - } - - disableZoomMessage() { - this._showZoomMessage = false; - } - - __debugStubManifestCalls(manifestRetrieval) { - const oldGetManifest = this._emsClient.getManifest; - this._emsClient.getManifest = manifestRetrieval; - return { - removeStub: () => { - delete this._emsClient.getManifest; - //not strictly necessary since this is prototype method - if (this._emsClient.getManifest !== oldGetManifest) { - this._emsClient.getManifest = oldGetManifest; - } - }, - }; - } - - async getFileLayers() { - if (!mapConfig.includeElasticMapsService) { - return []; - } - - const fileLayers = await this._emsClient.getFileLayers(); - return fileLayers.map(fileLayer => { - //backfill to older settings - const format = fileLayer.getDefaultFormatType(); - const meta = fileLayer.getDefaultFormatMeta(); - - return { - name: fileLayer.getDisplayName(), - origin: fileLayer.getOrigin(), - id: fileLayer.getId(), - created_at: fileLayer.getCreatedAt(), - attribution: fileLayer.getHTMLAttribution(), - fields: fileLayer.getFieldsInLanguage(), - format: format, //legacy: format and meta are split up - meta: meta, //legacy, format and meta are split up - }; - }); - } - - /** - * Returns all the services published by EMS (if configures) - * It also includes the service configured in tilemap (override) - */ - async getTMSServices() { - let allServices = []; - if (tilemapsConfig.deprecated.isOverridden) { - //use tilemap.* settings from yml - const tmsService = _.cloneDeep(tmsOptionsFromConfig); - tmsService.id = TMS_IN_YML_ID; - tmsService.origin = ORIGIN.KIBANA_YML; - allServices.push(tmsService); - } - - if (mapConfig.includeElasticMapsService) { - const servicesFromManifest = await this._emsClient.getTMSServices(); - const strippedServiceFromManifest = await Promise.all( - servicesFromManifest - .filter(tmsService => tmsService.getId() === mapConfig.emsTileLayerId.bright) - .map(async tmsService => { - //shim for compatibility - const shim = { - origin: tmsService.getOrigin(), - id: tmsService.getId(), - minZoom: await tmsService.getMinZoom(), - maxZoom: await tmsService.getMaxZoom(), - attribution: tmsService.getHTMLAttribution(), - }; - return shim; - }) - ); - allServices = allServices.concat(strippedServiceFromManifest); - } - - return allServices; - } - - /** - * Add optional query-parameters to all requests - * - * @param additionalQueryParams - */ - addQueryParams(additionalQueryParams) { - this._emsClient.addQueryParams(additionalQueryParams); - } - - async getEMSHotLink(fileLayerConfig) { - const fileLayers = await this._emsClient.getFileLayers(); - const layer = fileLayers.find(fileLayer => { - const hasIdByName = fileLayer.hasId(fileLayerConfig.name); //legacy - const hasIdById = fileLayer.hasId(fileLayerConfig.id); - return hasIdByName || hasIdById; - }); - return layer ? layer.getEMSHotLink() : null; - } - - async _getAttributesForEMSTMSLayer(isDesaturated, isDarkMode) { - const tmsServices = await this._emsClient.getTMSServices(); - const emsTileLayerId = mapConfig.emsTileLayerId; - let serviceId; - if (isDarkMode) { - serviceId = emsTileLayerId.dark; - } else { - if (isDesaturated) { - serviceId = emsTileLayerId.desaturated; - } else { - serviceId = emsTileLayerId.bright; - } - } - const tmsService = tmsServices.find(service => { - return service.getId() === serviceId; - }); - return { - url: await tmsService.getUrlTemplate(), - minZoom: await tmsService.getMinZoom(), - maxZoom: await tmsService.getMaxZoom(), - attribution: await tmsService.getHTMLAttribution(), - origin: ORIGIN.EMS, - }; - } - - async getAttributesForTMSLayer(tmsServiceConfig, isDesaturated, isDarkMode) { - if (tmsServiceConfig.origin === ORIGIN.EMS) { - return this._getAttributesForEMSTMSLayer(isDesaturated, isDarkMode); - } else if (tmsServiceConfig.origin === ORIGIN.KIBANA_YML) { - const config = tilemapsConfig.deprecated.config; - const attrs = _.pick(config, ['url', 'minzoom', 'maxzoom', 'attribution']); - return { ...attrs, ...{ origin: ORIGIN.KIBANA_YML } }; - } else { - //this is an older config. need to resolve this dynamically. - if (tmsServiceConfig.id === TMS_IN_YML_ID) { - const config = tilemapsConfig.deprecated.config; - const attrs = _.pick(config, ['url', 'minzoom', 'maxzoom', 'attribution']); - return { ...attrs, ...{ origin: ORIGIN.KIBANA_YML } }; - } else { - //assume ems - return this._getAttributesForEMSTMSLayer(isDesaturated, isDarkMode); - } - } - } - - async _getFileUrlFromEMS(fileLayerConfig) { - const fileLayers = await this._emsClient.getFileLayers(); - const layer = fileLayers.find(fileLayer => { - const hasIdByName = fileLayer.hasId(fileLayerConfig.name); //legacy - const hasIdById = fileLayer.hasId(fileLayerConfig.id); - return hasIdByName || hasIdById; - }); - - if (layer) { - return layer.getDefaultFormatUrl(); - } else { - throw new Error(`File ${fileLayerConfig.name} not recognized`); - } - } - - async getUrlForRegionLayer(fileLayerConfig) { - let url; - if (fileLayerConfig.origin === ORIGIN.EMS) { - url = this._getFileUrlFromEMS(fileLayerConfig); - } else if ( - fileLayerConfig.layerId && - fileLayerConfig.layerId.startsWith(`${ORIGIN.EMS}.`) - ) { - //fallback for older saved objects - url = this._getFileUrlFromEMS(fileLayerConfig); - } else if ( - fileLayerConfig.layerId && - fileLayerConfig.layerId.startsWith(`${ORIGIN.KIBANA_YML}.`) - ) { - //fallback for older saved objects - url = fileLayerConfig.url; - } else { - //generic fallback - url = fileLayerConfig.url; - } - return url; - } - - async getJsonForRegionLayer(fileLayerConfig) { - const url = await this.getUrlForRegionLayer(fileLayerConfig); - const response = await fetch(url); - return await response.json(); - } - } - - return new ServiceSettings(); - }); diff --git a/src/legacy/ui/public/visualize/_index.scss b/src/legacy/ui/public/visualize/_index.scss deleted file mode 100644 index d9761f741353b..0000000000000 --- a/src/legacy/ui/public/visualize/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import '../../../../plugins/visualizations/public/components/index'; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/common/basic_options.tsx b/src/plugins/charts/public/static/components/basic_options.tsx similarity index 90% rename from src/legacy/core_plugins/vis_type_vislib/public/components/common/basic_options.tsx rename to src/plugins/charts/public/static/components/basic_options.tsx index baf3e8ecd1b28..cac4c8d70d796 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/common/basic_options.tsx +++ b/src/plugins/charts/public/static/components/basic_options.tsx @@ -37,7 +37,7 @@ function BasicOptions({ return ( <> ({ setValue={setValue} /> ; + +export const Rotates = Object.freeze({ + HORIZONTAL: 0, + VERTICAL: 90, + ANGLED: 75, +}); +export type Rotates = $Values; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/common/color_ranges.tsx b/src/plugins/charts/public/static/components/color_ranges.tsx similarity index 93% rename from src/legacy/core_plugins/vis_type_vislib/public/components/common/color_ranges.tsx rename to src/plugins/charts/public/static/components/color_ranges.tsx index 84c70f10b12da..a9b05d7d91c7c 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/common/color_ranges.tsx +++ b/src/plugins/charts/public/static/components/color_ranges.tsx @@ -22,10 +22,7 @@ import { last } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { - RangeValues, - RangesParamEditor, -} from '../../../../../../plugins/vis_default_editor/public'; +import { RangeValues, RangesParamEditor } from '../../../../vis_default_editor/public'; export type SetColorRangeValue = (paramName: string, value: RangeValues[]) => void; @@ -74,7 +71,7 @@ function ColorRanges({ return ( ( +export type SetColorSchemaOptionsValue = ( paramName: T, - value: ColorSchemaVislibParams[T] + value: ColorSchemaParams[T] ) => void; -interface ColorSchemaOptionsProps extends ColorSchemaVislibParams { +interface ColorSchemaOptionsProps extends ColorSchemaParams { disabled?: boolean; colorSchemas: ColorSchema[]; uiState: VisOptionsProps['uiState']; @@ -67,7 +67,7 @@ function ColorSchemaOptions({ }} > @@ -80,11 +80,11 @@ function ColorSchemaOptions({ disabled={disabled} helpText={ showHelpText && - i18n.translate('visTypeVislib.controls.colorSchema.howToChangeColorsDescription', { + i18n.translate('charts.controls.colorSchema.howToChangeColorsDescription', { defaultMessage: 'Individual colors can be changed in the legend.', }) } - label={i18n.translate('visTypeVislib.controls.colorSchema.colorSchemaLabel', { + label={i18n.translate('charts.controls.colorSchema.colorSchemaLabel', { defaultMessage: 'Color schema', })} labelAppend={isCustomColors && resetColorsButton} @@ -96,7 +96,7 @@ function ColorSchemaOptions({ ({ const [stateValue, setStateValue] = useState(value); const [isValidState, setIsValidState] = useState(true); - const error = i18n.translate('visTypeVislib.controls.rangeErrorMessage', { + const error = i18n.translate('charts.controls.rangeErrorMessage', { defaultMessage: 'Values must be on or between {min} and {max}', values: { min, max }, }); diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/common/required_number_input.tsx b/src/plugins/charts/public/static/components/required_number_input.tsx similarity index 87% rename from src/legacy/core_plugins/vis_type_vislib/public/components/common/required_number_input.tsx rename to src/plugins/charts/public/static/components/required_number_input.tsx index 7b62016c4e502..7594c775b07ad 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/common/required_number_input.tsx +++ b/src/plugins/charts/public/static/components/required_number_input.tsx @@ -17,9 +17,8 @@ * under the License. */ -import React, { ReactNode, useCallback, ChangeEvent } from 'react'; +import React, { ReactNode, useCallback, ChangeEvent, useEffect } from 'react'; import { EuiFormRow, EuiFieldNumber } from '@elastic/eui'; -import { useValidation } from './utils'; interface NumberInputOptionProps { disabled?: boolean; @@ -42,7 +41,7 @@ interface NumberInputOptionProps { * * @param {number} props.value Should be numeric only */ -function NumberInputOption({ +function RequiredNumberInputOption({ disabled, error, isInvalid, @@ -57,7 +56,12 @@ function NumberInputOption({ 'data-test-subj': dataTestSubj, }: NumberInputOptionProps) { const isValid = value !== null; - useValidation(setValidity, paramName, isValid); + + useEffect(() => { + setValidity(paramName, isValid); + + return () => setValidity(paramName, true); + }, [isValid, paramName, setValidity]); const onChange = useCallback( (ev: ChangeEvent) => @@ -84,4 +88,4 @@ function NumberInputOption({ ); } -export { NumberInputOption }; +export { RequiredNumberInputOption }; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/common/select.tsx b/src/plugins/charts/public/static/components/select.tsx similarity index 100% rename from src/legacy/core_plugins/vis_type_vislib/public/components/common/select.tsx rename to src/plugins/charts/public/static/components/select.tsx diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/common/switch.tsx b/src/plugins/charts/public/static/components/switch.tsx similarity index 100% rename from src/legacy/core_plugins/vis_type_vislib/public/components/common/switch.tsx rename to src/plugins/charts/public/static/components/switch.tsx diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/common/text_input.tsx b/src/plugins/charts/public/static/components/text_input.tsx similarity index 100% rename from src/legacy/core_plugins/vis_type_vislib/public/components/common/text_input.tsx rename to src/plugins/charts/public/static/components/text_input.tsx diff --git a/src/plugins/charts/public/static/components/types.ts b/src/plugins/charts/public/static/components/types.ts new file mode 100644 index 0000000000000..196eb60b06aec --- /dev/null +++ b/src/plugins/charts/public/static/components/types.ts @@ -0,0 +1,43 @@ +/* + * 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 { ColorSchemas } from '../color_maps'; +import { Rotates } from './collections'; + +export interface ColorSchemaParams { + colorSchema: ColorSchemas; + invertColors: boolean; +} + +export interface Labels { + color?: string; + filter?: boolean; + overwriteColor?: boolean; + rotate?: Rotates; + show: boolean; + truncate?: number | null; +} + +export interface Style { + bgFill: string; + bgColor: boolean; + labelColor: boolean; + subText: string; + fontSize: number; +} diff --git a/src/plugins/charts/public/static/index.ts b/src/plugins/charts/public/static/index.ts index bee58e4f1e3e1..6fc097d05467f 100644 --- a/src/plugins/charts/public/static/index.ts +++ b/src/plugins/charts/public/static/index.ts @@ -18,3 +18,4 @@ */ export * from './color_maps'; +export * from './components'; diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor_output.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor_output.tsx index 36d90bb6bff1a..8510aaebfaa1e 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor_output.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor_output.tsx @@ -20,7 +20,7 @@ import { EuiScreenReaderOnly } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect, useRef } from 'react'; -import { expandLiteralStrings } from '../../../../../../../es_ui_shared/console_lang/lib'; +import { expandLiteralStrings } from '../../../../../../../es_ui_shared/public'; import { useEditorReadContext, useRequestReadContext, diff --git a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/send_request_to_es.ts b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/send_request_to_es.ts index 102f90a9feb6f..cfbd5691bc22b 100644 --- a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/send_request_to_es.ts +++ b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/send_request_to_es.ts @@ -18,7 +18,7 @@ */ import { extractDeprecationMessages } from '../../../lib/utils'; -import { collapseLiteralStrings } from '../../../../../es_ui_shared/console_lang/lib'; +import { collapseLiteralStrings } from '../../../../../es_ui_shared/public'; // @ts-ignore import * as es from '../../../lib/es/es'; import { BaseResponseType } from '../../../types'; diff --git a/src/plugins/console/public/application/models/legacy_core_editor/__tests__/output_tokenization.test.js b/src/plugins/console/public/application/models/legacy_core_editor/__tests__/output_tokenization.test.js index 6a93d007a6cdc..5c86b0a1d2092 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/__tests__/output_tokenization.test.js +++ b/src/plugins/console/public/application/models/legacy_core_editor/__tests__/output_tokenization.test.js @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import '../legacy_core_editor.test.mocks'; const $ = require('jquery'); import RowParser from '../../../../lib/row_parser'; import ace from 'brace'; diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/input_highlight_rules.js b/src/plugins/console/public/application/models/legacy_core_editor/mode/input_highlight_rules.js index 2c1b30f806f95..29f192f4ea858 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/input_highlight_rules.js +++ b/src/plugins/console/public/application/models/legacy_core_editor/mode/input_highlight_rules.js @@ -18,7 +18,7 @@ */ const ace = require('brace'); -import { addXJsonToRules } from '../../../../../../es_ui_shared/console_lang'; +import { addXJsonToRules } from '../../../../../../es_ui_shared/public'; export function addEOL(tokens, reg, nextIfEOL, normalNext) { if (typeof reg === 'object') { diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.js b/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.js index e27222ebd65e9..c9d538ab6ceb1 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.js +++ b/src/plugins/console/public/application/models/legacy_core_editor/mode/output_highlight_rules.js @@ -19,7 +19,7 @@ const ace = require('brace'); import 'brace/mode/json'; -import { addXJsonToRules } from '../../../../../../es_ui_shared/console_lang'; +import { addXJsonToRules } from '../../../../../../es_ui_shared/public'; const oop = ace.acequire('ace/lib/oop'); const JsonHighlightRules = ace.acequire('ace/mode/json_highlight_rules').JsonHighlightRules; diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/script.js b/src/plugins/console/public/application/models/legacy_core_editor/mode/script.js index 13ae329380221..89adea8921c07 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/script.js +++ b/src/plugins/console/public/application/models/legacy_core_editor/mode/script.js @@ -18,7 +18,7 @@ */ import ace from 'brace'; -import { ScriptHighlightRules } from '../../../../../../es_ui_shared/console_lang'; +import { ScriptHighlightRules } from '../../../../../../es_ui_shared/public'; const oop = ace.acequire('ace/lib/oop'); const TextMode = ace.acequire('ace/mode/text').Mode; diff --git a/src/plugins/console/public/application/models/sense_editor/__tests__/editor_input1.txt b/src/plugins/console/public/application/models/sense_editor/__tests__/editor_input1.txt index f9a4bcb85034d..398a0fdeab61f 100644 --- a/src/plugins/console/public/application/models/sense_editor/__tests__/editor_input1.txt +++ b/src/plugins/console/public/application/models/sense_editor/__tests__/editor_input1.txt @@ -25,3 +25,9 @@ GET index_1/type1/1/_source?_source_include=f DELETE index_2 + +POST /_sql?format=txt +{ + "query": "SELECT prenom FROM claude_index WHERE prenom = 'claude' ", + "fetch_size": 1 +} diff --git a/src/plugins/console/public/application/models/sense_editor/__tests__/sense_editor.test.js b/src/plugins/console/public/application/models/sense_editor/__tests__/sense_editor.test.js index 63f97345bc9ff..34b4cad7fbb6b 100644 --- a/src/plugins/console/public/application/models/sense_editor/__tests__/sense_editor.test.js +++ b/src/plugins/console/public/application/models/sense_editor/__tests__/sense_editor.test.js @@ -22,7 +22,7 @@ import $ from 'jquery'; import _ from 'lodash'; import { create } from '../create'; -import { collapseLiteralStrings } from '../../../../../../es_ui_shared/console_lang/lib'; +import { collapseLiteralStrings } from '../../../../../../es_ui_shared/public'; const editorInput1 = require('./editor_input1.txt'); describe('Editor', () => { @@ -470,6 +470,18 @@ curl -XGET "http://localhost:9200/_stats?level=shards" curl -XPUT "http://localhost:9200/index_1/type1/1" -H 'Content-Type: application/json' -d' { "f": 1 +}'`.trim() + ); + + multiReqCopyAsCurlTest( + 'with single quotes', + editorInput1, + { start: { lineNumber: 29 }, end: { lineNumber: 33 } }, + ` +curl -XPOST "http://localhost:9200/_sql?format=txt" -H 'Content-Type: application/json' -d' +{ + "query": "SELECT prenom FROM claude_index WHERE prenom = '\\''claude'\\'' ", + "fetch_size": 1 }'`.trim() ); }); diff --git a/src/plugins/console/public/application/models/sense_editor/sense_editor.ts b/src/plugins/console/public/application/models/sense_editor/sense_editor.ts index b1444bdf2bbab..d326543bbe00b 100644 --- a/src/plugins/console/public/application/models/sense_editor/sense_editor.ts +++ b/src/plugins/console/public/application/models/sense_editor/sense_editor.ts @@ -19,7 +19,7 @@ import _ from 'lodash'; import RowParser from '../../../lib/row_parser'; -import { collapseLiteralStrings } from '../../../../../es_ui_shared/console_lang/lib'; +import { collapseLiteralStrings } from '../../../../../es_ui_shared/public'; import * as utils from '../../../lib/utils'; // @ts-ignore @@ -484,8 +484,9 @@ export class SenseEditor { if (esData && esData.length) { ret += " -H 'Content-Type: application/json' -d'\n"; const dataAsString = collapseLiteralStrings(esData.join('\n')); - // since Sense doesn't allow single quote json string any single qoute is within a string. - ret += dataAsString.replace(/'/g, '\\"'); + + // We escape single quoted strings that that are wrapped in single quoted strings + ret += dataAsString.replace(/'/g, "'\\''"); if (esData.length > 1) { ret += '\n'; } // end with a new line diff --git a/src/plugins/console/public/lib/utils/index.ts b/src/plugins/console/public/lib/utils/index.ts index f66c952dd3af7..0ebea0f9d4055 100644 --- a/src/plugins/console/public/lib/utils/index.ts +++ b/src/plugins/console/public/lib/utils/index.ts @@ -18,10 +18,7 @@ */ import _ from 'lodash'; -import { - expandLiteralStrings, - collapseLiteralStrings, -} from '../../../../es_ui_shared/console_lang/lib'; +import { expandLiteralStrings, collapseLiteralStrings } from '../../../../es_ui_shared/public'; export function textFromRequest(request: any) { let data = request.data; diff --git a/src/plugins/console/server/lib/spec_definitions/js/ingest.ts b/src/plugins/console/server/lib/spec_definitions/js/ingest.ts index 1182dc075f42f..20dbeda5e0b3d 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/ingest.ts +++ b/src/plugins/console/server/lib/spec_definitions/js/ingest.ts @@ -57,6 +57,49 @@ const bytesProcessorDefinition = { }, }; +// Based on https://www.elastic.co/guide/en/elasticsearch/reference/master/ingest-circle-processor.html +const circleProcessorDefinition = { + circle: { + __template: { + field: '', + error_distance: '', + shape_type: '', + }, + field: '', + target_field: '', + error_distance: '', + shape_type: { + __one_of: ['geo_shape', 'shape'], + }, + ignore_missing: { + __one_of: [false, true], + }, + ...commonPipelineParams, + }, +}; + +// Based on https://www.elastic.co/guide/en/elasticsearch/reference/master/csv-processor.html +const csvProcessorDefinition = { + csv: { + __template: { + field: '', + target_fields: [''], + }, + field: '', + target_fields: [''], + separator: '', + quote: '', + empty_value: '', + trim: { + __one_of: [true, false], + }, + ignore_missing: { + __one_of: [false, true], + }, + ...commonPipelineParams, + }, +}; + // Based on https://www.elastic.co/guide/en/elasticsearch/reference/master/convert-processor.html const convertProcessorDefinition = { convert: { @@ -174,6 +217,25 @@ const foreachProcessorDefinition = { }, }; +// Based on https://www.elastic.co/guide/en/elasticsearch/reference/master/geoip-processor.html +const geoipProcessorDefinition = { + geoip: { + __template: { + field: '', + }, + field: '', + target_field: '', + database_file: '', + properties: [''], + ignore_missing: { + __one_of: [false, true], + }, + first_only: { + __one_of: [false, true], + }, + }, +}; + // Based on https://www.elastic.co/guide/en/elasticsearch/reference/master/grok-processor.html const grokProcessorDefinition = { grok: { @@ -209,6 +271,37 @@ const gsubProcessorDefinition = { }, }; +// Based on https://www.elastic.co/guide/en/elasticsearch/reference/master/htmlstrip-processor.html +const htmlStripProcessorDefinition = { + html_strip: { + __template: { + field: '', + }, + field: '', + target_field: '', + ignore_missing: { + __one_of: [false, true], + }, + ...commonPipelineParams, + }, +}; + +// Based on https://www.elastic.co/guide/en/elasticsearch/reference/master/inference-processor.html +const inferenceProcessorDefinition = { + inference: { + __template: { + model_id: '', + field_map: {}, + inference_config: {}, + }, + model_id: '', + field_map: {}, + inference_config: {}, + target_field: '', + ...commonPipelineParams, + }, +}; + // Based on https://www.elastic.co/guide/en/elasticsearch/reference/master/join-processor.html const joinProcessorDefinition = { join: { @@ -338,6 +431,18 @@ const setProcessorDefinition = { }, }; +// Based on https://www.elastic.co/guide/en/elasticsearch/reference/master/ingest-node-set-security-user-processor.html +const setSecurityUserProcessorDefinition = { + set_security_user: { + __template: { + field: '', + }, + field: '', + properties: [''], + ...commonPipelineParams, + }, +}; + // Based on https://www.elastic.co/guide/en/elasticsearch/reference/master/split-processor.html const splitProcessorDefinition = { split: { @@ -394,10 +499,43 @@ const uppercaseProcessorDefinition = { }, }; +// Based on https://www.elastic.co/guide/en/elasticsearch/reference/master/urldecode-processor.html +const urlDecodeProcessorDefinition = { + urldecode: { + __template: { + field: '', + }, + field: '', + target_field: '', + ignore_missing: { + __one_of: [false, true], + }, + ...commonPipelineParams, + }, +}; + +// Based on https://www.elastic.co/guide/en/elasticsearch/reference/master/user-agent-processor.html +const userAgentProcessorDefinition = { + user_agent: { + __template: { + field: '', + }, + field: '', + target_field: '', + regex_file: '', + properties: [''], + ignore_missing: { + __one_of: [false, true], + }, + }, +}; + const processorDefinition = { __one_of: [ appendProcessorDefinition, bytesProcessorDefinition, + csvProcessorDefinition, + circleProcessorDefinition, convertProcessorDefinition, dateProcessorDefinition, dateIndexNameProcessorDefinition, @@ -406,8 +544,11 @@ const processorDefinition = { dropProcessorDefinition, failProcessorDefinition, foreachProcessorDefinition, + geoipProcessorDefinition, grokProcessorDefinition, gsubProcessorDefinition, + htmlStripProcessorDefinition, + inferenceProcessorDefinition, joinProcessorDefinition, jsonProcessorDefinition, kvProcessorDefinition, @@ -417,10 +558,13 @@ const processorDefinition = { renameProcessorDefinition, scriptProcessorDefinition, setProcessorDefinition, + setSecurityUserProcessorDefinition, splitProcessorDefinition, sortProcessorDefinition, trimProcessorDefinition, uppercaseProcessorDefinition, + urlDecodeProcessorDefinition, + userAgentProcessorDefinition, ], }; diff --git a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap index e71e4f1b15134..7210879c5eacc 100644 --- a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -667,14 +667,13 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` + + + + + + + + + + +`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/intro.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/intro.test.tsx.snap similarity index 90% rename from src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/intro.test.tsx.snap rename to src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/intro.test.tsx.snap index 812031b4b363c..ff179401e6f71 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/intro.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/intro.test.tsx.snap @@ -8,7 +8,7 @@ exports[`Intro component renders correctly 1`] = ` title={ } @@ -37,7 +37,7 @@ exports[`Intro component renders correctly 1`] = ` > Proceed with caution! @@ -53,7 +53,7 @@ exports[`Intro component renders correctly 1`] = `
Modifying objects is for advanced users only. Object properties are not validated and invalid objects could cause errors, data loss, or worse. Unless someone with intimate knowledge of the code told you to be in here, you probably shouldn’t be. diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/not_found_errors.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap similarity index 89% rename from src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/not_found_errors.test.tsx.snap rename to src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap index ac565a000813e..d5372fd5b18d9 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/not_found_errors.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap @@ -10,7 +10,7 @@ exports[`NotFoundErrors component renders correctly for index-pattern type 1`] = title={ } @@ -39,7 +39,7 @@ exports[`NotFoundErrors component renders correctly for index-pattern type 1`] = > There is a problem with this saved object @@ -55,7 +55,7 @@ exports[`NotFoundErrors component renders correctly for index-pattern type 1`] =
The index pattern associated with this object no longer exists. @@ -64,7 +64,7 @@ exports[`NotFoundErrors component renders correctly for index-pattern type 1`] =
If you know what this error means, go ahead and fix it — otherwise click the delete button above. @@ -87,7 +87,7 @@ exports[`NotFoundErrors component renders correctly for index-pattern-field type title={ } @@ -116,7 +116,7 @@ exports[`NotFoundErrors component renders correctly for index-pattern-field type > There is a problem with this saved object @@ -132,7 +132,7 @@ exports[`NotFoundErrors component renders correctly for index-pattern-field type
A field associated with this object no longer exists in the index pattern. @@ -141,7 +141,7 @@ exports[`NotFoundErrors component renders correctly for index-pattern-field type
If you know what this error means, go ahead and fix it — otherwise click the delete button above. @@ -164,7 +164,7 @@ exports[`NotFoundErrors component renders correctly for search type 1`] = ` title={ } @@ -193,7 +193,7 @@ exports[`NotFoundErrors component renders correctly for search type 1`] = ` > There is a problem with this saved object @@ -209,7 +209,7 @@ exports[`NotFoundErrors component renders correctly for search type 1`] = `
The saved search associated with this object no longer exists. @@ -218,7 +218,7 @@ exports[`NotFoundErrors component renders correctly for search type 1`] = `
If you know what this error means, go ahead and fix it — otherwise click the delete button above. @@ -241,7 +241,7 @@ exports[`NotFoundErrors component renders correctly for unknown type 1`] = ` title={ } @@ -270,7 +270,7 @@ exports[`NotFoundErrors component renders correctly for unknown type 1`] = ` > There is a problem with this saved object @@ -287,7 +287,7 @@ exports[`NotFoundErrors component renders correctly for unknown type 1`] = `
If you know what this error means, go ahead and fix it — otherwise click the delete button above. diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/field.test.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/field.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/field.test.tsx rename to src/plugins/saved_objects_management/public/management_section/object_view/components/field.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/field.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/field.tsx similarity index 97% rename from src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/field.tsx rename to src/plugins/saved_objects_management/public/management_section/object_view/components/field.tsx index 1ed0b57e400b8..1b69eb4240d68 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/field.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/field.tsx @@ -104,9 +104,9 @@ export class Field extends PureComponent { id={this.fieldId} label={ !!currentValue ? ( - + ) : ( - + ) } checked={!!currentValue} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/form.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/form.tsx similarity index 89% rename from src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/form.tsx rename to src/plugins/saved_objects_management/public/management_section/object_view/components/form.tsx index 7270d41eef529..04be7ee3ce207 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/form.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/form.tsx @@ -29,15 +29,11 @@ import { import { cloneDeep, set } from 'lodash'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - SimpleSavedObject, - SavedObjectsClientContract, -} from '../../../../../../../../../core/public'; - -import { SavedObjectLoader } from '../../../../../../../../../plugins/saved_objects/public'; +import { SimpleSavedObject, SavedObjectsClientContract } from '../../../../../../core/public'; +import { SavedObjectLoader } from '../../../../../saved_objects/public'; import { Field } from './field'; import { ObjectField, FieldState, SubmittedFormData } from '../../types'; -import { createFieldList } from '../../lib/create_field_list'; +import { createFieldList } from '../../../lib'; interface FormProps { object: SimpleSavedObject; @@ -96,7 +92,7 @@ export class Form extends Component { { data-test-subj="savedObjectEditSave" > @@ -117,14 +113,14 @@ export class Form extends Component { diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/header.test.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/header.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/header.test.tsx rename to src/plugins/saved_objects_management/public/management_section/object_view/components/header.test.tsx diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/header.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/header.tsx new file mode 100644 index 0000000000000..305d953c4990b --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/header.tsx @@ -0,0 +1,110 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiButton, + EuiPageContentHeader, + EuiPageContentHeaderSection, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface HeaderProps { + canEdit: boolean; + canDelete: boolean; + canViewInApp: boolean; + type: string; + viewUrl: string; + onDeleteClick: () => void; +} + +export const Header = ({ + canEdit, + canDelete, + canViewInApp, + type, + viewUrl, + onDeleteClick, +}: HeaderProps) => { + return ( + + + + {canEdit ? ( +

+ +

+ ) : ( +

+ +

+ )} +
+
+ + + {canViewInApp && ( + + + + + + )} + {canDelete && ( + + onDeleteClick()} + data-test-subj="savedObjectEditDelete" + > + + + + )} + + +
+ ); +}; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/index.ts b/src/plugins/saved_objects_management/public/management_section/object_view/components/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/index.ts rename to src/plugins/saved_objects_management/public/management_section/object_view/components/index.ts diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/intro.test.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/intro.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/intro.test.tsx rename to src/plugins/saved_objects_management/public/management_section/object_view/components/intro.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/intro.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/intro.tsx similarity index 92% rename from src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/intro.tsx rename to src/plugins/saved_objects_management/public/management_section/object_view/components/intro.tsx index 098ad71345d49..920a5fcbcb02e 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/intro.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/intro.tsx @@ -26,7 +26,7 @@ export const Intro = () => { } @@ -35,7 +35,7 @@ export const Intro = () => { >
diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/not_found_errors.test.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/not_found_errors.test.tsx rename to src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/not_found_errors.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.tsx similarity index 87% rename from src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/not_found_errors.tsx rename to src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.tsx index c3d18855f6c9a..1a63f7eaf4819 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/not_found_errors.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.tsx @@ -31,21 +31,21 @@ export const NotFoundErrors = ({ type }: NotFoundErrors) => { case 'search': return ( ); case 'index-pattern': return ( ); case 'index-pattern-field': return ( ); @@ -58,7 +58,7 @@ export const NotFoundErrors = ({ type }: NotFoundErrors) => { } @@ -68,7 +68,7 @@ export const NotFoundErrors = ({ type }: NotFoundErrors) => {
{getMessage()}
diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/index.ts b/src/plugins/saved_objects_management/public/management_section/object_view/index.ts new file mode 100644 index 0000000000000..a823923536d31 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/object_view/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 { SavedObjectEdition } from './saved_object_view'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/saved_object_view.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx similarity index 89% rename from src/legacy/core_plugins/kibana/public/management/sections/objects/saved_object_view.tsx rename to src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx index 4984fe3e6d6b8..f714970a5cac3 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/saved_object_view.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx @@ -26,16 +26,16 @@ import { OverlayStart, NotificationsStart, SimpleSavedObject, -} from '../../../../../../../core/public'; -import { ISavedObjectsManagementRegistry } from '../../saved_object_registry'; -import { Header, NotFoundErrors, Intro, Form } from './components/object_view'; -import { canViewInApp } from './lib/in_app_url'; -import { SubmittedFormData } from './types'; +} from '../../../../../core/public'; +import { ISavedObjectsManagementServiceRegistry } from '../../services'; +import { Header, NotFoundErrors, Intro, Form } from './components'; +import { canViewInApp } from '../../lib'; +import { SubmittedFormData } from '../types'; interface SavedObjectEditionProps { id: string; serviceName: string; - serviceRegistry: ISavedObjectsManagementRegistry; + serviceRegistry: ISavedObjectsManagementServiceRegistry; capabilities: Capabilities; overlays: OverlayStart; notifications: NotificationsStart; @@ -135,17 +135,17 @@ export class SavedObjectEdition extends Component< const { type, object } = this.state; const confirmed = await overlays.openConfirm( - i18n.translate('kbn.management.objects.confirmModalOptions.modalDescription', { + i18n.translate('savedObjectsManagement.deleteConfirm.modalDescription', { defaultMessage: 'This action permanently removes the object from Kibana.', }), { confirmButtonText: i18n.translate( - 'kbn.management.objects.confirmModalOptions.deleteButtonLabel', + 'savedObjectsManagement.deleteConfirm.modalDeleteButtonLabel', { defaultMessage: 'Delete', } ), - title: i18n.translate('kbn.management.objects.confirmModalOptions.modalTitle', { + title: i18n.translate('savedObjectsManagement.deleteConfirm.modalTitle', { defaultMessage: `Delete '{title}'?`, values: { title: object?.attributes?.title || 'saved Kibana object', diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap new file mode 100644 index 0000000000000..fe64df6ff51d1 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap @@ -0,0 +1,432 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SavedObjectsTable delete should show a confirm modal 1`] = ` + + } + confirmButtonText={ + + } + defaultFocusedButton="confirm" + onCancel={[Function]} + onConfirm={[Function]} + title={ + + } +> +

+ +

+ +
+`; + +exports[`SavedObjectsTable export should allow the user to choose when exporting all 1`] = ` + + + + + + + + + } + labelType="legend" + > + + + + + } + name="includeReferencesDeep" + onChange={[Function]} + /> + + + + + + + + + + + + + + + + + + + + +`; + +exports[`SavedObjectsTable import should show the flyout 1`] = ` + +`; + +exports[`SavedObjectsTable relationships should show the flyout 1`] = ` + +`; + +exports[`SavedObjectsTable should render normally 1`] = ` + +
+ + + +`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap new file mode 100644 index 0000000000000..4721d166c65e5 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap @@ -0,0 +1,703 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Flyout conflicts should allow conflict resolution 1`] = ` + + + +

+ +

+
+
+ + + + + } + > +

+ + + , + } + } + /> +

+
+
+ +
+ + + + + + + + + + + + + + +
+`; + +exports[`Flyout conflicts should allow conflict resolution 2`] = ` +[MockFunction] { + "calls": Array [ + Array [ + Object { + "getConflictResolutions": [Function], + "http": Object { + "addLoadingCountSource": [MockFunction], + "anonymousPaths": Object { + "isAnonymous": [MockFunction], + "register": [MockFunction], + }, + "basePath": BasePath { + "basePath": "", + "get": [Function], + "prepend": [Function], + "remove": [Function], + "serverBasePath": "", + }, + "delete": [MockFunction], + "fetch": [MockFunction], + "get": [MockFunction], + "getLoadingCount$": [MockFunction], + "head": [MockFunction], + "intercept": [MockFunction], + "options": [MockFunction], + "patch": [MockFunction], + "post": [MockFunction], + "put": [MockFunction], + }, + "state": Object { + "conflictedIndexPatterns": undefined, + "conflictedSavedObjectsLinkedToSavedSearches": undefined, + "conflictedSearchDocs": undefined, + "conflictingRecord": undefined, + "error": undefined, + "failedImports": Array [ + Object { + "error": Object { + "references": Array [ + Object { + "id": "MyIndexPattern*", + "type": "index-pattern", + }, + ], + "type": "missing_references", + }, + "obj": Object { + "id": "1", + "title": "My Visualization", + "type": "visualization", + }, + }, + ], + "file": Object { + "name": "foo.ndjson", + "path": "/home/foo.ndjson", + }, + "importCount": 0, + "indexPatterns": Array [ + Object { + "id": "1", + }, + Object { + "id": "2", + }, + ], + "isLegacyFile": false, + "isOverwriteAllChecked": true, + "loadingMessage": undefined, + "status": "loading", + "unmatchedReferences": Array [ + Object { + "existingIndexPatternId": "MyIndexPattern*", + "list": Array [ + Object { + "id": "1", + "title": "My Visualization", + "type": "visualization", + }, + ], + "newIndexPatternId": "2", + }, + ], + }, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Object { + "failedImports": Array [], + "importCount": 1, + "status": "success", + }, + }, + ], +} +`; + +exports[`Flyout conflicts should handle errors 1`] = ` + + } +> +

+ +

+

+ +`; + +exports[`Flyout errors should display unsupported type errors properly 1`] = ` + + } +> +

+ +

+

+ wigwags [id=1] unsupported type +

+
+`; + +exports[`Flyout legacy conflicts should allow conflict resolution 1`] = ` + + + +

+ +

+
+
+ + + + + } + > +

+ +

+
+
+ + + + } + > +

+ + + , + } + } + /> +

+
+
+ +
+ + + + + + + + + + + + + + +
+`; + +exports[`Flyout legacy conflicts should handle errors 1`] = ` +Array [ + + } + > +

+ +

+
, + + } + > +

+ + + , + } + } + /> +

+
, + + } + > +

+ foobar +

+
, +] +`; + +exports[`Flyout should render import step 1`] = ` + + + +

+ +

+
+
+ + + + } + labelType="label" + > + + } + onChange={[Function]} + /> + + + + } + name="overwriteAll" + onChange={[Function]} + /> + + + + + + + + + + + + + + + + + +
+`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap new file mode 100644 index 0000000000000..642a5030e4ec0 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap @@ -0,0 +1,106 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Header should render normally 1`] = ` + + + + +

+ +

+
+
+ + + + + + + + + + + + + + + + + + + +
+ + +

+ + + +

+
+ +
+`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap new file mode 100644 index 0000000000000..a8bb691cd54e9 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap @@ -0,0 +1,716 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Relationships should render dashboards normally 1`] = ` + + + +

+ + + +    + MyDashboard +

+
+
+ +
+ +

+ Here are the saved objects related to MyDashboard. Deleting this dashboard affects its parent objects, but not its children. +

+
+ + +
+
+
+`; + +exports[`Relationships should render errors 1`] = ` + + + +

+ + + +    + MyDashboard +

+
+
+ + + } + > + foo + + +
+`; + +exports[`Relationships should render index patterns normally 1`] = ` + + + +

+ + + +    + MyIndexPattern* +

+
+
+ +
+ +

+ Here are the saved objects related to MyIndexPattern*. Deleting this index-pattern affects its parent objects, but not its children. +

+
+ + +
+
+
+`; + +exports[`Relationships should render searches normally 1`] = ` + + + +

+ + + +    + MySearch +

+
+
+ +
+ +

+ Here are the saved objects related to MySearch. Deleting this search affects its parent objects, but not its children. +

+
+ + +
+
+
+`; + +exports[`Relationships should render visualizations normally 1`] = ` + + + +

+ + + +    + MyViz +

+
+
+ +
+ +

+ Here are the saved objects related to MyViz. Deleting this visualization affects its parent objects, but not its children. +

+
+ + +
+
+
+`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap new file mode 100644 index 0000000000000..d09dd6f8b868b --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap @@ -0,0 +1,432 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Table prevents saved objects from being deleted 1`] = ` + + + + , + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={false} + panelPaddingSize="m" + > + + } + labelType="label" + > + + } + name="includeReferencesDeep" + onChange={[Function]} + /> + + + + + + + , + ] + } + /> + +
+ +
+
+`; + +exports[`Table should render normally 1`] = ` + + + + , + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={false} + panelPaddingSize="m" + > + + } + labelType="label" + > + + } + name="includeReferencesDeep" + onChange={[Function]} + /> + + + + + + + , + ] + } + /> + +
+ +
+
+`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.mocks.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.mocks.ts new file mode 100644 index 0000000000000..b5361d212954f --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.mocks.ts @@ -0,0 +1,44 @@ +/* + * 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 const importFileMock = jest.fn(); +jest.doMock('../../../lib/import_file', () => ({ + importFile: importFileMock, +})); + +export const resolveImportErrorsMock = jest.fn(); +jest.doMock('../../../lib/resolve_import_errors', () => ({ + resolveImportErrors: resolveImportErrorsMock, +})); + +export const importLegacyFileMock = jest.fn(); +jest.doMock('../../../lib/import_legacy_file', () => ({ + importLegacyFile: importLegacyFileMock, +})); + +export const resolveSavedObjectsMock = jest.fn(); +export const resolveSavedSearchesMock = jest.fn(); +export const resolveIndexPatternConflictsMock = jest.fn(); +export const saveObjectsMock = jest.fn(); +jest.doMock('../../../lib/resolve_saved_objects', () => ({ + resolveSavedObjects: resolveSavedObjectsMock, + resolveSavedSearches: resolveSavedSearchesMock, + resolveIndexPatternConflicts: resolveIndexPatternConflictsMock, + saveObjects: saveObjectsMock, +})); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx new file mode 100644 index 0000000000000..5d713ff044f24 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx @@ -0,0 +1,545 @@ +/* + * 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 { + importFileMock, + importLegacyFileMock, + resolveImportErrorsMock, + resolveIndexPatternConflictsMock, + resolveSavedObjectsMock, + resolveSavedSearchesMock, + saveObjectsMock, +} from './flyout.test.mocks'; + +import React from 'react'; +import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; +import { coreMock } from '../../../../../../core/public/mocks'; +import { serviceRegistryMock } from '../../../services/service_registry.mock'; +import { Flyout, FlyoutProps, FlyoutState } from './flyout'; +import { ShallowWrapper } from 'enzyme'; + +const mockFile = ({ + name: 'foo.ndjson', + path: '/home/foo.ndjson', +} as unknown) as File; +const legacyMockFile = ({ + name: 'foo.json', + path: '/home/foo.json', +} as unknown) as File; + +describe('Flyout', () => { + let defaultProps: FlyoutProps; + + const shallowRender = (props: FlyoutProps) => { + return (shallowWithI18nProvider() as unknown) as ShallowWrapper< + FlyoutProps, + FlyoutState, + Flyout + >; + }; + + beforeEach(() => { + const { http, overlays } = coreMock.createStart(); + + defaultProps = { + close: jest.fn(), + done: jest.fn(), + newIndexPatternUrl: '', + indexPatterns: { + getFields: jest.fn().mockImplementation(() => [{ id: '1' }, { id: '2' }]), + } as any, + overlays, + http, + allowedTypes: ['search', 'index-pattern', 'visualization'], + serviceRegistry: serviceRegistryMock.create(), + }; + }); + + it('should render import step', async () => { + const component = shallowRender(defaultProps); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + + it('should toggle the overwrite all control', async () => { + const component = shallowRender(defaultProps); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component.state('isOverwriteAllChecked')).toBe(true); + component.find('EuiSwitch').simulate('change'); + expect(component.state('isOverwriteAllChecked')).toBe(false); + }); + + it('should allow picking a file', async () => { + const component = shallowRender(defaultProps); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component.state('file')).toBe(undefined); + component.find('EuiFilePicker').simulate('change', [mockFile]); + expect(component.state('file')).toBe(mockFile); + }); + + it('should allow removing a file', async () => { + const component = shallowRender(defaultProps); + + // Ensure all promises resolve + await Promise.resolve(); + // Ensure the state changes are reflected + component.update(); + + expect(component.state('file')).toBe(undefined); + component.find('EuiFilePicker').simulate('change', [mockFile]); + expect(component.state('file')).toBe(mockFile); + component.find('EuiFilePicker').simulate('change', []); + expect(component.state('file')).toBe(undefined); + }); + + it('should handle invalid files', async () => { + const component = shallowRender(defaultProps); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + importLegacyFileMock.mockImplementation(() => { + throw new Error('foobar'); + }); + + await component.instance().legacyImport(); + expect(component.state('error')).toBe('The file could not be processed.'); + + importLegacyFileMock.mockImplementation(() => ({ + invalid: true, + })); + + await component.instance().legacyImport(); + expect(component.state('error')).toBe( + 'Saved objects file format is invalid and cannot be imported.' + ); + }); + + describe('conflicts', () => { + beforeEach(() => { + importFileMock.mockImplementation(() => ({ + success: false, + successCount: 0, + errors: [ + { + id: '1', + type: 'visualization', + title: 'My Visualization', + error: { + type: 'missing_references', + references: [ + { + id: 'MyIndexPattern*', + type: 'index-pattern', + }, + ], + }, + }, + ], + })); + resolveImportErrorsMock.mockImplementation(() => ({ + status: 'success', + importCount: 1, + failedImports: [], + })); + }); + + it('should figure out unmatchedReferences', async () => { + const component = shallowRender(defaultProps); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + component.setState({ file: mockFile, isLegacyFile: false }); + await component.instance().import(); + + expect(importFileMock).toHaveBeenCalledWith(defaultProps.http, mockFile, true); + expect(component.state()).toMatchObject({ + conflictedIndexPatterns: undefined, + conflictedSavedObjectsLinkedToSavedSearches: undefined, + conflictedSearchDocs: undefined, + importCount: 0, + status: 'idle', + error: undefined, + unmatchedReferences: [ + { + existingIndexPatternId: 'MyIndexPattern*', + newIndexPatternId: undefined, + list: [ + { + id: '1', + type: 'visualization', + title: 'My Visualization', + }, + ], + }, + ], + }); + }); + + it('should allow conflict resolution', async () => { + const component = shallowRender(defaultProps); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + component.setState({ file: mockFile, isLegacyFile: false }); + await component.instance().import(); + + // Ensure it looks right + component.update(); + expect(component).toMatchSnapshot(); + + // Ensure we can change the resolution + component.instance().onIndexChanged('MyIndexPattern*', { target: { value: '2' } }); + expect(component.state('unmatchedReferences')![0].newIndexPatternId).toBe('2'); + + // Let's resolve now + await component + .find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]') + .simulate('click'); + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + expect(resolveImportErrorsMock).toMatchSnapshot(); + }); + + it('should handle errors', async () => { + const component = shallowRender(defaultProps); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + resolveImportErrorsMock.mockImplementation(() => ({ + status: 'success', + importCount: 0, + failedImports: [ + { + obj: { + type: 'visualization', + id: '1', + }, + error: { + type: 'unknown', + }, + }, + ], + })); + + component.setState({ file: mockFile, isLegacyFile: false }); + + // Go through the import flow + await component.instance().import(); + component.update(); + // Set a resolution + component.instance().onIndexChanged('MyIndexPattern*', { target: { value: '2' } }); + await component + .find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]') + .simulate('click'); + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + + expect(component.state('failedImports')).toEqual([ + { + error: { + type: 'unknown', + }, + obj: { + id: '1', + type: 'visualization', + }, + }, + ]); + expect(component.find('EuiFlyoutBody EuiCallOut')).toMatchSnapshot(); + }); + }); + + describe('errors', () => { + it('should display unsupported type errors properly', async () => { + const component = shallowRender(defaultProps); + + // Ensure all promises resolve + await Promise.resolve(); + // Ensure the state changes are reflected + component.update(); + + importFileMock.mockImplementation(() => ({ + success: false, + successCount: 0, + errors: [ + { + id: '1', + type: 'wigwags', + title: 'My Title', + error: { + type: 'unsupported_type', + }, + }, + ], + })); + resolveImportErrorsMock.mockImplementation(() => ({ + status: 'success', + importCount: 0, + failedImports: [ + { + error: { + type: 'unsupported_type', + }, + obj: { + id: '1', + type: 'wigwags', + title: 'My Title', + }, + }, + ], + })); + + component.setState({ file: mockFile, isLegacyFile: false }); + + // Go through the import flow + await component.instance().import(); + component.update(); + + // Ensure all promises resolve + await Promise.resolve(); + + expect(component.state('status')).toBe('success'); + expect(component.state('failedImports')).toEqual([ + { + error: { + type: 'unsupported_type', + }, + obj: { + id: '1', + type: 'wigwags', + title: 'My Title', + }, + }, + ]); + expect(component.find('EuiFlyout EuiCallOut')).toMatchSnapshot(); + }); + }); + + describe('legacy conflicts', () => { + const mockData = [ + { + _id: '1', + _type: 'search', + }, + { + _id: '2', + _type: 'index-pattern', + }, + { + _id: '3', + _type: 'invalid', + }, + ]; + + const mockConflictedIndexPatterns = [ + { + doc: { + _type: 'index-pattern', + _id: '1', + _source: { + title: 'MyIndexPattern*', + }, + }, + obj: { + searchSource: { + getOwnField: (field: string) => { + if (field === 'index') { + return 'MyIndexPattern*'; + } + if (field === 'filter') { + return [{ meta: { index: 'filterIndex' } }]; + } + }, + }, + _serialize: () => { + return { references: [{ id: 'MyIndexPattern*' }, { id: 'filterIndex' }] }; + }, + }, + }, + ]; + + const mockConflictedSavedObjectsLinkedToSavedSearches = [2]; + const mockConflictedSearchDocs = [3]; + + beforeEach(() => { + importLegacyFileMock.mockImplementation(() => mockData); + resolveSavedObjectsMock.mockImplementation(() => ({ + conflictedIndexPatterns: mockConflictedIndexPatterns, + conflictedSavedObjectsLinkedToSavedSearches: mockConflictedSavedObjectsLinkedToSavedSearches, + conflictedSearchDocs: mockConflictedSearchDocs, + importedObjectCount: 2, + confirmModalPromise: () => {}, + })); + }); + + it('should figure out unmatchedReferences', async () => { + const component = shallowRender(defaultProps); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + component.setState({ file: legacyMockFile, isLegacyFile: true }); + await component.instance().legacyImport(); + + expect(importLegacyFileMock).toHaveBeenCalledWith(legacyMockFile); + // Remove the last element from data since it should be filtered out + expect(resolveSavedObjectsMock).toHaveBeenCalledWith( + mockData.slice(0, 2).map(doc => ({ ...doc, _migrationVersion: {} })), + true, + defaultProps.serviceRegistry.all().map(s => s.service), + defaultProps.indexPatterns, + defaultProps.overlays.openConfirm + ); + + expect(component.state()).toMatchObject({ + conflictedIndexPatterns: mockConflictedIndexPatterns, + conflictedSavedObjectsLinkedToSavedSearches: mockConflictedSavedObjectsLinkedToSavedSearches, + conflictedSearchDocs: mockConflictedSearchDocs, + importCount: 2, + status: 'idle', + error: undefined, + unmatchedReferences: [ + { + existingIndexPatternId: 'MyIndexPattern*', + newIndexPatternId: undefined, + list: [ + { + id: 'MyIndexPattern*', + title: 'MyIndexPattern*', + type: 'index-pattern', + }, + ], + }, + { + existingIndexPatternId: 'filterIndex', + list: [ + { + id: 'filterIndex', + title: 'MyIndexPattern*', + type: 'index-pattern', + }, + ], + newIndexPatternId: undefined, + }, + ], + }); + }); + + it('should allow conflict resolution', async () => { + const component = shallowRender(defaultProps); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + component.setState({ file: legacyMockFile, isLegacyFile: true }); + await component.instance().legacyImport(); + + // Ensure it looks right + component.update(); + expect(component).toMatchSnapshot(); + + // Ensure we can change the resolution + component.instance().onIndexChanged('MyIndexPattern*', { target: { value: '2' } }); + expect(component.state('unmatchedReferences')![0].newIndexPatternId).toBe('2'); + + // Let's resolve now + await component + .find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]') + .simulate('click'); + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + expect(resolveIndexPatternConflictsMock).toHaveBeenCalledWith( + component.instance().resolutions, + mockConflictedIndexPatterns, + true, + defaultProps.indexPatterns + ); + expect(saveObjectsMock).toHaveBeenCalledWith( + mockConflictedSavedObjectsLinkedToSavedSearches, + true + ); + expect(resolveSavedSearchesMock).toHaveBeenCalledWith( + mockConflictedSearchDocs, + defaultProps.serviceRegistry.all().map(s => s.service), + defaultProps.indexPatterns, + true + ); + }); + + it('should handle errors', async () => { + const component = shallowRender(defaultProps); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + resolveIndexPatternConflictsMock.mockImplementation(() => { + throw new Error('foobar'); + }); + + component.setState({ file: legacyMockFile, isLegacyFile: true }); + + // Go through the import flow + await component.instance().legacyImport(); + component.update(); + // Set a resolution + component.instance().onIndexChanged('MyIndexPattern*', { target: { value: '2' } }); + await component + .find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]') + .simulate('click'); + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + + expect(component.state('error')).toEqual('foobar'); + expect(component.find('EuiFlyoutBody EuiCallOut')).toMatchSnapshot(); + }); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx new file mode 100644 index 0000000000000..45788dcb601ae --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx @@ -0,0 +1,981 @@ +/* + * 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, { Component, Fragment } from 'react'; +import { take, get as getField } from 'lodash'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiButtonEmpty, + EuiButton, + EuiText, + EuiTitle, + EuiForm, + EuiFormRow, + EuiSwitch, + // @ts-ignore + EuiFilePicker, + EuiInMemoryTable, + EuiSelect, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingKibana, + EuiCallOut, + EuiSpacer, + EuiLink, + EuiConfirmModal, + EuiOverlayMask, + EUI_MODAL_CONFIRM_BUTTON, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { OverlayStart, HttpStart } from 'src/core/public'; +import { IndexPatternsContract, IIndexPattern } from '../../../../../data/public'; +import { + importFile, + importLegacyFile, + resolveImportErrors, + logLegacyImport, + getDefaultTitle, + processImportResponse, + ProcessedImportResponse, +} from '../../../lib'; +import { + resolveSavedObjects, + resolveSavedSearches, + resolveIndexPatternConflicts, + saveObjects, +} from '../../../lib/resolve_saved_objects'; +import { ISavedObjectsManagementServiceRegistry } from '../../../services'; + +export interface FlyoutProps { + serviceRegistry: ISavedObjectsManagementServiceRegistry; + allowedTypes: string[]; + close: () => void; + done: () => void; + newIndexPatternUrl: string; + indexPatterns: IndexPatternsContract; + overlays: OverlayStart; + http: HttpStart; +} + +export interface FlyoutState { + conflictedIndexPatterns?: any[]; + conflictedSavedObjectsLinkedToSavedSearches?: any[]; + conflictedSearchDocs?: any[]; + unmatchedReferences?: ProcessedImportResponse['unmatchedReferences']; + failedImports?: ProcessedImportResponse['failedImports']; + conflictingRecord?: ConflictingRecord; + error?: string; + file?: File; + importCount: number; + indexPatterns?: IIndexPattern[]; + isOverwriteAllChecked: boolean; + loadingMessage?: string; + isLegacyFile: boolean; + status: string; +} + +interface ConflictingRecord { + id: string; + type: string; + title: string; + done: (success: boolean) => void; +} + +export class Flyout extends Component { + constructor(props: FlyoutProps) { + super(props); + + this.state = { + conflictedIndexPatterns: undefined, + conflictedSavedObjectsLinkedToSavedSearches: undefined, + conflictedSearchDocs: undefined, + unmatchedReferences: undefined, + conflictingRecord: undefined, + error: undefined, + file: undefined, + importCount: 0, + indexPatterns: undefined, + isOverwriteAllChecked: true, + loadingMessage: undefined, + isLegacyFile: false, + status: 'idle', + }; + } + + componentDidMount() { + this.fetchIndexPatterns(); + } + + fetchIndexPatterns = async () => { + const indexPatterns = await this.props.indexPatterns.getFields(['id', 'title']); + this.setState({ indexPatterns } as any); + }; + + changeOverwriteAll = () => { + this.setState(state => ({ + isOverwriteAllChecked: !state.isOverwriteAllChecked, + })); + }; + + setImportFile = (files: FileList | null) => { + if (!files || !files[0]) { + this.setState({ file: undefined, isLegacyFile: false }); + return; + } + const file = files[0]; + this.setState({ + file, + isLegacyFile: /\.json$/i.test(file.name) || file.type === 'application/json', + }); + }; + + /** + * Import + * + * Does the initial import of a file, resolveImportErrors then handles errors and retries + */ + import = async () => { + const { http } = this.props; + const { file, isOverwriteAllChecked } = this.state; + this.setState({ status: 'loading', error: undefined }); + + // Import the file + try { + const response = await importFile(http, file!, isOverwriteAllChecked); + this.setState(processImportResponse(response), () => { + // Resolve import errors right away if there's no index patterns to match + // This will ask about overwriting each object, etc + if (this.state.unmatchedReferences?.length === 0) { + this.resolveImportErrors(); + } + }); + } catch (e) { + this.setState({ + status: 'error', + error: i18n.translate('savedObjectsManagement.objectsTable.flyout.importFileErrorMessage', { + defaultMessage: 'The file could not be processed.', + }), + }); + return; + } + }; + + /** + * Get Conflict Resolutions + * + * Function iterates through the objects, displays a modal for each asking the user if they wish to overwrite it or not. + * + * @param {array} objects List of objects to request the user if they wish to overwrite it + * @return {Promise} An object with the key being "type:id" and value the resolution chosen by the user + */ + getConflictResolutions = async (objects: any[]) => { + const resolutions: Record = {}; + for (const { type, id, title } of objects) { + const overwrite = await new Promise(resolve => { + this.setState({ + conflictingRecord: { + id, + type, + title, + done: resolve, + }, + }); + }); + resolutions[`${type}:${id}`] = overwrite; + this.setState({ conflictingRecord: undefined }); + } + return resolutions; + }; + + /** + * Resolve Import Errors + * + * Function goes through the failedImports and tries to resolve the issues. + */ + resolveImportErrors = async () => { + this.setState({ + error: undefined, + status: 'loading', + loadingMessage: undefined, + }); + + try { + const updatedState = await resolveImportErrors({ + http: this.props.http, + state: this.state, + getConflictResolutions: this.getConflictResolutions, + }); + this.setState(updatedState); + } catch (e) { + this.setState({ + status: 'error', + error: i18n.translate( + 'savedObjectsManagement.objectsTable.flyout.resolveImportErrorsFileErrorMessage', + { defaultMessage: 'The file could not be processed.' } + ), + }); + } + }; + + legacyImport = async () => { + const { serviceRegistry, indexPatterns, overlays, http, allowedTypes } = this.props; + const { file, isOverwriteAllChecked } = this.state; + + this.setState({ status: 'loading', error: undefined }); + + // Log warning on server, don't wait for response + logLegacyImport(http); + + let contents; + try { + contents = await importLegacyFile(file!); + } catch (e) { + this.setState({ + status: 'error', + error: i18n.translate( + 'savedObjectsManagement.objectsTable.flyout.importLegacyFileErrorMessage', + { defaultMessage: 'The file could not be processed.' } + ), + }); + return; + } + + if (!Array.isArray(contents)) { + this.setState({ + status: 'error', + error: i18n.translate( + 'savedObjectsManagement.objectsTable.flyout.invalidFormatOfImportedFileErrorMessage', + { defaultMessage: 'Saved objects file format is invalid and cannot be imported.' } + ), + }); + return; + } + + contents = contents + .filter(content => allowedTypes.includes(content._type)) + .map(doc => ({ + ...doc, + // The server assumes that documents with no migrationVersion are up to date. + // That assumption enables Kibana and other API consumers to not have to build + // up migrationVersion prior to creating new objects. But it means that imports + // need to set migrationVersion to something other than undefined, so that imported + // docs are not seen as automatically up-to-date. + _migrationVersion: doc._migrationVersion || {}, + })); + + const { + conflictedIndexPatterns, + conflictedSavedObjectsLinkedToSavedSearches, + conflictedSearchDocs, + importedObjectCount, + failedImports, + } = await resolveSavedObjects( + contents, + isOverwriteAllChecked, + serviceRegistry.all().map(e => e.service), + indexPatterns, + overlays.openConfirm + ); + + const byId: Record = {}; + conflictedIndexPatterns + .map(({ doc, obj }) => { + return { doc, obj: obj._serialize() }; + }) + .forEach(({ doc, obj }) => + obj.references.forEach((ref: Record) => { + byId[ref.id] = byId[ref.id] != null ? byId[ref.id].concat({ doc, obj }) : [{ doc, obj }]; + }) + ); + const unmatchedReferences = Object.entries(byId).reduce( + (accum, [existingIndexPatternId, list]) => { + accum.push({ + existingIndexPatternId, + newIndexPatternId: undefined, + list: list.map(({ doc }) => ({ + id: existingIndexPatternId, + type: doc._type, + title: doc._source.title, + })), + }); + return accum; + }, + [] as any[] + ); + + this.setState({ + conflictedIndexPatterns, + conflictedSavedObjectsLinkedToSavedSearches, + conflictedSearchDocs, + failedImports, + unmatchedReferences, + importCount: importedObjectCount, + status: unmatchedReferences.length === 0 ? 'success' : 'idle', + }); + }; + + public get hasUnmatchedReferences() { + return this.state.unmatchedReferences && this.state.unmatchedReferences.length > 0; + } + + public get resolutions() { + return this.state.unmatchedReferences!.reduce( + (accum, { existingIndexPatternId, newIndexPatternId }) => { + if (newIndexPatternId) { + accum.push({ + oldId: existingIndexPatternId, + newId: newIndexPatternId, + }); + } + return accum; + }, + [] as Array<{ oldId: string; newId: string }> + ); + } + + confirmLegacyImport = async () => { + const { + conflictedIndexPatterns, + isOverwriteAllChecked, + conflictedSavedObjectsLinkedToSavedSearches, + conflictedSearchDocs, + failedImports, + } = this.state; + + const { serviceRegistry, indexPatterns } = this.props; + + this.setState({ + error: undefined, + status: 'loading', + loadingMessage: undefined, + }); + + let importCount = this.state.importCount; + + if (this.hasUnmatchedReferences) { + try { + const resolutions = this.resolutions; + + // Do not Promise.all these calls as the order matters + this.setState({ + loadingMessage: i18n.translate( + 'savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.resolvingConflictsLoadingMessage', + { defaultMessage: 'Resolving conflicts…' } + ), + }); + if (resolutions.length) { + importCount += await resolveIndexPatternConflicts( + resolutions, + conflictedIndexPatterns!, + isOverwriteAllChecked, + indexPatterns + ); + } + this.setState({ + loadingMessage: i18n.translate( + 'savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savingConflictsLoadingMessage', + { defaultMessage: 'Saving conflicts…' } + ), + }); + importCount += await saveObjects( + conflictedSavedObjectsLinkedToSavedSearches!, + isOverwriteAllChecked + ); + this.setState({ + loadingMessage: i18n.translate( + 'savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savedSearchAreLinkedProperlyLoadingMessage', + { defaultMessage: 'Ensure saved searches are linked properly…' } + ), + }); + importCount += await resolveSavedSearches( + conflictedSearchDocs!, + serviceRegistry.all().map(e => e.service), + indexPatterns, + isOverwriteAllChecked + ); + this.setState({ + loadingMessage: i18n.translate( + 'savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.retryingFailedObjectsLoadingMessage', + { defaultMessage: 'Retrying failed objects…' } + ), + }); + importCount += await saveObjects( + failedImports!.map(({ obj }) => obj) as any[], + isOverwriteAllChecked + ); + } catch (e) { + this.setState({ + error: e.message, + status: 'error', + loadingMessage: undefined, + }); + return; + } + } + + this.setState({ status: 'success', importCount }); + }; + + onIndexChanged = (id: string, e: any) => { + const value = e.target.value; + this.setState(state => { + const conflictIndex = state.unmatchedReferences?.findIndex( + conflict => conflict.existingIndexPatternId === id + ); + if (conflictIndex === undefined || conflictIndex === -1) { + return state; + } + + return { + unmatchedReferences: [ + ...state.unmatchedReferences!.slice(0, conflictIndex), + { + ...state.unmatchedReferences![conflictIndex], + newIndexPatternId: value, + }, + ...state.unmatchedReferences!.slice(conflictIndex + 1), + ], + } as any; + }); + }; + + renderUnmatchedReferences() { + const { unmatchedReferences } = this.state; + + if (!unmatchedReferences) { + return null; + } + + const columns = [ + { + field: 'existingIndexPatternId', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdName', + { defaultMessage: 'ID' } + ), + description: i18n.translate( + 'savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdDescription', + { defaultMessage: 'ID of the index pattern' } + ), + sortable: true, + }, + { + field: 'list', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountName', + { defaultMessage: 'Count' } + ), + description: i18n.translate( + 'savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountDescription', + { defaultMessage: 'How many affected objects' } + ), + render: (list: any[]) => { + return {list.length}; + }, + }, + { + field: 'list', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsName', + { defaultMessage: 'Sample of affected objects' } + ), + description: i18n.translate( + 'savedObjectsManagement.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsDescription', + { defaultMessage: 'Sample of affected objects' } + ), + render: (list: any[]) => { + return ( +
    + {take(list, 3).map((obj, key) => ( +
  • {obj.title}
  • + ))} +
+ ); + }, + }, + { + field: 'existingIndexPatternId', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.flyout.renderConflicts.columnNewIndexPatternName', + { defaultMessage: 'New index pattern' } + ), + render: (id: string) => { + const options = this.state.indexPatterns!.map( + indexPattern => + ({ + text: indexPattern.title, + value: indexPattern.id, + 'data-test-subj': `indexPatternOption-${indexPattern.title}`, + } as { text: string; value: string; 'data-test-subj'?: string }) + ); + + options.unshift({ + text: '-- Skip Import --', + value: '', + }); + + return ( + this.onIndexChanged(id, e)} + options={options} + /> + ); + }, + }, + ]; + + const pagination = { + pageSizeOptions: [5, 10, 25], + }; + + return ( + + ); + } + + renderError() { + const { error, status } = this.state; + + if (status !== 'error') { + return null; + } + + return ( + + + } + color="danger" + > +

{error}

+
+ +
+ ); + } + + renderBody() { + const { + status, + loadingMessage, + isOverwriteAllChecked, + importCount, + failedImports = [], + isLegacyFile, + } = this.state; + + if (status === 'loading') { + return ( + + + + + +

{loadingMessage}

+
+
+
+ ); + } + + // Kept backwards compatible logic + if ( + failedImports.length && + (!this.hasUnmatchedReferences || (isLegacyFile === false && status === 'success')) + ) { + return ( + + } + color="warning" + iconType="help" + > +

+ +

+

+ {failedImports + .map(({ error, obj }) => { + if (error.type === 'missing_references') { + return error.references.map(reference => { + return i18n.translate( + 'savedObjectsManagement.objectsTable.flyout.importFailedMissingReference', + { + defaultMessage: '{type} [id={id}] could not locate {refType} [id={refId}]', + values: { + id: obj.id, + type: obj.type, + refId: reference.id, + refType: reference.type, + }, + } + ); + }); + } else if (error.type === 'unsupported_type') { + return i18n.translate( + 'savedObjectsManagement.objectsTable.flyout.importFailedUnsupportedType', + { + defaultMessage: '{type} [id={id}] unsupported type', + values: { + id: obj.id, + type: obj.type, + }, + } + ); + } + return getField(error, 'body.message', (error as any).message ?? ''); + }) + .join(' ')} +

+
+ ); + } + + if (status === 'success') { + if (importCount === 0) { + return ( + + } + color="primary" + /> + ); + } + + return ( + + } + color="success" + iconType="check" + > +

+ +

+
+ ); + } + + if (this.hasUnmatchedReferences) { + return this.renderUnmatchedReferences(); + } + + return ( + + + } + > + + } + onChange={this.setImportFile} + /> + + + + } + data-test-subj="importSavedObjectsOverwriteToggle" + checked={isOverwriteAllChecked} + onChange={this.changeOverwriteAll} + /> + + + ); + } + + renderFooter() { + const { status } = this.state; + const { done, close } = this.props; + + let confirmButton; + + if (status === 'success') { + confirmButton = ( + + + + ); + } else if (this.hasUnmatchedReferences) { + confirmButton = ( + + + + ); + } else { + confirmButton = ( + + + + ); + } + + return ( + + + + + + + {confirmButton} + + ); + } + + renderSubheader() { + if (this.state.status === 'loading' || this.state.status === 'success') { + return null; + } + + let legacyFileWarning; + if (this.state.isLegacyFile) { + legacyFileWarning = ( + + } + color="warning" + iconType="help" + > +

+ +

+
+ ); + } + + let indexPatternConflictsWarning; + if (this.hasUnmatchedReferences) { + indexPatternConflictsWarning = ( + + } + color="warning" + iconType="help" + > +

+ + + + ), + }} + /> +

+
+ ); + } + + if (!legacyFileWarning && !indexPatternConflictsWarning) { + return null; + } + + return ( + + {legacyFileWarning && ( + + + {legacyFileWarning} + + )} + {indexPatternConflictsWarning && ( + + + {indexPatternConflictsWarning} + + )} + + ); + } + + overwriteConfirmed() { + this.state.conflictingRecord!.done(true); + } + + overwriteSkipped() { + this.state.conflictingRecord!.done(false); + } + + render() { + const { close } = this.props; + + let confirmOverwriteModal; + if (this.state.conflictingRecord) { + confirmOverwriteModal = ( + + +

+ +

+
+
+ ); + } + + return ( + + + +

+ +

+
+
+ + + {this.renderSubheader()} + {this.renderError()} + {this.renderBody()} + + + {this.renderFooter()} + {confirmOverwriteModal} +
+ ); + } +} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.test.tsx new file mode 100644 index 0000000000000..891190d0bb24b --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; +import { Header } from './header'; + +describe('Header', () => { + it('should render normally', () => { + const props = { + onExportAll: () => {}, + onImport: () => {}, + onRefresh: () => {}, + totalCount: 4, + filteredCount: 2, + }; + + const component = shallow(
); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx new file mode 100644 index 0000000000000..7a9584f08d632 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx @@ -0,0 +1,114 @@ +/* + * 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, { Fragment } from 'react'; +import { + EuiSpacer, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiTextColor, + EuiButtonEmpty, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const Header = ({ + onExportAll, + onImport, + onRefresh, + filteredCount, +}: { + onExportAll: () => void; + onImport: () => void; + onRefresh: () => void; + filteredCount: number; +}) => ( + + + + +

+ +

+
+
+ + + + + + + + + + + + + + + + + + + + +
+ + +

+ + + +

+
+ +
+); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/index.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/components/index.ts new file mode 100644 index 0000000000000..9c8736a9011eb --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/index.ts @@ -0,0 +1,23 @@ +/* + * 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 { Header } from './header'; +export { Table } from './table'; +export { Flyout } from './flyout'; +export { Relationships } from './relationships'; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx new file mode 100644 index 0000000000000..347f2d977015c --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx @@ -0,0 +1,338 @@ +/* + * 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 { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; +import { httpServiceMock } from '../../../../../../core/public/mocks'; +import { Relationships, RelationshipsProps } from './relationships'; + +jest.mock('../../../lib/fetch_export_by_type_and_search', () => ({ + fetchExportByTypeAndSearch: jest.fn(), +})); + +jest.mock('../../../lib/fetch_export_objects', () => ({ + fetchExportObjects: jest.fn(), +})); + +describe('Relationships', () => { + it('should render index patterns normally', async () => { + const props: RelationshipsProps = { + goInspectObject: () => {}, + canGoInApp: () => true, + basePath: httpServiceMock.createSetupContract().basePath, + getRelationships: jest.fn().mockImplementation(() => [ + { + type: 'search', + id: '1', + relationship: 'parent', + meta: { + editUrl: '/management/kibana/objects/savedSearches/1', + icon: 'search', + inAppUrl: { + path: '/app/kibana#/discover/1', + uiCapabilitiesPath: 'discover.show', + }, + title: 'My Search Title', + }, + }, + { + type: 'visualization', + id: '2', + relationship: 'parent', + meta: { + editUrl: '/management/kibana/objects/savedVisualizations/2', + icon: 'visualizeApp', + inAppUrl: { + path: '/app/kibana#/visualize/edit/2', + uiCapabilitiesPath: 'visualize.show', + }, + title: 'My Visualization Title', + }, + }, + ]), + savedObject: { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + meta: { + title: 'MyIndexPattern*', + icon: 'indexPatternApp', + editUrl: '#/management/kibana/index_patterns/1', + inAppUrl: { + path: '/management/kibana/index_patterns/1', + uiCapabilitiesPath: 'management.kibana.index_patterns', + }, + }, + }, + close: jest.fn(), + }; + + const component = shallowWithI18nProvider(); + + // Make sure we are showing loading + expect(component.find('EuiLoadingKibana').length).toBe(1); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(props.getRelationships).toHaveBeenCalled(); + expect(component).toMatchSnapshot(); + }); + + it('should render searches normally', async () => { + const props: RelationshipsProps = { + goInspectObject: () => {}, + canGoInApp: () => true, + basePath: httpServiceMock.createSetupContract().basePath, + getRelationships: jest.fn().mockImplementation(() => [ + { + type: 'index-pattern', + id: '1', + relationship: 'child', + meta: { + editUrl: '/management/kibana/index_patterns/1', + icon: 'indexPatternApp', + inAppUrl: { + path: '/app/kibana#/management/kibana/index_patterns/1', + uiCapabilitiesPath: 'management.kibana.index_patterns', + }, + title: 'My Index Pattern', + }, + }, + { + type: 'visualization', + id: '2', + relationship: 'parent', + meta: { + editUrl: '/management/kibana/objects/savedVisualizations/2', + icon: 'visualizeApp', + inAppUrl: { + path: '/app/kibana#/visualize/edit/2', + uiCapabilitiesPath: 'visualize.show', + }, + title: 'My Visualization Title', + }, + }, + ]), + savedObject: { + id: '1', + type: 'search', + attributes: {}, + references: [], + meta: { + title: 'MySearch', + icon: 'search', + editUrl: '#/management/kibana/objects/savedSearches/1', + inAppUrl: { + path: '/discover/1', + uiCapabilitiesPath: 'discover.show', + }, + }, + }, + close: jest.fn(), + }; + + const component = shallowWithI18nProvider(); + + // Make sure we are showing loading + expect(component.find('EuiLoadingKibana').length).toBe(1); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(props.getRelationships).toHaveBeenCalled(); + expect(component).toMatchSnapshot(); + }); + + it('should render visualizations normally', async () => { + const props: RelationshipsProps = { + goInspectObject: () => {}, + canGoInApp: () => true, + basePath: httpServiceMock.createSetupContract().basePath, + getRelationships: jest.fn().mockImplementation(() => [ + { + type: 'dashboard', + id: '1', + relationship: 'parent', + meta: { + editUrl: '/management/kibana/objects/savedDashboards/1', + icon: 'dashboardApp', + inAppUrl: { + path: '/app/kibana#/dashboard/1', + uiCapabilitiesPath: 'dashboard.show', + }, + title: 'My Dashboard 1', + }, + }, + { + type: 'dashboard', + id: '2', + relationship: 'parent', + meta: { + editUrl: '/management/kibana/objects/savedDashboards/2', + icon: 'dashboardApp', + inAppUrl: { + path: '/app/kibana#/dashboard/2', + uiCapabilitiesPath: 'dashboard.show', + }, + title: 'My Dashboard 2', + }, + }, + ]), + savedObject: { + id: '1', + type: 'visualization', + attributes: {}, + references: [], + meta: { + title: 'MyViz', + icon: 'visualizeApp', + editUrl: '#/management/kibana/objects/savedVisualizations/1', + inAppUrl: { + path: '/visualize/edit/1', + uiCapabilitiesPath: 'visualize.show', + }, + }, + }, + close: jest.fn(), + }; + + const component = shallowWithI18nProvider(); + + // Make sure we are showing loading + expect(component.find('EuiLoadingKibana').length).toBe(1); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(props.getRelationships).toHaveBeenCalled(); + expect(component).toMatchSnapshot(); + }); + + it('should render dashboards normally', async () => { + const props: RelationshipsProps = { + goInspectObject: () => {}, + canGoInApp: () => true, + basePath: httpServiceMock.createSetupContract().basePath, + getRelationships: jest.fn().mockImplementation(() => [ + { + type: 'visualization', + id: '1', + relationship: 'child', + meta: { + editUrl: '/management/kibana/objects/savedVisualizations/1', + icon: 'visualizeApp', + inAppUrl: { + path: '/app/kibana#/visualize/edit/1', + uiCapabilitiesPath: 'visualize.show', + }, + title: 'My Visualization Title 1', + }, + }, + { + type: 'visualization', + id: '2', + relationship: 'child', + meta: { + editUrl: '/management/kibana/objects/savedVisualizations/2', + icon: 'visualizeApp', + inAppUrl: { + path: '/app/kibana#/visualize/edit/2', + uiCapabilitiesPath: 'visualize.show', + }, + title: 'My Visualization Title 2', + }, + }, + ]), + savedObject: { + id: '1', + type: 'dashboard', + attributes: {}, + references: [], + meta: { + title: 'MyDashboard', + icon: 'dashboardApp', + editUrl: '#/management/kibana/objects/savedDashboards/1', + inAppUrl: { + path: '/dashboard/1', + uiCapabilitiesPath: 'dashboard.show', + }, + }, + }, + close: jest.fn(), + }; + + const component = shallowWithI18nProvider(); + + // Make sure we are showing loading + expect(component.find('EuiLoadingKibana').length).toBe(1); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(props.getRelationships).toHaveBeenCalled(); + expect(component).toMatchSnapshot(); + }); + + it('should render errors', async () => { + const props: RelationshipsProps = { + goInspectObject: () => {}, + canGoInApp: () => true, + basePath: httpServiceMock.createSetupContract().basePath, + getRelationships: jest.fn().mockImplementation(() => { + throw new Error('foo'); + }), + savedObject: { + id: '1', + type: 'dashboard', + attributes: {}, + references: [], + meta: { + title: 'MyDashboard', + icon: 'dashboardApp', + editUrl: '#/management/kibana/objects/savedDashboards/1', + inAppUrl: { + path: '/dashboard/1', + uiCapabilitiesPath: 'dashboard.show', + }, + }, + }, + close: jest.fn(), + }; + + const component = shallowWithI18nProvider(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(props.getRelationships).toHaveBeenCalled(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx new file mode 100644 index 0000000000000..ddb262138d565 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx @@ -0,0 +1,347 @@ +/* + * 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, { Component } from 'react'; +import { + EuiTitle, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiLink, + EuiIcon, + EuiCallOut, + EuiLoadingKibana, + EuiInMemoryTable, + EuiToolTip, + EuiText, + EuiSpacer, +} from '@elastic/eui'; +import { FilterConfig } from '@elastic/eui/src/components/search_bar/filters/filters'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { IBasePath } from 'src/core/public'; +import { getDefaultTitle, getSavedObjectLabel } from '../../../lib'; +import { SavedObjectWithMetadata, SavedObjectRelation } from '../../../types'; + +export interface RelationshipsProps { + basePath: IBasePath; + getRelationships: (type: string, id: string) => Promise; + savedObject: SavedObjectWithMetadata; + close: () => void; + goInspectObject: (obj: SavedObjectWithMetadata) => void; + canGoInApp: (obj: SavedObjectWithMetadata) => boolean; +} + +export interface RelationshipsState { + relationships: SavedObjectRelation[]; + isLoading: boolean; + error?: string; +} + +export class Relationships extends Component { + constructor(props: RelationshipsProps) { + super(props); + + this.state = { + relationships: [], + isLoading: false, + error: undefined, + }; + } + + UNSAFE_componentWillMount() { + this.getRelationshipData(); + } + + UNSAFE_componentWillReceiveProps(nextProps: RelationshipsProps) { + if (nextProps.savedObject.id !== this.props.savedObject.id) { + this.getRelationshipData(); + } + } + + async getRelationshipData() { + const { savedObject, getRelationships } = this.props; + + this.setState({ isLoading: true }); + + try { + const relationships = await getRelationships(savedObject.type, savedObject.id); + this.setState({ relationships, isLoading: false, error: undefined }); + } catch (err) { + this.setState({ error: err.message, isLoading: false }); + } + } + + renderError() { + const { error } = this.state; + + if (!error) { + return null; + } + + return ( + + } + color="danger" + > + {error} + + ); + } + + renderRelationships() { + const { goInspectObject, savedObject, basePath } = this.props; + const { relationships, isLoading, error } = this.state; + + if (error) { + return this.renderError(); + } + + if (isLoading) { + return ; + } + + const columns = [ + { + field: 'type', + name: i18n.translate('savedObjectsManagement.objectsTable.relationships.columnTypeName', { + defaultMessage: 'Type', + }), + width: '50px', + align: 'center', + description: i18n.translate( + 'savedObjectsManagement.objectsTable.relationships.columnTypeDescription', + { defaultMessage: 'Type of the saved object' } + ), + sortable: false, + render: (type: string, object: SavedObjectWithMetadata) => { + return ( + + + + ); + }, + }, + { + field: 'relationship', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.relationships.columnRelationshipName', + { defaultMessage: 'Direct relationship' } + ), + dataType: 'string', + sortable: false, + width: '125px', + 'data-test-subj': 'directRelationship', + render: (relationship: string) => { + if (relationship === 'parent') { + return ( + + + + ); + } + if (relationship === 'child') { + return ( + + + + ); + } + }, + }, + { + field: 'meta.title', + name: i18n.translate('savedObjectsManagement.objectsTable.relationships.columnTitleName', { + defaultMessage: 'Title', + }), + description: i18n.translate( + 'savedObjectsManagement.objectsTable.relationships.columnTitleDescription', + { defaultMessage: 'Title of the saved object' } + ), + dataType: 'string', + sortable: false, + render: (title: string, object: SavedObjectWithMetadata) => { + const { path = '' } = object.meta.inAppUrl || {}; + const canGoInApp = this.props.canGoInApp(object); + if (!canGoInApp) { + return ( + + {title || getDefaultTitle(object)} + + ); + } + return ( + + {title || getDefaultTitle(object)} + + ); + }, + }, + { + name: i18n.translate( + 'savedObjectsManagement.objectsTable.relationships.columnActionsName', + { defaultMessage: 'Actions' } + ), + actions: [ + { + name: i18n.translate( + 'savedObjectsManagement.objectsTable.relationships.columnActions.inspectActionName', + { defaultMessage: 'Inspect' } + ), + description: i18n.translate( + 'savedObjectsManagement.objectsTable.relationships.columnActions.inspectActionDescription', + { defaultMessage: 'Inspect this saved object' } + ), + type: 'icon', + icon: 'inspect', + 'data-test-subj': 'relationshipsTableAction-inspect', + onClick: (object: SavedObjectWithMetadata) => goInspectObject(object), + available: (object: SavedObjectWithMetadata) => !!object.meta.editUrl, + }, + ], + }, + ]; + + const filterTypesMap = new Map( + relationships.map(relationship => [ + relationship.type, + { + value: relationship.type, + name: relationship.type, + view: relationship.type, + }, + ]) + ); + + const search = { + filters: [ + { + type: 'field_value_selection', + field: 'relationship', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.relationships.search.filters.relationship.name', + { defaultMessage: 'Direct relationship' } + ), + multiSelect: 'or', + options: [ + { + value: 'parent', + name: 'parent', + view: i18n.translate( + 'savedObjectsManagement.objectsTable.relationships.search.filters.relationship.parentAsValue.view', + { defaultMessage: 'Parent' } + ), + }, + { + value: 'child', + name: 'child', + view: i18n.translate( + 'savedObjectsManagement.objectsTable.relationships.search.filters.relationship.childAsValue.view', + { defaultMessage: 'Child' } + ), + }, + ], + }, + { + type: 'field_value_selection', + field: 'type', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.relationships.search.filters.type.name', + { defaultMessage: 'Type' } + ), + multiSelect: 'or', + options: [...filterTypesMap.values()], + }, + ] as FilterConfig[], + }; + + return ( +
+ +

+ {i18n.translate( + 'savedObjectsManagement.objectsTable.relationships.relationshipsTitle', + { + defaultMessage: + 'Here are the saved objects related to {title}. ' + + 'Deleting this {type} affects its parent objects, but not its children.', + values: { + type: savedObject.type, + title: savedObject.meta.title || getDefaultTitle(savedObject), + }, + } + )} +

+
+ + ({ + 'data-test-subj': `relationshipsTableRow`, + })} + /> +
+ ); + } + + render() { + const { close, savedObject } = this.props; + + return ( + + + +

+ + + +    + {savedObject.meta.title || getDefaultTitle(savedObject)} +

+
+
+ + {this.renderRelationships()} +
+ ); + } +} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx new file mode 100644 index 0000000000000..356f227773610 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx @@ -0,0 +1,126 @@ +/* + * 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 { shallowWithI18nProvider, mountWithI18nProvider } from 'test_utils/enzyme_helpers'; +// @ts-ignore +import { findTestSubject } from '@elastic/eui/lib/test'; +import { keyCodes } from '@elastic/eui'; +import { httpServiceMock } from '../../../../../../core/public/mocks'; +import { actionServiceMock } from '../../../services/action_service.mock'; +import { Table, TableProps } from './table'; + +const defaultProps: TableProps = { + basePath: httpServiceMock.createSetupContract().basePath, + actionRegistry: actionServiceMock.createStart(), + selectedSavedObjects: [ + { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + meta: { + title: `MyIndexPattern*`, + icon: 'indexPatternApp', + editUrl: '#/management/kibana/index_patterns/1', + inAppUrl: { + path: '/management/kibana/index_patterns/1', + uiCapabilitiesPath: 'management.kibana.index_patterns', + }, + }, + }, + ], + selectionConfig: { + onSelectionChange: () => {}, + }, + filterOptions: [{ value: 2 }], + onDelete: () => {}, + onExport: () => {}, + goInspectObject: () => {}, + canGoInApp: () => true, + pageIndex: 1, + pageSize: 2, + items: [ + { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + meta: { + title: `MyIndexPattern*`, + icon: 'indexPatternApp', + editUrl: '#/management/kibana/index_patterns/1', + inAppUrl: { + path: '/management/kibana/index_patterns/1', + uiCapabilitiesPath: 'management.kibana.index_patterns', + }, + }, + }, + ], + itemId: 'id', + totalItemCount: 3, + onQueryChange: () => {}, + onTableChange: () => {}, + isSearching: false, + onShowRelationships: () => {}, + canDelete: true, +}; + +describe('Table', () => { + it('should render normally', () => { + const component = shallowWithI18nProvider(
); + + expect(component).toMatchSnapshot(); + }); + + it('should handle query parse error', () => { + const onQueryChangeMock = jest.fn(); + const customizedProps = { + ...defaultProps, + onQueryChange: onQueryChangeMock, + }; + + const component = mountWithI18nProvider(
); + const searchBar = findTestSubject(component, 'savedObjectSearchBar'); + + // Send invalid query + searchBar.simulate('keyup', { keyCode: keyCodes.ENTER, target: { value: '?' } }); + expect(onQueryChangeMock).toHaveBeenCalledTimes(0); + expect(component.state().isSearchTextValid).toBe(false); + + onQueryChangeMock.mockReset(); + + // Send valid query to ensure component can recover from invalid query + searchBar.simulate('keyup', { keyCode: keyCodes.ENTER, target: { value: 'I am valid' } }); + expect(onQueryChangeMock).toHaveBeenCalledTimes(1); + expect(component.state().isSearchTextValid).toBe(true); + }); + + it(`prevents saved objects from being deleted`, () => { + const selectedSavedObjects = [ + { type: 'visualization' }, + { type: 'search' }, + { type: 'index-pattern' }, + ] as any; + const customizedProps = { ...defaultProps, selectedSavedObjects, canDelete: false }; + const component = shallowWithI18nProvider(
); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx new file mode 100644 index 0000000000000..5b574e4b3d331 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -0,0 +1,401 @@ +/* + * 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 { IBasePath } from 'src/core/public'; +import React, { PureComponent, Fragment } from 'react'; +import { + // @ts-ignore + EuiSearchBar, + EuiBasicTable, + EuiButton, + EuiIcon, + EuiLink, + EuiSpacer, + EuiToolTip, + EuiFormErrorText, + EuiPopover, + EuiSwitch, + EuiFormRow, + EuiText, + EuiTableFieldDataColumnType, + EuiTableActionsColumnType, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { getDefaultTitle, getSavedObjectLabel } from '../../../lib'; +import { SavedObjectWithMetadata } from '../../../types'; +import { + SavedObjectsManagementActionServiceStart, + SavedObjectsManagementAction, +} from '../../../services'; + +export interface TableProps { + basePath: IBasePath; + actionRegistry: SavedObjectsManagementActionServiceStart; + selectedSavedObjects: SavedObjectWithMetadata[]; + selectionConfig: { + onSelectionChange: (selection: SavedObjectWithMetadata[]) => void; + }; + filterOptions: any[]; + canDelete: boolean; + onDelete: () => void; + onExport: (includeReferencesDeep: boolean) => void; + goInspectObject: (obj: SavedObjectWithMetadata) => void; + pageIndex: number; + pageSize: number; + items: SavedObjectWithMetadata[]; + itemId: string | (() => string); + totalItemCount: number; + onQueryChange: (query: any) => void; + onTableChange: (table: any) => void; + isSearching: boolean; + onShowRelationships: (object: SavedObjectWithMetadata) => void; + canGoInApp: (obj: SavedObjectWithMetadata) => boolean; +} + +interface TableState { + isSearchTextValid: boolean; + parseErrorMessage: any; + isExportPopoverOpen: boolean; + isIncludeReferencesDeepChecked: boolean; + activeAction?: SavedObjectsManagementAction; +} + +export class Table extends PureComponent { + state: TableState = { + isSearchTextValid: true, + parseErrorMessage: null, + isExportPopoverOpen: false, + isIncludeReferencesDeepChecked: true, + activeAction: undefined, + }; + + constructor(props: TableProps) { + super(props); + } + + onChange = ({ query, error }: any) => { + if (error) { + this.setState({ + isSearchTextValid: false, + parseErrorMessage: error.message, + }); + return; + } + + this.setState({ + isSearchTextValid: true, + parseErrorMessage: null, + }); + this.props.onQueryChange({ query }); + }; + + closeExportPopover = () => { + this.setState({ isExportPopoverOpen: false }); + }; + + toggleExportPopoverVisibility = () => { + this.setState(state => ({ + isExportPopoverOpen: !state.isExportPopoverOpen, + })); + }; + + toggleIsIncludeReferencesDeepChecked = () => { + this.setState(state => ({ + isIncludeReferencesDeepChecked: !state.isIncludeReferencesDeepChecked, + })); + }; + + onExportClick = () => { + const { onExport } = this.props; + const { isIncludeReferencesDeepChecked } = this.state; + onExport(isIncludeReferencesDeepChecked); + this.setState({ isExportPopoverOpen: false }); + }; + + render() { + const { + pageIndex, + pageSize, + itemId, + items, + totalItemCount, + isSearching, + filterOptions, + selectionConfig: selection, + onDelete, + selectedSavedObjects, + onTableChange, + goInspectObject, + onShowRelationships, + basePath, + actionRegistry, + } = this.props; + + const pagination = { + pageIndex, + pageSize, + totalItemCount, + pageSizeOptions: [5, 10, 20, 50], + }; + + const filters = [ + { + type: 'field_value_selection', + field: 'type', + name: i18n.translate('savedObjectsManagement.objectsTable.table.typeFilterName', { + defaultMessage: 'Type', + }), + multiSelect: 'or', + options: filterOptions, + }, + // Add this back in once we have tag support + // { + // type: 'field_value_selection', + // field: 'tag', + // name: 'Tags', + // multiSelect: 'or', + // options: [], + // }, + ]; + + const columns = [ + { + field: 'type', + name: i18n.translate('savedObjectsManagement.objectsTable.table.columnTypeName', { + defaultMessage: 'Type', + }), + width: '50px', + align: 'center', + description: i18n.translate( + 'savedObjectsManagement.objectsTable.table.columnTypeDescription', + { defaultMessage: 'Type of the saved object' } + ), + sortable: false, + 'data-test-subj': 'savedObjectsTableRowType', + render: (type: string, object: SavedObjectWithMetadata) => { + return ( + + + + ); + }, + } as EuiTableFieldDataColumnType>, + { + field: 'meta.title', + name: i18n.translate('savedObjectsManagement.objectsTable.table.columnTitleName', { + defaultMessage: 'Title', + }), + description: i18n.translate( + 'savedObjectsManagement.objectsTable.table.columnTitleDescription', + { defaultMessage: 'Title of the saved object' } + ), + dataType: 'string', + sortable: false, + 'data-test-subj': 'savedObjectsTableRowTitle', + render: (title: string, object: SavedObjectWithMetadata) => { + const { path = '' } = object.meta.inAppUrl || {}; + const canGoInApp = this.props.canGoInApp(object); + if (!canGoInApp) { + return {title || getDefaultTitle(object)}; + } + return ( + {title || getDefaultTitle(object)} + ); + }, + } as EuiTableFieldDataColumnType>, + { + name: i18n.translate('savedObjectsManagement.objectsTable.table.columnActionsName', { + defaultMessage: 'Actions', + }), + actions: [ + { + name: i18n.translate( + 'savedObjectsManagement.objectsTable.table.columnActions.inspectActionName', + { defaultMessage: 'Inspect' } + ), + description: i18n.translate( + 'savedObjectsManagement.objectsTable.table.columnActions.inspectActionDescription', + { defaultMessage: 'Inspect this saved object' } + ), + type: 'icon', + icon: 'inspect', + onClick: object => goInspectObject(object), + available: object => !!object.meta.editUrl, + 'data-test-subj': 'savedObjectsTableAction-inspect', + }, + { + name: i18n.translate( + 'savedObjectsManagement.objectsTable.table.columnActions.viewRelationshipsActionName', + { defaultMessage: 'Relationships' } + ), + description: i18n.translate( + 'savedObjectsManagement.objectsTable.table.columnActions.viewRelationshipsActionDescription', + { + defaultMessage: + 'View the relationships this saved object has to other saved objects', + } + ), + type: 'icon', + icon: 'kqlSelector', + onClick: object => onShowRelationships(object), + 'data-test-subj': 'savedObjectsTableAction-relationships', + }, + ...actionRegistry.getAll().map(action => { + return { + ...action.euiAction, + 'data-test-subj': `savedObjectsTableAction-${action.id}`, + onClick: (object: SavedObjectWithMetadata) => { + this.setState({ + activeAction: action, + }); + + action.registerOnFinishCallback(() => { + this.setState({ + activeAction: undefined, + }); + }); + + if (action.euiAction.onClick) { + action.euiAction.onClick(object as any); + } + }, + }; + }), + ], + } as EuiTableActionsColumnType, + ]; + + let queryParseError; + if (!this.state.isSearchTextValid) { + const parseErrorMsg = i18n.translate( + 'savedObjectsManagement.objectsTable.searchBar.unableToParseQueryErrorMessage', + { defaultMessage: 'Unable to parse query' } + ); + queryParseError = ( + {`${parseErrorMsg}. ${this.state.parseErrorMessage}`} + ); + } + + const button = ( + + + + ); + + const activeActionContents = this.state.activeAction?.render() ?? null; + + return ( + + {activeActionContents} + + + , + + + } + > + + } + checked={this.state.isIncludeReferencesDeepChecked} + onChange={this.toggleIsIncludeReferencesDeepChecked} + /> + + + + + + + , + ]} + /> + {queryParseError} + +
+ ({ + 'data-test-subj': `savedObjectsTableRow row-${item.id}`, + })} + /> +
+
+ ); + } +} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/index.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/index.ts new file mode 100644 index 0000000000000..8777b15389690 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/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 { SavedObjectsTable } from './saved_objects_table'; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.mocks.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.mocks.ts new file mode 100644 index 0000000000000..6b4659a6b5a13 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.mocks.ts @@ -0,0 +1,67 @@ +/* + * 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 const saveAsMock = jest.fn(); +jest.doMock('@elastic/filesaver', () => ({ + saveAs: saveAsMock, +})); + +jest.doMock('lodash', () => ({ + ...jest.requireActual('lodash'), + debounce: (func: Function) => { + function debounced(this: any, ...args: any[]) { + return func.apply(this, args); + } + return debounced; + }, +})); + +export const findObjectsMock = jest.fn(); +jest.doMock('../../lib/find_objects', () => ({ + findObjects: findObjectsMock, +})); + +export const fetchExportObjectsMock = jest.fn(); +jest.doMock('../../lib/fetch_export_objects', () => ({ + fetchExportObjects: fetchExportObjectsMock, +})); + +export const fetchExportByTypeAndSearchMock = jest.fn(); +jest.doMock('../../lib/fetch_export_by_type_and_search', () => ({ + fetchExportByTypeAndSearch: fetchExportByTypeAndSearchMock, +})); + +export const extractExportDetailsMock = jest.fn(); +jest.doMock('../../lib/extract_export_details', () => ({ + extractExportDetails: extractExportDetailsMock, +})); + +jest.doMock('./components/header', () => ({ + Header: () => 'Header', +})); + +export const getSavedObjectCountsMock = jest.fn(); +jest.doMock('../../lib/get_saved_object_counts', () => ({ + getSavedObjectCounts: getSavedObjectCountsMock, +})); + +export const getRelationshipsMock = jest.fn(); +jest.doMock('../../lib/get_relationships', () => ({ + getRelationships: getRelationshipsMock, +})); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx new file mode 100644 index 0000000000000..342fdc4784b09 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -0,0 +1,551 @@ +/* + * 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 { + extractExportDetailsMock, + fetchExportByTypeAndSearchMock, + fetchExportObjectsMock, + findObjectsMock, + getRelationshipsMock, + getSavedObjectCountsMock, + saveAsMock, +} from './saved_objects_table.test.mocks'; + +import React from 'react'; +import { Query } from '@elastic/eui'; +import { ShallowWrapper } from 'enzyme'; +import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; +import { + httpServiceMock, + overlayServiceMock, + notificationServiceMock, + savedObjectsServiceMock, + applicationServiceMock, +} from '../../../../../core/public/mocks'; +import { dataPluginMock } from '../../../../data/public/mocks'; +import { serviceRegistryMock } from '../../services/service_registry.mock'; +import { actionServiceMock } from '../../services/action_service.mock'; +import { + SavedObjectsTable, + SavedObjectsTableProps, + SavedObjectsTableState, +} from './saved_objects_table'; +import { Flyout, Relationships } from './components'; +import { SavedObjectWithMetadata } from '../../types'; + +const allowedTypes = ['index-pattern', 'visualization', 'dashboard', 'search']; + +const allSavedObjects = [ + { + id: '1', + type: 'index-pattern', + attributes: { + title: `MyIndexPattern*`, + }, + }, + { + id: '2', + type: 'search', + attributes: { + title: `MySearch`, + }, + }, + { + id: '3', + type: 'dashboard', + attributes: { + title: `MyDashboard`, + }, + }, + { + id: '4', + type: 'visualization', + attributes: { + title: `MyViz`, + }, + }, +]; + +describe('SavedObjectsTable', () => { + let defaultProps: SavedObjectsTableProps; + let http: ReturnType; + let overlays: ReturnType; + let notifications: ReturnType; + let savedObjects: ReturnType; + + const shallowRender = (overrides: Partial = {}) => { + return (shallowWithI18nProvider( + + ) as unknown) as ShallowWrapper< + SavedObjectsTableProps, + SavedObjectsTableState, + SavedObjectsTable + >; + }; + + beforeEach(() => { + extractExportDetailsMock.mockReset(); + + http = httpServiceMock.createStartContract(); + overlays = overlayServiceMock.createStartContract(); + notifications = notificationServiceMock.createStartContract(); + savedObjects = savedObjectsServiceMock.createStartContract(); + + const applications = applicationServiceMock.createStartContract(); + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: false, + }, + }; + + http.post.mockResolvedValue([]); + + getSavedObjectCountsMock.mockReturnValue({ + 'index-pattern': 0, + visualization: 0, + dashboard: 0, + search: 0, + }); + + defaultProps = { + allowedTypes, + serviceRegistry: serviceRegistryMock.create(), + actionRegistry: actionServiceMock.createStart(), + savedObjectsClient: savedObjects.client, + indexPatterns: dataPluginMock.createStartContract().indexPatterns, + http, + overlays, + notifications, + applications, + perPageConfig: 15, + goInspectObject: () => {}, + canGoInApp: () => true, + }; + + findObjectsMock.mockImplementation(() => ({ + total: 4, + savedObjects: [ + { + id: '1', + type: 'index-pattern', + meta: { + title: `MyIndexPattern*`, + icon: 'indexPatternApp', + editUrl: '#/management/kibana/index_patterns/1', + inAppUrl: { + path: '/management/kibana/index_patterns/1', + uiCapabilitiesPath: 'management.kibana.index_patterns', + }, + }, + }, + { + id: '2', + type: 'search', + meta: { + title: `MySearch`, + icon: 'search', + editUrl: '#/management/kibana/objects/savedSearches/2', + inAppUrl: { + path: '/discover/2', + uiCapabilitiesPath: 'discover.show', + }, + }, + }, + { + id: '3', + type: 'dashboard', + meta: { + title: `MyDashboard`, + icon: 'dashboardApp', + editUrl: '#/management/kibana/objects/savedDashboards/3', + inAppUrl: { + path: '/dashboard/3', + uiCapabilitiesPath: 'dashboard.show', + }, + }, + }, + { + id: '4', + type: 'visualization', + meta: { + title: `MyViz`, + icon: 'visualizeApp', + editUrl: '#/management/kibana/objects/savedVisualizations/4', + inAppUrl: { + path: '/visualize/edit/4', + uiCapabilitiesPath: 'visualize.show', + }, + }, + }, + ], + })); + }); + + it('should render normally', async () => { + const component = shallowRender({ perPageConfig: 15 }); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + + it('should add danger toast when find fails', async () => { + findObjectsMock.mockImplementation(() => { + throw new Error('Simulated find error'); + }); + const component = shallowRender({ perPageConfig: 15 }); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(notifications.toasts.addDanger).toHaveBeenCalled(); + }); + + describe('export', () => { + it('should export selected objects', async () => { + const mockSelectedSavedObjects = [ + { id: '1', type: 'index-pattern' }, + { id: '3', type: 'dashboard' }, + ] as SavedObjectWithMetadata[]; + + const mockSavedObjects = mockSelectedSavedObjects.map(obj => ({ + _id: obj.id, + _source: {}, + })); + + const mockSavedObjectsClient = { + ...defaultProps.savedObjectsClient, + bulkGet: jest.fn().mockImplementation(() => ({ + savedObjects: mockSavedObjects, + })), + }; + + const component = shallowRender({ savedObjectsClient: mockSavedObjectsClient }); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + // Set some as selected + component.instance().onSelectionChanged(mockSelectedSavedObjects); + + await component.instance().onExport(true); + + expect(fetchExportObjectsMock).toHaveBeenCalledWith(http, mockSelectedSavedObjects, true); + expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ + title: 'Your file is downloading in the background', + }); + }); + + it('should display a warning is export contains missing references', async () => { + const mockSelectedSavedObjects = [ + { id: '1', type: 'index-pattern' }, + { id: '3', type: 'dashboard' }, + ] as SavedObjectWithMetadata[]; + + const mockSavedObjects = mockSelectedSavedObjects.map(obj => ({ + _id: obj.id, + _source: {}, + })); + + const mockSavedObjectsClient = { + ...defaultProps.savedObjectsClient, + bulkGet: jest.fn().mockImplementation(() => ({ + savedObjects: mockSavedObjects, + })), + }; + + extractExportDetailsMock.mockImplementation(() => ({ + exportedCount: 2, + missingRefCount: 1, + missingReferences: [{ id: '7', type: 'visualisation' }], + })); + + const component = shallowRender({ savedObjectsClient: mockSavedObjectsClient }); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + // Set some as selected + component.instance().onSelectionChanged(mockSelectedSavedObjects); + + await component.instance().onExport(true); + + expect(fetchExportObjectsMock).toHaveBeenCalledWith(http, mockSelectedSavedObjects, true); + expect(notifications.toasts.addWarning).toHaveBeenCalledWith({ + title: + 'Your file is downloading in the background. ' + + 'Some related objects could not be found. ' + + 'Please see the last line in the exported file for a list of missing objects.', + }); + }); + + it('should allow the user to choose when exporting all', async () => { + const component = shallowRender(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + (component.find('Header') as any).prop('onExportAll')(); + component.update(); + + expect(component.find('EuiModal')).toMatchSnapshot(); + }); + + it('should export all', async () => { + const component = shallowRender(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + // Set up mocks + const blob = new Blob([JSON.stringify(allSavedObjects)], { type: 'application/ndjson' }); + fetchExportByTypeAndSearchMock.mockImplementation(() => blob); + + await component.instance().onExportAll(); + + expect(fetchExportByTypeAndSearchMock).toHaveBeenCalledWith( + http, + allowedTypes, + undefined, + true + ); + expect(saveAsMock).toHaveBeenCalledWith(blob, 'export.ndjson'); + expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ + title: 'Your file is downloading in the background', + }); + }); + + it('should export all, accounting for the current search criteria', async () => { + const component = shallowRender(); + + component.instance().onQueryChange({ + query: Query.parse('test'), + }); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + // Set up mocks + const blob = new Blob([JSON.stringify(allSavedObjects)], { type: 'application/ndjson' }); + fetchExportByTypeAndSearchMock.mockImplementation(() => blob); + + await component.instance().onExportAll(); + + expect(fetchExportByTypeAndSearchMock).toHaveBeenCalledWith( + http, + allowedTypes, + 'test*', + true + ); + expect(saveAsMock).toHaveBeenCalledWith(blob, 'export.ndjson'); + expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ + title: 'Your file is downloading in the background', + }); + }); + }); + + describe('import', () => { + it('should show the flyout', async () => { + const component = shallowRender(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + component.instance().showImportFlyout(); + component.update(); + + expect(component.find(Flyout)).toMatchSnapshot(); + }); + + it('should hide the flyout', async () => { + const component = shallowRender(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + component.instance().hideImportFlyout(); + component.update(); + + expect(component.find(Flyout).length).toBe(0); + }); + }); + + describe('relationships', () => { + it('should fetch relationships', async () => { + const component = shallowRender(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + await component.instance().getRelationships('search', '1'); + const savedObjectTypes = ['index-pattern', 'visualization', 'dashboard', 'search']; + expect(getRelationshipsMock).toHaveBeenCalledWith(http, 'search', '1', savedObjectTypes); + }); + + it('should show the flyout', async () => { + const component = shallowRender(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + component.instance().onShowRelationships({ + id: '2', + type: 'search', + meta: { + title: `MySearch`, + icon: 'search', + editUrl: '#/management/kibana/objects/savedSearches/2', + inAppUrl: { + path: '/discover/2', + uiCapabilitiesPath: 'discover.show', + }, + }, + } as SavedObjectWithMetadata); + component.update(); + + expect(component.find(Relationships)).toMatchSnapshot(); + expect(component.state('relationshipObject')).toEqual({ + id: '2', + type: 'search', + meta: { + title: 'MySearch', + editUrl: '#/management/kibana/objects/savedSearches/2', + icon: 'search', + inAppUrl: { + path: '/discover/2', + uiCapabilitiesPath: 'discover.show', + }, + }, + }); + }); + + it('should hide the flyout', async () => { + const component = shallowRender(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + component.instance().onHideRelationships(); + component.update(); + + expect(component.find(Relationships).length).toBe(0); + expect(component.state('relationshipId')).toBe(undefined); + expect(component.state('relationshipType')).toBe(undefined); + expect(component.state('relationshipTitle')).toBe(undefined); + }); + }); + + describe('delete', () => { + it('should show a confirm modal', async () => { + const component = shallowRender(); + + const mockSelectedSavedObjects = [ + { id: '1', type: 'index-pattern' }, + { id: '3', type: 'dashboard' }, + ] as SavedObjectWithMetadata[]; + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + // Set some as selected + component.instance().onSelectionChanged(mockSelectedSavedObjects); + await component.instance().onDelete(); + component.update(); + + expect(component.find('EuiConfirmModal')).toMatchSnapshot(); + }); + + it('should delete selected objects', async () => { + const mockSelectedSavedObjects = [ + { id: '1', type: 'index-pattern' }, + { id: '3', type: 'dashboard' }, + ] as SavedObjectWithMetadata[]; + + const mockSavedObjects = mockSelectedSavedObjects.map(obj => ({ + id: obj.id, + type: obj.type, + source: {}, + })); + + const mockSavedObjectsClient = { + ...defaultProps.savedObjectsClient, + bulkGet: jest.fn().mockImplementation(() => ({ + savedObjects: mockSavedObjects, + })), + delete: jest.fn(), + }; + + const component = shallowRender({ savedObjectsClient: mockSavedObjectsClient }); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + // Set some as selected + component.instance().onSelectionChanged(mockSelectedSavedObjects); + + await component.instance().delete(); + + expect(defaultProps.indexPatterns.clearCache).toHaveBeenCalled(); + expect(mockSavedObjectsClient.bulkGet).toHaveBeenCalledWith(mockSelectedSavedObjects); + expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith( + mockSavedObjects[0].type, + mockSavedObjects[0].id + ); + expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith( + mockSavedObjects[1].type, + mockSavedObjects[1].id + ); + expect(component.state('selectedSavedObjects').length).toBe(0); + }); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx new file mode 100644 index 0000000000000..c76fea5a0fb29 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -0,0 +1,756 @@ +/* + * 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, { Component } from 'react'; +import { debounce } from 'lodash'; +// @ts-ignore +import { saveAs } from '@elastic/filesaver'; +import { + EuiSpacer, + Query, + EuiInMemoryTable, + EuiIcon, + EuiConfirmModal, + EuiLoadingKibana, + EuiOverlayMask, + EUI_MODAL_CONFIRM_BUTTON, + EuiCheckboxGroup, + EuiToolTip, + EuiPageContent, + EuiSwitch, + EuiModal, + EuiModalHeader, + EuiModalBody, + EuiModalFooter, + EuiButtonEmpty, + EuiButton, + EuiModalHeaderTitle, + EuiFormRow, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + SavedObjectsClientContract, + SavedObjectsFindOptions, + HttpStart, + OverlayStart, + NotificationsStart, + ApplicationStart, +} from 'src/core/public'; +import { IndexPatternsContract } from '../../../../data/public'; +import { + parseQuery, + getSavedObjectCounts, + getRelationships, + getSavedObjectLabel, + fetchExportObjects, + fetchExportByTypeAndSearch, + findObjects, + extractExportDetails, + SavedObjectsExportResultDetails, +} from '../../lib'; +import { SavedObjectWithMetadata } from '../../types'; +import { + ISavedObjectsManagementServiceRegistry, + SavedObjectsManagementActionServiceStart, +} from '../../services'; +import { Header, Table, Flyout, Relationships } from './components'; + +interface ExportAllOption { + id: string; + label: string; +} + +export interface SavedObjectsTableProps { + allowedTypes: string[]; + serviceRegistry: ISavedObjectsManagementServiceRegistry; + actionRegistry: SavedObjectsManagementActionServiceStart; + savedObjectsClient: SavedObjectsClientContract; + indexPatterns: IndexPatternsContract; + http: HttpStart; + overlays: OverlayStart; + notifications: NotificationsStart; + applications: ApplicationStart; + perPageConfig: number; + goInspectObject: (obj: SavedObjectWithMetadata) => void; + canGoInApp: (obj: SavedObjectWithMetadata) => boolean; +} + +export interface SavedObjectsTableState { + totalCount: number; + page: number; + perPage: number; + savedObjects: SavedObjectWithMetadata[]; + savedObjectCounts: Record; + activeQuery: Query; + selectedSavedObjects: SavedObjectWithMetadata[]; + isShowingImportFlyout: boolean; + isSearching: boolean; + filteredItemCount: number; + isShowingRelationships: boolean; + relationshipObject?: SavedObjectWithMetadata; + isShowingDeleteConfirmModal: boolean; + isShowingExportAllOptionsModal: boolean; + isDeleting: boolean; + exportAllOptions: ExportAllOption[]; + exportAllSelectedOptions: Record; + isIncludeReferencesDeepChecked: boolean; +} + +export class SavedObjectsTable extends Component { + private _isMounted = false; + + constructor(props: SavedObjectsTableProps) { + super(props); + + this.state = { + totalCount: 0, + page: 0, + perPage: props.perPageConfig || 50, + savedObjects: [], + savedObjectCounts: props.allowedTypes.reduce((typeToCountMap, type) => { + typeToCountMap[type] = 0; + return typeToCountMap; + }, {} as Record), + activeQuery: Query.parse(''), + selectedSavedObjects: [], + isShowingImportFlyout: false, + isSearching: false, + filteredItemCount: 0, + isShowingRelationships: false, + relationshipObject: undefined, + isShowingDeleteConfirmModal: false, + isShowingExportAllOptionsModal: false, + isDeleting: false, + exportAllOptions: [], + exportAllSelectedOptions: {}, + isIncludeReferencesDeepChecked: true, + }; + } + + componentDidMount() { + this._isMounted = true; + this.fetchSavedObjects(); + this.fetchCounts(); + } + + componentWillUnmount() { + this._isMounted = false; + this.debouncedFetch.cancel(); + } + + fetchCounts = async () => { + const { allowedTypes } = this.props; + const { queryText, visibleTypes } = parseQuery(this.state.activeQuery); + + const filteredTypes = allowedTypes.filter(type => !visibleTypes || visibleTypes.includes(type)); + + // These are the saved objects visible in the table. + const filteredSavedObjectCounts = await getSavedObjectCounts( + this.props.http, + filteredTypes, + queryText + ); + + const exportAllOptions: ExportAllOption[] = []; + const exportAllSelectedOptions: Record = {}; + + Object.keys(filteredSavedObjectCounts).forEach(id => { + // Add this type as a bulk-export option. + exportAllOptions.push({ + id, + label: `${id} (${filteredSavedObjectCounts[id] || 0})`, + }); + + // Select it by default. + exportAllSelectedOptions[id] = true; + }); + + // Fetch all the saved objects that exist so we can accurately populate the counts within + // the table filter dropdown. + const savedObjectCounts = await getSavedObjectCounts(this.props.http, allowedTypes, queryText); + + this.setState(state => ({ + ...state, + savedObjectCounts, + exportAllOptions, + exportAllSelectedOptions, + })); + }; + + fetchSavedObjects = () => { + this.setState( + { + isSearching: true, + }, + this.debouncedFetch + ); + }; + + debouncedFetch = debounce(async () => { + const { activeQuery: query, page, perPage } = this.state; + const { notifications, http, allowedTypes } = this.props; + const { queryText, visibleTypes } = parseQuery(query); + // "searchFields" is missing from the "findOptions" but gets injected via the API. + // The API extracts the fields from each uiExports.savedObjectsManagement "defaultSearchField" attribute + const findOptions: SavedObjectsFindOptions = { + search: queryText ? `${queryText}*` : undefined, + perPage, + page: page + 1, + fields: ['id'], + type: allowedTypes.filter(type => !visibleTypes || visibleTypes.includes(type)), + }; + if (findOptions.type.length > 1) { + findOptions.sortField = 'type'; + } + + try { + const resp = await findObjects(http, findOptions); + if (!this._isMounted) { + return; + } + + this.setState(({ activeQuery }) => { + // ignore results for old requests + if (activeQuery.text !== query.text) { + return null; + } + + return { + savedObjects: resp.savedObjects, + filteredItemCount: resp.total, + isSearching: false, + }; + }); + } catch (error) { + if (this._isMounted) { + this.setState({ + isSearching: false, + }); + } + notifications.toasts.addDanger({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.unableFindSavedObjectsNotificationMessage', + { defaultMessage: 'Unable find saved objects' } + ), + text: `${error}`, + }); + } + }, 300); + + refreshData = async () => { + await Promise.all([this.fetchSavedObjects(), this.fetchCounts()]); + }; + + onSelectionChanged = (selection: SavedObjectWithMetadata[]) => { + this.setState({ selectedSavedObjects: selection }); + }; + + onQueryChange = ({ query }: { query: Query }) => { + // TODO: Use isSameQuery to compare new query with state.activeQuery to avoid re-fetching the + // same data we already have. + this.setState( + { + activeQuery: query, + page: 0, // Reset this on each query change + selectedSavedObjects: [], + }, + () => { + this.fetchSavedObjects(); + this.fetchCounts(); + } + ); + }; + + onTableChange = async (table: any) => { + const { index: page, size: perPage } = table.page || {}; + + this.setState( + { + page, + perPage, + selectedSavedObjects: [], + }, + this.fetchSavedObjects + ); + }; + + onShowRelationships = (object: SavedObjectWithMetadata) => { + this.setState({ + isShowingRelationships: true, + relationshipObject: object, + }); + }; + + onHideRelationships = () => { + this.setState({ + isShowingRelationships: false, + relationshipObject: undefined, + }); + }; + + onExport = async (includeReferencesDeep: boolean) => { + const { selectedSavedObjects } = this.state; + const { notifications, http } = this.props; + const objectsToExport = selectedSavedObjects.map(obj => ({ id: obj.id, type: obj.type })); + + let blob; + try { + blob = await fetchExportObjects(http, objectsToExport, includeReferencesDeep); + } catch (e) { + notifications.toasts.addDanger({ + title: i18n.translate('savedObjectsManagement.objectsTable.export.dangerNotification', { + defaultMessage: 'Unable to generate export', + }), + }); + throw e; + } + + saveAs(blob, 'export.ndjson'); + + const exportDetails = await extractExportDetails(blob); + this.showExportSuccessMessage(exportDetails); + }; + + onExportAll = async () => { + const { exportAllSelectedOptions, isIncludeReferencesDeepChecked, activeQuery } = this.state; + const { notifications, http } = this.props; + const { queryText } = parseQuery(activeQuery); + const exportTypes = Object.entries(exportAllSelectedOptions).reduce((accum, [id, selected]) => { + if (selected) { + accum.push(id); + } + return accum; + }, [] as string[]); + + let blob; + try { + blob = await fetchExportByTypeAndSearch( + http, + exportTypes, + queryText ? `${queryText}*` : undefined, + isIncludeReferencesDeepChecked + ); + } catch (e) { + notifications.toasts.addDanger({ + title: i18n.translate('savedObjectsManagement.objectsTable.export.dangerNotification', { + defaultMessage: 'Unable to generate export', + }), + }); + throw e; + } + + saveAs(blob, 'export.ndjson'); + + const exportDetails = await extractExportDetails(blob); + this.showExportSuccessMessage(exportDetails); + this.setState({ isShowingExportAllOptionsModal: false }); + }; + + showExportSuccessMessage = (exportDetails: SavedObjectsExportResultDetails | undefined) => { + const { notifications } = this.props; + if (exportDetails && exportDetails.missingReferences.length > 0) { + notifications.toasts.addWarning({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.export.successWithMissingRefsNotification', + { + defaultMessage: + 'Your file is downloading in the background. ' + + 'Some related objects could not be found. ' + + 'Please see the last line in the exported file for a list of missing objects.', + } + ), + }); + } else { + notifications.toasts.addSuccess({ + title: i18n.translate('savedObjectsManagement.objectsTable.export.successNotification', { + defaultMessage: 'Your file is downloading in the background', + }), + }); + } + }; + + finishImport = () => { + this.hideImportFlyout(); + this.fetchSavedObjects(); + this.fetchCounts(); + }; + + showImportFlyout = () => { + this.setState({ isShowingImportFlyout: true }); + }; + + hideImportFlyout = () => { + this.setState({ isShowingImportFlyout: false }); + }; + + onDelete = () => { + this.setState({ isShowingDeleteConfirmModal: true }); + }; + + delete = async () => { + const { savedObjectsClient } = this.props; + const { selectedSavedObjects, isDeleting } = this.state; + + if (isDeleting) { + return; + } + + this.setState({ isDeleting: true }); + + const indexPatterns = selectedSavedObjects.filter(object => object.type === 'index-pattern'); + if (indexPatterns.length) { + await this.props.indexPatterns.clearCache(); + } + + const objects = await savedObjectsClient.bulkGet(selectedSavedObjects); + const deletes = objects.savedObjects.map(object => + savedObjectsClient.delete(object.type, object.id) + ); + await Promise.all(deletes); + + // Unset this + this.setState({ + selectedSavedObjects: [], + }); + + // Fetching all data + await this.fetchSavedObjects(); + await this.fetchCounts(); + + // Allow the user to interact with the table once the saved objects have been re-fetched. + this.setState({ + isShowingDeleteConfirmModal: false, + isDeleting: false, + }); + }; + + getRelationships = async (type: string, id: string) => { + const { allowedTypes, http } = this.props; + return await getRelationships(http, type, id, allowedTypes); + }; + + renderFlyout() { + if (!this.state.isShowingImportFlyout) { + return null; + } + const { applications } = this.props; + const newIndexPatternUrl = applications.getUrlForApp('kibana', { + path: '#/management/kibana/index_pattern', + }); + + return ( + + ); + } + + renderRelationships() { + if (!this.state.isShowingRelationships) { + return null; + } + + return ( + + ); + } + + renderDeleteConfirmModal() { + const { isShowingDeleteConfirmModal, isDeleting, selectedSavedObjects } = this.state; + + if (!isShowingDeleteConfirmModal) { + return null; + } + + let modal; + + if (isDeleting) { + // Block the user from interacting with the table while its contents are being deleted. + modal = ; + } else { + const onCancel = () => { + this.setState({ isShowingDeleteConfirmModal: false }); + }; + + const onConfirm = () => { + this.delete(); + }; + + modal = ( + + } + onCancel={onCancel} + onConfirm={onConfirm} + buttonColor="danger" + cancelButtonText={ + + } + confirmButtonText={ + isDeleting ? ( + + ) : ( + + ) + } + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + > +

+ +

+ ( + + + + ), + }, + { + field: 'id', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.idColumnName', + { defaultMessage: 'Id' } + ), + }, + { + field: 'meta.title', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName', + { defaultMessage: 'Title' } + ), + }, + ]} + pagination={true} + sorting={false} + /> +
+ ); + } + + return {modal}; + } + + changeIncludeReferencesDeep = () => { + this.setState(state => ({ + isIncludeReferencesDeepChecked: !state.isIncludeReferencesDeepChecked, + })); + }; + + closeExportAllModal = () => { + this.setState({ isShowingExportAllOptionsModal: false }); + }; + + renderExportAllOptionsModal() { + const { + isShowingExportAllOptionsModal, + filteredItemCount, + exportAllOptions, + exportAllSelectedOptions, + isIncludeReferencesDeepChecked, + } = this.state; + + if (!isShowingExportAllOptionsModal) { + return null; + } + + return ( + + + + + + + + + + } + labelType="legend" + > + { + const newExportAllSelectedOptions = { + ...exportAllSelectedOptions, + ...{ + [optionId]: !exportAllSelectedOptions[optionId], + }, + }; + + this.setState({ + exportAllSelectedOptions: newExportAllSelectedOptions, + }); + }} + /> + + + + } + checked={isIncludeReferencesDeepChecked} + onChange={this.changeIncludeReferencesDeep} + /> + + + + + + + + + + + + + + + + + + + + + + ); + } + + render() { + const { + selectedSavedObjects, + page, + perPage, + savedObjects, + filteredItemCount, + isSearching, + savedObjectCounts, + } = this.state; + const { http, allowedTypes, applications } = this.props; + + const selectionConfig = { + onSelectionChange: this.onSelectionChanged, + }; + + const filterOptions = allowedTypes.map(type => ({ + value: type, + name: type, + view: `${type} (${savedObjectCounts[type] || 0})`, + })); + + return ( + + {this.renderFlyout()} + {this.renderRelationships()} + {this.renderDeleteConfirmModal()} + {this.renderExportAllOptionsModal()} +
this.setState({ isShowingExportAllOptionsModal: true })} + onImport={this.showImportFlyout} + onRefresh={this.refreshData} + filteredCount={filteredItemCount} + /> + +
+ + ); + } +} diff --git a/src/plugins/saved_objects_management/public/management_section/types.ts b/src/plugins/saved_objects_management/public/management_section/types.ts new file mode 100644 index 0000000000000..541746534b84a --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/types.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 { SavedObjectReference } from '../../../../core/types'; + +export interface ObjectField { + type: FieldType; + name: string; + value: any; +} + +export type FieldType = 'text' | 'number' | 'boolean' | 'array' | 'json'; + +export interface FieldState { + value?: any; + invalid?: boolean; +} + +export interface SubmittedFormData { + attributes: any; + references: SavedObjectReference[]; +} diff --git a/src/plugins/saved_objects_management/public/mocks.ts b/src/plugins/saved_objects_management/public/mocks.ts index 8cf23afe87907..1de3de8e85302 100644 --- a/src/plugins/saved_objects_management/public/mocks.ts +++ b/src/plugins/saved_objects_management/public/mocks.ts @@ -17,23 +17,27 @@ * under the License. */ -import { actionRegistryMock } from './services/action_registry.mock'; +import { actionServiceMock } from './services/action_service.mock'; +import { serviceRegistryMock } from './services/service_registry.mock'; import { SavedObjectsManagementPluginSetup, SavedObjectsManagementPluginStart } from './plugin'; const createSetupContractMock = (): jest.Mocked => { const mock = { - actionRegistry: actionRegistryMock.create(), + actions: actionServiceMock.createSetup(), + serviceRegistry: serviceRegistryMock.create(), }; return mock; }; const createStartContractMock = (): jest.Mocked => { - const mock = {}; + const mock = { + actions: actionServiceMock.createStart(), + }; return mock; }; export const savedObjectsManagementPluginMock = { - createActionRegistry: actionRegistryMock.create, + createServiceRegistry: serviceRegistryMock.create, createSetupContract: createSetupContractMock, createStartContract: createStartContractMock, }; diff --git a/src/plugins/saved_objects_management/public/plugin.test.ts b/src/plugins/saved_objects_management/public/plugin.test.ts index 1cafbb235ad5b..09080f46a6869 100644 --- a/src/plugins/saved_objects_management/public/plugin.test.ts +++ b/src/plugins/saved_objects_management/public/plugin.test.ts @@ -20,6 +20,9 @@ import { coreMock } from '../../../core/public/mocks'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { homePluginMock } from '../../home/public/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { managementPluginMock } from '../../management/public/mocks'; +import { dataPluginMock } from '../../data/public/mocks'; import { SavedObjectsManagementPlugin } from './plugin'; describe('SavedObjectsManagementPlugin', () => { @@ -31,10 +34,13 @@ describe('SavedObjectsManagementPlugin', () => { describe('#setup', () => { it('registers the saved_objects feature to the home plugin', async () => { - const coreSetup = coreMock.createSetup(); + const coreSetup = coreMock.createSetup({ + pluginStartDeps: { data: dataPluginMock.createStartContract() }, + }); const homeSetup = homePluginMock.createSetupContract(); + const managementSetup = managementPluginMock.createSetupContract(); - await plugin.setup(coreSetup, { home: homeSetup }); + await plugin.setup(coreSetup, { home: homeSetup, management: managementSetup }); expect(homeSetup.featureCatalogue.register).toHaveBeenCalledTimes(1); expect(homeSetup.featureCatalogue.register).toHaveBeenCalledWith( diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index 3f2e9c166058e..c8dede3da9263 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -19,37 +19,59 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { ManagementSetup } from '../../management/public'; +import { DataPublicPluginStart } from '../../data/public'; +import { DashboardStart } from '../../dashboard/public'; +import { DiscoverStart } from '../../discover/public'; import { HomePublicPluginSetup, FeatureCatalogueCategory } from '../../home/public'; +import { VisualizationsStart } from '../../visualizations/public'; import { - SavedObjectsManagementActionRegistry, - ISavedObjectsManagementActionRegistry, + SavedObjectsManagementActionService, + SavedObjectsManagementActionServiceSetup, + SavedObjectsManagementActionServiceStart, + SavedObjectsManagementServiceRegistry, + ISavedObjectsManagementServiceRegistry, } from './services'; +import { registerServices } from './register_services'; export interface SavedObjectsManagementPluginSetup { - actionRegistry: ISavedObjectsManagementActionRegistry; + actions: SavedObjectsManagementActionServiceSetup; + serviceRegistry: ISavedObjectsManagementServiceRegistry; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SavedObjectsManagementPluginStart {} +export interface SavedObjectsManagementPluginStart { + actions: SavedObjectsManagementActionServiceStart; +} export interface SetupDependencies { + management: ManagementSetup; home: HomePublicPluginSetup; } +export interface StartDependencies { + data: DataPublicPluginStart; + dashboard?: DashboardStart; + visualizations?: VisualizationsStart; + discover?: DiscoverStart; +} + export class SavedObjectsManagementPlugin implements Plugin< SavedObjectsManagementPluginSetup, SavedObjectsManagementPluginStart, SetupDependencies, - {} + StartDependencies > { - private actionRegistry = new SavedObjectsManagementActionRegistry(); + private actionService = new SavedObjectsManagementActionService(); + private serviceRegistry = new SavedObjectsManagementServiceRegistry(); public setup( - core: CoreSetup<{}>, - { home }: SetupDependencies + core: CoreSetup, + { home, management }: SetupDependencies ): SavedObjectsManagementPluginSetup { + const actionSetup = this.actionService.setup(); + home.featureCatalogue.register({ id: 'saved_objects', title: i18n.translate('savedObjectsManagement.objects.savedObjectsTitle', { @@ -65,12 +87,39 @@ export class SavedObjectsManagementPlugin category: FeatureCatalogueCategory.ADMIN, }); + const kibanaSection = management.sections.getSection('kibana'); + if (!kibanaSection) { + throw new Error('`kibana` management section not found.'); + } + kibanaSection.registerApp({ + id: 'objects', + title: i18n.translate('savedObjectsManagement.managementSectionLabel', { + defaultMessage: 'Saved Objects', + }), + order: 10, + mount: async mountParams => { + const { mountManagementSection } = await import('./management_section'); + return mountManagementSection({ + core, + serviceRegistry: this.serviceRegistry, + mountParams, + }); + }, + }); + + // depends on `getStartServices`, should not be awaited + registerServices(this.serviceRegistry, core.getStartServices); + return { - actionRegistry: this.actionRegistry, + actions: actionSetup, + serviceRegistry: this.serviceRegistry, }; } public start(core: CoreStart) { - return {}; + const actionStart = this.actionService.start(); + return { + actions: actionStart, + }; } } diff --git a/src/plugins/saved_objects_management/public/register_services.ts b/src/plugins/saved_objects_management/public/register_services.ts new file mode 100644 index 0000000000000..a34b632b78f6c --- /dev/null +++ b/src/plugins/saved_objects_management/public/register_services.ts @@ -0,0 +1,59 @@ +/* + * 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 { StartServicesAccessor } from '../../../core/public'; +import { SavedObjectsManagementPluginStart, StartDependencies } from './plugin'; +import { ISavedObjectsManagementServiceRegistry } from './services'; + +export const registerServices = async ( + registry: ISavedObjectsManagementServiceRegistry, + getStartServices: StartServicesAccessor +) => { + const [coreStart, { dashboard, data, visualizations, discover }] = await getStartServices(); + + if (dashboard) { + registry.register({ + id: 'savedDashboards', + title: 'dashboards', + service: dashboard.getSavedDashboardLoader(), + }); + } + + if (visualizations) { + registry.register({ + id: 'savedVisualizations', + title: 'visualizations', + service: visualizations.savedVisualizationsLoader, + }); + } + + if (discover) { + registry.register({ + id: 'savedSearches', + title: 'searches', + service: discover.savedSearches.createLoader({ + savedObjectsClient: coreStart.savedObjects.client, + indexPatterns: data.indexPatterns, + search: data.search, + chrome: coreStart.chrome, + overlays: coreStart.overlays, + }), + }); + } +}; diff --git a/src/plugins/saved_objects_management/public/services/action_registry.mock.ts b/src/plugins/saved_objects_management/public/services/action_registry.mock.ts deleted file mode 100644 index a9093ad42d0ac..0000000000000 --- a/src/plugins/saved_objects_management/public/services/action_registry.mock.ts +++ /dev/null @@ -1,37 +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 { ISavedObjectsManagementActionRegistry } from './action_registry'; - -const createRegistryMock = (): jest.Mocked => { - const mock = { - register: jest.fn(), - has: jest.fn(), - getAll: jest.fn(), - }; - - mock.has.mockReturnValue(true); - mock.getAll.mockReturnValue([]); - - return mock; -}; - -export const actionRegistryMock = { - create: createRegistryMock, -}; diff --git a/src/plugins/saved_objects_management/public/services/action_registry.test.ts b/src/plugins/saved_objects_management/public/services/action_registry.test.ts deleted file mode 100644 index eb3bda00f4196..0000000000000 --- a/src/plugins/saved_objects_management/public/services/action_registry.test.ts +++ /dev/null @@ -1,76 +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 { SavedObjectsManagementActionRegistry } from './action_registry'; -import { SavedObjectsManagementAction } from './action_types'; - -class DummyAction extends SavedObjectsManagementAction { - constructor(public id: string) { - super(); - } - - public euiAction = { - name: 'name', - description: 'description', - icon: 'icon', - type: 'type', - }; - - public render = () => ''; -} - -describe('SavedObjectsManagementActionRegistry', () => { - let registry: SavedObjectsManagementActionRegistry; - - const createAction = (id: string): SavedObjectsManagementAction => { - return new DummyAction(id); - }; - - beforeEach(() => { - registry = new SavedObjectsManagementActionRegistry(); - }); - - describe('#register', () => { - it('allows actions to be registered and retrieved', () => { - const action = createAction('foo'); - registry.register(action); - expect(registry.getAll()).toContain(action); - }); - - it('does not allow actions with duplicate ids to be registered', () => { - const action = createAction('my-action'); - registry.register(action); - expect(() => registry.register(action)).toThrowErrorMatchingInlineSnapshot( - `"Saved Objects Management Action with id 'my-action' already exists"` - ); - }); - }); - - describe('#has', () => { - it('returns true when an action with a matching ID exists', () => { - const action = createAction('existing-action'); - registry.register(action); - expect(registry.has('existing-action')).toEqual(true); - }); - - it(`returns false when an action doesn't exist`, () => { - expect(registry.has('missing-action')).toEqual(false); - }); - }); -}); diff --git a/src/plugins/saved_objects_management/public/services/action_registry.ts b/src/plugins/saved_objects_management/public/services/action_registry.ts deleted file mode 100644 index 8bf77231dd73f..0000000000000 --- a/src/plugins/saved_objects_management/public/services/action_registry.ts +++ /dev/null @@ -1,52 +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 { SavedObjectsManagementAction } from './action_types'; - -export type ISavedObjectsManagementActionRegistry = PublicMethodsOf< - SavedObjectsManagementActionRegistry ->; - -export class SavedObjectsManagementActionRegistry { - private readonly actions = new Map(); - - /** - * register given action in the registry. - */ - register(action: SavedObjectsManagementAction) { - if (this.actions.has(action.id)) { - throw new Error(`Saved Objects Management Action with id '${action.id}' already exists`); - } - this.actions.set(action.id, action); - } - - /** - * return true if the registry contains given action, false otherwise. - */ - has(actionId: string) { - return this.actions.has(actionId); - } - - /** - * return all {@link SavedObjectsManagementAction | actions} currently registered. - */ - getAll() { - return [...this.actions.values()]; - } -} diff --git a/src/plugins/saved_objects_management/public/services/action_service.mock.ts b/src/plugins/saved_objects_management/public/services/action_service.mock.ts new file mode 100644 index 0000000000000..97c95a589b925 --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/action_service.mock.ts @@ -0,0 +1,57 @@ +/* + * 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 { + SavedObjectsManagementActionService, + SavedObjectsManagementActionServiceSetup, + SavedObjectsManagementActionServiceStart, +} from './action_service'; + +const createSetupMock = (): jest.Mocked => { + const mock = { + register: jest.fn(), + }; + return mock; +}; + +const createStartMock = (): jest.Mocked => { + const mock = { + has: jest.fn(), + getAll: jest.fn(), + }; + + mock.has.mockReturnValue(true); + mock.getAll.mockReturnValue([]); + + return mock; +}; + +const createServiceMock = (): jest.Mocked> => { + const mock = { + setup: jest.fn().mockReturnValue(createSetupMock()), + start: jest.fn().mockReturnValue(createStartMock()), + }; + return mock; +}; + +export const actionServiceMock = { + create: createServiceMock, + createSetup: createSetupMock, + createStart: createStartMock, +}; diff --git a/src/plugins/saved_objects_management/public/services/action_service.test.ts b/src/plugins/saved_objects_management/public/services/action_service.test.ts new file mode 100644 index 0000000000000..107554589f83d --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/action_service.test.ts @@ -0,0 +1,84 @@ +/* + * 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 { + SavedObjectsManagementActionService, + SavedObjectsManagementActionServiceSetup, +} from './action_service'; +import { SavedObjectsManagementAction } from './types'; + +class DummyAction extends SavedObjectsManagementAction { + constructor(public id: string) { + super(); + } + + public euiAction = { + name: 'name', + description: 'description', + icon: 'icon', + type: 'type', + }; + + public render = () => ''; +} + +describe('SavedObjectsManagementActionRegistry', () => { + let service: SavedObjectsManagementActionService; + let setup: SavedObjectsManagementActionServiceSetup; + + const createAction = (id: string): SavedObjectsManagementAction => { + return new DummyAction(id); + }; + + beforeEach(() => { + service = new SavedObjectsManagementActionService(); + setup = service.setup(); + }); + + describe('#register', () => { + it('allows actions to be registered and retrieved', () => { + const action = createAction('foo'); + setup.register(action); + const start = service.start(); + expect(start.getAll()).toContain(action); + }); + + it('does not allow actions with duplicate ids to be registered', () => { + const action = createAction('my-action'); + setup.register(action); + expect(() => setup.register(action)).toThrowErrorMatchingInlineSnapshot( + `"Saved Objects Management Action with id 'my-action' already exists"` + ); + }); + }); + + describe('#has', () => { + it('returns true when an action with a matching ID exists', () => { + const action = createAction('existing-action'); + setup.register(action); + const start = service.start(); + expect(start.has('existing-action')).toEqual(true); + }); + + it(`returns false when an action doesn't exist`, () => { + const start = service.start(); + expect(start.has('missing-action')).toEqual(false); + }); + }); +}); diff --git a/src/plugins/saved_objects_management/public/services/action_service.ts b/src/plugins/saved_objects_management/public/services/action_service.ts new file mode 100644 index 0000000000000..2b0b4cf5431e5 --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/action_service.ts @@ -0,0 +1,60 @@ +/* + * 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 { SavedObjectsManagementAction } from './types'; + +export interface SavedObjectsManagementActionServiceSetup { + /** + * register given action in the registry. + */ + register: (action: SavedObjectsManagementAction) => void; +} + +export interface SavedObjectsManagementActionServiceStart { + /** + * return true if the registry contains given action, false otherwise. + */ + has: (actionId: string) => boolean; + /** + * return all {@link SavedObjectsManagementAction | actions} currently registered. + */ + getAll: () => SavedObjectsManagementAction[]; +} + +export class SavedObjectsManagementActionService { + private readonly actions = new Map(); + + setup(): SavedObjectsManagementActionServiceSetup { + return { + register: action => { + if (this.actions.has(action.id)) { + throw new Error(`Saved Objects Management Action with id '${action.id}' already exists`); + } + this.actions.set(action.id, action); + }, + }; + } + + start(): SavedObjectsManagementActionServiceStart { + return { + has: actionId => this.actions.has(actionId), + getAll: () => [...this.actions.values()], + }; + } +} diff --git a/src/plugins/saved_objects_management/public/services/index.ts b/src/plugins/saved_objects_management/public/services/index.ts index d6353576b8e11..a59ad9012c402 100644 --- a/src/plugins/saved_objects_management/public/services/index.ts +++ b/src/plugins/saved_objects_management/public/services/index.ts @@ -18,7 +18,13 @@ */ export { - SavedObjectsManagementActionRegistry, - ISavedObjectsManagementActionRegistry, -} from './action_registry'; -export { SavedObjectsManagementAction, SavedObjectsManagementRecord } from './action_types'; + SavedObjectsManagementActionService, + SavedObjectsManagementActionServiceStart, + SavedObjectsManagementActionServiceSetup, +} from './action_service'; +export { + SavedObjectsManagementServiceRegistry, + ISavedObjectsManagementServiceRegistry, + SavedObjectsManagementServiceRegistryEntry, +} from './service_registry'; +export { SavedObjectsManagementAction, SavedObjectsManagementRecord } from './types'; diff --git a/src/plugins/saved_objects_management/public/services/service_registry.mock.ts b/src/plugins/saved_objects_management/public/services/service_registry.mock.ts new file mode 100644 index 0000000000000..2e671c781928f --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/service_registry.mock.ts @@ -0,0 +1,36 @@ +/* + * 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 { ISavedObjectsManagementServiceRegistry } from './service_registry'; + +const createRegistryMock = (): jest.Mocked => { + const mock = { + register: jest.fn(), + all: jest.fn(), + get: jest.fn(), + }; + + mock.all.mockReturnValue([]); + + return mock; +}; + +export const serviceRegistryMock = { + create: createRegistryMock, +}; diff --git a/src/plugins/saved_objects_management/public/services/service_registry.ts b/src/plugins/saved_objects_management/public/services/service_registry.ts new file mode 100644 index 0000000000000..2d6ec0b92047a --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/service_registry.ts @@ -0,0 +1,49 @@ +/* + * 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 { SavedObjectLoader } from '../../../saved_objects/public'; + +export interface SavedObjectsManagementServiceRegistryEntry { + id: string; + service: SavedObjectLoader; + title: string; +} + +export type ISavedObjectsManagementServiceRegistry = PublicMethodsOf< + SavedObjectsManagementServiceRegistry +>; + +export class SavedObjectsManagementServiceRegistry { + private readonly registry = new Map(); + + public register(entry: SavedObjectsManagementServiceRegistryEntry) { + if (this.registry.has(entry.id)) { + throw new Error(''); + } + this.registry.set(entry.id, entry); + } + + public all(): SavedObjectsManagementServiceRegistryEntry[] { + return [...this.registry.values()]; + } + + public get(id: string): SavedObjectsManagementServiceRegistryEntry | undefined { + return this.registry.get(id); + } +} diff --git a/src/plugins/saved_objects_management/public/services/action_types.ts b/src/plugins/saved_objects_management/public/services/types.ts similarity index 100% rename from src/plugins/saved_objects_management/public/services/action_types.ts rename to src/plugins/saved_objects_management/public/services/types.ts diff --git a/src/plugins/saved_objects_management/public/types.ts b/src/plugins/saved_objects_management/public/types.ts new file mode 100644 index 0000000000000..e91b5d253b55f --- /dev/null +++ b/src/plugins/saved_objects_management/public/types.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 { SavedObjectMetadata, SavedObjectWithMetadata, SavedObjectRelation } from '../common'; diff --git a/src/plugins/saved_objects_management/server/routes/get_allowed_types.ts b/src/plugins/saved_objects_management/server/routes/get_allowed_types.ts new file mode 100644 index 0000000000000..ab5bec6678946 --- /dev/null +++ b/src/plugins/saved_objects_management/server/routes/get_allowed_types.ts @@ -0,0 +1,40 @@ +/* + * 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 { IRouter } from 'src/core/server'; + +export const registerGetAllowedTypesRoute = (router: IRouter) => { + router.get( + { + path: '/api/kibana/management/saved_objects/_allowed_types', + validate: false, + }, + async (context, req, res) => { + const allowedTypes = context.core.savedObjects.typeRegistry + .getImportableAndExportableTypes() + .map(type => type.name); + + return res.ok({ + body: { + types: allowedTypes, + }, + }); + } + ); +}; diff --git a/src/plugins/saved_objects_management/server/routes/index.test.ts b/src/plugins/saved_objects_management/server/routes/index.test.ts index f183972953dce..237760444f04e 100644 --- a/src/plugins/saved_objects_management/server/routes/index.test.ts +++ b/src/plugins/saved_objects_management/server/routes/index.test.ts @@ -34,7 +34,7 @@ describe('registerRoutes', () => { }); expect(httpSetup.createRouter).toHaveBeenCalledTimes(1); - expect(router.get).toHaveBeenCalledTimes(2); + expect(router.get).toHaveBeenCalledTimes(3); expect(router.post).toHaveBeenCalledTimes(2); expect(router.get).toHaveBeenCalledWith( @@ -49,6 +49,12 @@ describe('registerRoutes', () => { }), expect.any(Function) ); + expect(router.get).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/kibana/management/saved_objects/_allowed_types', + }), + expect.any(Function) + ); expect(router.post).toHaveBeenCalledWith( expect.objectContaining({ path: '/api/kibana/management/saved_objects/scroll/counts', diff --git a/src/plugins/saved_objects_management/server/routes/index.ts b/src/plugins/saved_objects_management/server/routes/index.ts index 2c6adb71ed3ce..0929de56b215e 100644 --- a/src/plugins/saved_objects_management/server/routes/index.ts +++ b/src/plugins/saved_objects_management/server/routes/index.ts @@ -23,6 +23,7 @@ import { registerFindRoute } from './find'; import { registerScrollForCountRoute } from './scroll_count'; import { registerScrollForExportRoute } from './scroll_export'; import { registerRelationshipsRoute } from './relationships'; +import { registerGetAllowedTypesRoute } from './get_allowed_types'; interface RegisterRouteOptions { http: HttpServiceSetup; @@ -35,4 +36,5 @@ export function registerRoutes({ http, managementServicePromise }: RegisterRoute registerScrollForCountRoute(router); registerScrollForExportRoute(router); registerRelationshipsRoute(router, managementServicePromise); + registerGetAllowedTypesRoute(router); } diff --git a/src/plugins/saved_objects_management/server/services/management.test.ts b/src/plugins/saved_objects_management/server/services/management.test.ts index 6b95048749fae..3625a3f913444 100644 --- a/src/plugins/saved_objects_management/server/services/management.test.ts +++ b/src/plugins/saved_objects_management/server/services/management.test.ts @@ -28,7 +28,7 @@ describe('SavedObjectsManagement', () => { registry.registerType({ name: 'unknown', hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', mappings: { properties: {} }, migrations: {}, ...type, diff --git a/src/plugins/saved_objects_management/server/types.ts b/src/plugins/saved_objects_management/server/types.ts index 5c4763d357e87..bd17d6a19ae70 100644 --- a/src/plugins/saved_objects_management/server/types.ts +++ b/src/plugins/saved_objects_management/server/types.ts @@ -17,38 +17,10 @@ * under the License. */ -import { SavedObject } from 'src/core/server'; - // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SavedObjectsManagementPluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SavedObjectsManagementPluginStart {} -/** - * The metadata injected into a {@link SavedObject | saved object} when returning - * {@link SavedObjectWithMetadata | enhanced objects} from the plugin API endpoints. - */ -export interface SavedObjectMetadata { - icon?: string; - title?: string; - editUrl?: string; - inAppUrl?: { path: string; uiCapabilitiesPath: string }; -} - -/** - * A {@link SavedObject | saved object} enhanced with meta properties used by the client-side plugin. - */ -export type SavedObjectWithMetadata = SavedObject & { - meta: SavedObjectMetadata; -}; - -/** - * Represents a relation between two {@link SavedObject | saved object} - */ -export interface SavedObjectRelation { - id: string; - type: string; - relationship: 'child' | 'parent'; - meta: SavedObjectMetadata; -} +export { SavedObjectMetadata, SavedObjectWithMetadata, SavedObjectRelation } from '../common'; diff --git a/src/plugins/share/server/routes/goto.ts b/src/plugins/share/server/routes/goto.ts index 0c5b74915e58a..747af3b9e57df 100644 --- a/src/plugins/share/server/routes/goto.ts +++ b/src/plugins/share/server/routes/goto.ts @@ -34,7 +34,7 @@ export const createGotoRoute = ({ shortUrlLookup: ShortUrlLookupService; http: CoreSetup['http']; }) => { - router.get( + http.resources.register( { path: getGotoPath('{urlId}'), validate: { @@ -63,14 +63,8 @@ export const createGotoRoute = ({ }, }); } - const body = await context.core.rendering.render(); - return response.ok({ - headers: { - 'content-security-policy': http.csp.header, - }, - body, - }); + return response.renderCoreApp(); }) ); }; diff --git a/src/plugins/telemetry/server/collectors/application_usage/saved_objects_types.ts b/src/plugins/telemetry/server/collectors/application_usage/saved_objects_types.ts index 9f997ab7b5df3..a0de79da565e6 100644 --- a/src/plugins/telemetry/server/collectors/application_usage/saved_objects_types.ts +++ b/src/plugins/telemetry/server/collectors/application_usage/saved_objects_types.ts @@ -33,7 +33,7 @@ export function registerMappings(registerType: SavedObjectsServiceSetup['registe registerType({ name: 'application_usage_totals', hidden: false, - namespaceAgnostic: true, + namespaceType: 'agnostic', mappings: { properties: { appId: { type: 'keyword' }, @@ -46,7 +46,7 @@ export function registerMappings(registerType: SavedObjectsServiceSetup['registe registerType({ name: 'application_usage_transactional', hidden: false, - namespaceAgnostic: true, + namespaceType: 'agnostic', mappings: { properties: { timestamp: { type: 'date' }, diff --git a/src/plugins/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts b/src/plugins/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts index 3f6e1836cac7d..603742f612a6b 100644 --- a/src/plugins/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts +++ b/src/plugins/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts @@ -38,7 +38,7 @@ export function registerUiMetricUsageCollector( registerType({ name: 'ui-metric', hidden: false, - namespaceAgnostic: true, + namespaceType: 'agnostic', mappings: { properties: { count: { diff --git a/src/plugins/telemetry/server/plugin.ts b/src/plugins/telemetry/server/plugin.ts index 1df6a665e4d76..d1530c272027a 100644 --- a/src/plugins/telemetry/server/plugin.ts +++ b/src/plugins/telemetry/server/plugin.ts @@ -125,7 +125,7 @@ export class TelemetryPlugin implements Plugin { registerType({ name: 'telemetry', hidden: false, - namespaceAgnostic: true, + namespaceType: 'agnostic', mappings: { properties: { enabled: { diff --git a/src/plugins/telemetry_collection_manager/server/encryption/encrypt.test.mocks.ts b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.test.mocks.ts new file mode 100644 index 0000000000000..9a7cb8ba28d04 --- /dev/null +++ b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.test.mocks.ts @@ -0,0 +1,27 @@ +/* + * 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 const mockEncrypt = jest.fn(); +export const createRequestEncryptor = jest.fn().mockResolvedValue({ + encrypt: mockEncrypt, +}); + +jest.doMock('@elastic/request-crypto', () => ({ + createRequestEncryptor, +})); diff --git a/src/plugins/telemetry_collection_manager/server/encryption/encrypt.test.ts b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.test.ts index 4a4ba7aa1f321..c04625eb1dd42 100644 --- a/src/plugins/telemetry_collection_manager/server/encryption/encrypt.test.ts +++ b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.test.ts @@ -16,16 +16,9 @@ * specific language governing permissions and limitations * under the License. */ - +import { createRequestEncryptor, mockEncrypt } from './encrypt.test.mocks'; import { telemetryJWKS } from './telemetry_jwks'; import { encryptTelemetry, getKID } from './encrypt'; -import { createRequestEncryptor } from '@elastic/request-crypto'; - -jest.mock('@elastic/request-crypto', () => ({ - createRequestEncryptor: jest.fn().mockResolvedValue({ - encrypt: jest.fn(), - }), -})); describe('getKID', () => { it(`returns 'kibana_dev' kid for development`, async () => { @@ -42,9 +35,25 @@ describe('getKID', () => { }); describe('encryptTelemetry', () => { + afterEach(() => { + mockEncrypt.mockReset(); + }); + it('encrypts payload', async () => { const payload = { some: 'value' }; - await encryptTelemetry(payload, true); + await encryptTelemetry(payload, { isProd: true }); expect(createRequestEncryptor).toBeCalledWith(telemetryJWKS); }); + + it('uses kibana kid on { isProd: true }', async () => { + const payload = { some: 'value' }; + await encryptTelemetry(payload, { isProd: true }); + expect(mockEncrypt).toBeCalledWith('kibana', payload); + }); + + it('uses kibana_dev kid on { isProd: false }', async () => { + const payload = { some: 'value' }; + await encryptTelemetry(payload, { isProd: false }); + expect(mockEncrypt).toBeCalledWith('kibana_dev', payload); + }); }); diff --git a/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts index c20f4b768b7dc..44f053064cfcb 100644 --- a/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts +++ b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts @@ -24,7 +24,7 @@ export function getKID(isProd = false): string { return isProd ? 'kibana' : 'kibana_dev'; } -export async function encryptTelemetry(payload: any, isProd = false): Promise { +export async function encryptTelemetry(payload: any, { isProd = false } = {}): Promise { const kid = getKID(isProd); const encryptor = await createRequestEncryptor(telemetryJWKS); const clusters = [].concat(payload); diff --git a/src/plugins/telemetry_collection_manager/server/plugin.ts b/src/plugins/telemetry_collection_manager/server/plugin.ts index 7e8dff9e0aec1..f2f20e215c535 100644 --- a/src/plugins/telemetry_collection_manager/server/plugin.ts +++ b/src/plugins/telemetry_collection_manager/server/plugin.ts @@ -158,7 +158,7 @@ export class TelemetryCollectionManagerPlugin if (config.unencrypted) { return optInStats; } - return encryptTelemetry(optInStats, this.isDev); + return encryptTelemetry(optInStats, { isProd: !this.isDev }); } } catch (err) { this.logger.debug(`Failed to collect any opt in stats with registered collections.`); @@ -205,7 +205,8 @@ export class TelemetryCollectionManagerPlugin if (config.unencrypted) { return usageData; } - return encryptTelemetry(usageData, this.isDev); + + return encryptTelemetry(usageData, { isProd: !this.isDev }); } } catch (err) { this.logger.debug( diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts index f532c2c8aa219..feaa1f6a60e2f 100644 --- a/src/plugins/ui_actions/public/actions/action.ts +++ b/src/plugins/ui_actions/public/actions/action.ts @@ -63,9 +63,11 @@ export interface Action { isCompatible(context: Context): Promise; /** - * If this returns something truthy, this is used in addition to the `execute` method when clicked. + * If this returns something truthy, this will be used as [href] attribute on a link if possible (e.g. in context menu item) + * to support right click -> open in a new tab behavior. + * For regular click navigation is prevented and `execute()` takes control. */ - getHref?(context: Context): string | undefined; + getHref?(context: Context): Promise; /** * Executes the action. diff --git a/src/plugins/ui_actions/public/actions/action_definition.ts b/src/plugins/ui_actions/public/actions/action_definition.ts index 3eaa13572a826..79fda78401abd 100644 --- a/src/plugins/ui_actions/public/actions/action_definition.ts +++ b/src/plugins/ui_actions/public/actions/action_definition.ts @@ -63,7 +63,7 @@ export interface ActionDefinition { /** * If this returns something truthy, this is used in addition to the `execute` method when clicked. */ - getHref?(context: ActionContextMapping[T]): string | undefined; + getHref?(context: ActionContextMapping[T]): Promise; /** * Executes the action. diff --git a/src/plugins/ui_actions/public/actions/create_action.ts b/src/plugins/ui_actions/public/actions/create_action.ts index 90a9415c0b497..cc66f221e4082 100644 --- a/src/plugins/ui_actions/public/actions/create_action.ts +++ b/src/plugins/ui_actions/public/actions/create_action.ts @@ -28,7 +28,6 @@ export function createAction(action: ActionDefinition): id: action.type, isCompatible: () => Promise.resolve(true), getDisplayName: () => '', - getHref: () => undefined, ...action, }; } diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index 3dce2c1f4c257..d26740ffdf033 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -71,7 +71,7 @@ async function buildEuiContextMenuPanelItems({ } items.push( - convertPanelActionToContextMenuItem({ + await convertPanelActionToContextMenuItem({ action, actionContext, closeMenu, @@ -88,9 +88,9 @@ async function buildEuiContextMenuPanelItems({ * * @param {ContextMenuAction} action * @param {Embeddable} embeddable - * @return {EuiContextMenuPanelItemDescriptor} + * @return {Promise} */ -function convertPanelActionToContextMenuItem({ +async function convertPanelActionToContextMenuItem({ action, actionContext, closeMenu, @@ -98,7 +98,7 @@ function convertPanelActionToContextMenuItem({ action: Action; actionContext: A; closeMenu: () => void; -}): EuiContextMenuPanelItemDescriptor { +}): Promise { const menuPanelItem: EuiContextMenuPanelItemDescriptor = { name: action.MenuItem ? React.createElement(uiToReactComponent(action.MenuItem), { @@ -110,13 +110,33 @@ function convertPanelActionToContextMenuItem({ 'data-test-subj': `embeddablePanelAction-${action.id}`, }; - menuPanelItem.onClick = () => { - action.execute(actionContext); + menuPanelItem.onClick = event => { + if (event.currentTarget instanceof HTMLAnchorElement) { + // from react-router's + if ( + !event.defaultPrevented && // onClick prevented default + event.button === 0 && // ignore everything but left clicks + (!event.currentTarget.target || event.currentTarget.target === '_self') && // let browser handle "target=_blank" etc. + !(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) // ignore clicks with modifier keys + ) { + event.preventDefault(); + action.execute(actionContext); + } else { + // let browser handle navigation + } + } else { + // not a link + action.execute(actionContext); + } + closeMenu(); }; - if (action.getHref && action.getHref(actionContext)) { - menuPanelItem.href = action.getHref(actionContext); + if (action.getHref) { + const href = await action.getHref(actionContext); + if (href) { + menuPanelItem.href = href; + } } return menuPanelItem; diff --git a/src/plugins/ui_actions/public/triggers/trigger_internal.ts b/src/plugins/ui_actions/public/triggers/trigger_internal.ts index 5b670df354f78..1fc92d7c0cb1b 100644 --- a/src/plugins/ui_actions/public/triggers/trigger_internal.ts +++ b/src/plugins/ui_actions/public/triggers/trigger_internal.ts @@ -55,13 +55,6 @@ export class TriggerInternal { action: Action, context: TriggerContextMapping[T] ) { - const href = action.getHref && action.getHref(context); - - if (href) { - window.location.href = href; - return; - } - await action.execute(context); } diff --git a/src/plugins/vis_default_editor/public/components/controls/components/number_list/utils.ts b/src/plugins/vis_default_editor/public/components/controls/components/number_list/utils.ts index c2ac63c98cbea..dac86249ebbb9 100644 --- a/src/plugins/vis_default_editor/public/components/controls/components/number_list/utils.ts +++ b/src/plugins/vis_default_editor/public/components/controls/components/number_list/utils.ts @@ -92,7 +92,7 @@ function validateValueUnique( isDuplicate: false, }; - if (inputValue && list.indexOf(inputValue) !== index) { + if (inputValue !== EMPTY_STRING && list.indexOf(inputValue) !== index) { result.isDuplicate = true; result.error = i18n.translate( 'visDefaultEditor.controls.numberList.duplicateValueErrorMessage', diff --git a/src/plugins/vis_default_editor/public/default_editor.tsx b/src/plugins/vis_default_editor/public/default_editor.tsx index f1963b94dcf95..1c2ddbc314f99 100644 --- a/src/plugins/vis_default_editor/public/default_editor.tsx +++ b/src/plugins/vis_default_editor/public/default_editor.tsx @@ -19,7 +19,7 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; -import { EditorRenderProps } from 'src/legacy/core_plugins/kibana/public/visualize/np_ready/types'; +import { EditorRenderProps } from 'src/plugins/visualize/public'; import { PanelsContainer, Panel } from '../../kibana_react/public'; import './vis_type_agg_filter'; diff --git a/src/plugins/vis_default_editor/public/default_editor_controller.tsx b/src/plugins/vis_default_editor/public/default_editor_controller.tsx index 798da09f8e30b..014c69f50d558 100644 --- a/src/plugins/vis_default_editor/public/default_editor_controller.tsx +++ b/src/plugins/vis_default_editor/public/default_editor_controller.tsx @@ -22,7 +22,7 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { i18n } from '@kbn/i18n'; import { EventEmitter } from 'events'; -import { EditorRenderProps } from 'src/legacy/core_plugins/kibana/public/visualize/np_ready/types'; +import { EditorRenderProps } from 'src/plugins/visualize/public'; import { Vis, VisualizeEmbeddableContract } from 'src/plugins/visualizations/public'; import { Storage } from '../../kibana_utils/public'; import { KibanaContextProvider } from '../../kibana_react/public'; diff --git a/src/plugins/vis_type_markdown/config.ts b/src/plugins/vis_type_markdown/config.ts new file mode 100644 index 0000000000000..6749bd83de39f --- /dev/null +++ b/src/plugins/vis_type_markdown/config.ts @@ -0,0 +1,26 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), +}); + +export type ConfigSchema = TypeOf; diff --git a/src/plugins/vis_type_markdown/kibana.json b/src/plugins/vis_type_markdown/kibana.json new file mode 100644 index 0000000000000..d52e22118ccf0 --- /dev/null +++ b/src/plugins/vis_type_markdown/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "visTypeMarkdown", + "version": "kibana", + "ui": true, + "server": true, + "requiredPlugins": ["expressions", "visualizations"] +} diff --git a/src/legacy/core_plugins/vis_type_markdown/public/__snapshots__/markdown_fn.test.ts.snap b/src/plugins/vis_type_markdown/public/__snapshots__/markdown_fn.test.ts.snap similarity index 100% rename from src/legacy/core_plugins/vis_type_markdown/public/__snapshots__/markdown_fn.test.ts.snap rename to src/plugins/vis_type_markdown/public/__snapshots__/markdown_fn.test.ts.snap diff --git a/src/legacy/core_plugins/vis_type_markdown/public/_markdown_vis.scss b/src/plugins/vis_type_markdown/public/_markdown_vis.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_markdown/public/_markdown_vis.scss rename to src/plugins/vis_type_markdown/public/_markdown_vis.scss diff --git a/src/plugins/vis_type_markdown/public/index.scss b/src/plugins/vis_type_markdown/public/index.scss new file mode 100644 index 0000000000000..ddb7fe3a6b0d9 --- /dev/null +++ b/src/plugins/vis_type_markdown/public/index.scss @@ -0,0 +1,8 @@ +// Prefix all styles with "mkd" to avoid conflicts. +// Examples +// mkdChart +// mkdChart__legend +// mkdChart__legend--small +// mkdChart__legend-isLoading + +@import './markdown_vis'; diff --git a/src/plugins/vis_type_markdown/public/index.ts b/src/plugins/vis_type_markdown/public/index.ts new file mode 100644 index 0000000000000..bc3b6ff7bf105 --- /dev/null +++ b/src/plugins/vis_type_markdown/public/index.ts @@ -0,0 +1,25 @@ +/* + * 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 { PluginInitializerContext } from '../../../core/public'; +import { MarkdownPlugin as Plugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new Plugin(initializerContext); +} diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_fn.test.ts b/src/plugins/vis_type_markdown/public/markdown_fn.test.ts similarity index 89% rename from src/legacy/core_plugins/vis_type_markdown/public/markdown_fn.test.ts rename to src/plugins/vis_type_markdown/public/markdown_fn.test.ts index 5f41840bac99b..d6085804e74ab 100644 --- a/src/legacy/core_plugins/vis_type_markdown/public/markdown_fn.test.ts +++ b/src/plugins/vis_type_markdown/public/markdown_fn.test.ts @@ -17,8 +17,7 @@ * under the License. */ -// eslint-disable-next-line -import { functionWrapper } from '../../../../plugins/expressions/common/expression_functions/specs/tests/utils'; +import { functionWrapper } from '../../expressions/common/expression_functions/specs/tests/utils'; import { createMarkdownVisFn } from './markdown_fn'; describe('interpreter/functions#markdown', () => { diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_fn.ts b/src/plugins/vis_type_markdown/public/markdown_fn.ts similarity index 95% rename from src/legacy/core_plugins/vis_type_markdown/public/markdown_fn.ts rename to src/plugins/vis_type_markdown/public/markdown_fn.ts index bbf2b7844c73f..9f0809109e465 100644 --- a/src/legacy/core_plugins/vis_type_markdown/public/markdown_fn.ts +++ b/src/plugins/vis_type_markdown/public/markdown_fn.ts @@ -18,7 +18,7 @@ */ import { i18n } from '@kbn/i18n'; -import { ExpressionFunctionDefinition, Render } from '../../../../plugins/expressions/public'; +import { ExpressionFunctionDefinition, Render } from '../../expressions/public'; import { Arguments, MarkdownVisParams } from './types'; interface RenderValue { diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx b/src/plugins/vis_type_markdown/public/markdown_options.tsx similarity index 100% rename from src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx rename to src/plugins/vis_type_markdown/public/markdown_options.tsx diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis.ts b/src/plugins/vis_type_markdown/public/markdown_vis.ts similarity index 96% rename from src/legacy/core_plugins/vis_type_markdown/public/markdown_vis.ts rename to src/plugins/vis_type_markdown/public/markdown_vis.ts index 57ea6d9c9bb3d..b84d9638eb973 100644 --- a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis.ts +++ b/src/plugins/vis_type_markdown/public/markdown_vis.ts @@ -22,7 +22,7 @@ import { i18n } from '@kbn/i18n'; import { MarkdownVisWrapper } from './markdown_vis_controller'; import { MarkdownOptions } from './markdown_options'; import { SettingsOptions } from './settings_options'; -import { DefaultEditorSize } from '../../../../plugins/vis_default_editor/public'; +import { DefaultEditorSize } from '../../vis_default_editor/public'; export const markdownVisDefinition = { name: 'markdown', diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.test.tsx b/src/plugins/vis_type_markdown/public/markdown_vis_controller.test.tsx similarity index 100% rename from src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.test.tsx rename to src/plugins/vis_type_markdown/public/markdown_vis_controller.test.tsx diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx b/src/plugins/vis_type_markdown/public/markdown_vis_controller.tsx similarity index 97% rename from src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx rename to src/plugins/vis_type_markdown/public/markdown_vis_controller.tsx index 3260e9f7d8091..4e77bb196b713 100644 --- a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx +++ b/src/plugins/vis_type_markdown/public/markdown_vis_controller.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; -import { Markdown } from '../../../../plugins/kibana_react/public'; +import { Markdown } from '../../kibana_react/public'; import { MarkdownVisParams } from './types'; interface MarkdownVisComponentProps extends MarkdownVisParams { diff --git a/src/plugins/vis_type_markdown/public/plugin.ts b/src/plugins/vis_type_markdown/public/plugin.ts new file mode 100644 index 0000000000000..9365017a31adc --- /dev/null +++ b/src/plugins/vis_type_markdown/public/plugin.ts @@ -0,0 +1,52 @@ +/* + * 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 { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; +import { VisualizationsSetup } from '../../visualizations/public'; + +import { markdownVisDefinition } from './markdown_vis'; +import { createMarkdownVisFn } from './markdown_fn'; +import { ConfigSchema } from '../config'; + +import './index.scss'; + +/** @internal */ +export interface MarkdownPluginSetupDependencies { + expressions: ReturnType; + visualizations: VisualizationsSetup; +} + +/** @internal */ +export class MarkdownPlugin implements Plugin { + initializerContext: PluginInitializerContext; + + constructor(initializerContext: PluginInitializerContext) { + this.initializerContext = initializerContext; + } + + public setup(core: CoreSetup, { expressions, visualizations }: MarkdownPluginSetupDependencies) { + visualizations.createReactVisualization(markdownVisDefinition); + expressions.registerFunction(createMarkdownVisFn); + } + + public start(core: CoreStart) { + // nothing to do here yet + } +} diff --git a/src/legacy/core_plugins/vis_type_markdown/public/settings_options.tsx b/src/plugins/vis_type_markdown/public/settings_options.tsx similarity index 96% rename from src/legacy/core_plugins/vis_type_markdown/public/settings_options.tsx rename to src/plugins/vis_type_markdown/public/settings_options.tsx index 552fd63373554..6f6a80564ce07 100644 --- a/src/legacy/core_plugins/vis_type_markdown/public/settings_options.tsx +++ b/src/plugins/vis_type_markdown/public/settings_options.tsx @@ -22,7 +22,7 @@ import { EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { VisOptionsProps } from 'src/plugins/vis_default_editor/public'; -import { RangeOption, SwitchOption } from '../../vis_type_vislib/public'; +import { RangeOption, SwitchOption } from '../../charts/public'; import { MarkdownVisParams } from './types'; function SettingsOptions({ stateParams, setValue }: VisOptionsProps) { diff --git a/src/legacy/core_plugins/vis_type_markdown/public/types.ts b/src/plugins/vis_type_markdown/public/types.ts similarity index 100% rename from src/legacy/core_plugins/vis_type_markdown/public/types.ts rename to src/plugins/vis_type_markdown/public/types.ts diff --git a/src/plugins/vis_type_markdown/server/index.ts b/src/plugins/vis_type_markdown/server/index.ts new file mode 100644 index 0000000000000..73e1712353b1a --- /dev/null +++ b/src/plugins/vis_type_markdown/server/index.ts @@ -0,0 +1,34 @@ +/* + * 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 { PluginConfigDescriptor } from 'kibana/server'; + +import { configSchema, ConfigSchema } from '../config'; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + deprecations: ({ renameFromRoot }) => [ + renameFromRoot('markdown_vis.enabled', 'vis_type_markdown.enabled'), + ], +}; + +export const plugin = () => ({ + setup() {}, + start() {}, +}); diff --git a/src/plugins/vis_type_metric/config.ts b/src/plugins/vis_type_metric/config.ts new file mode 100644 index 0000000000000..6749bd83de39f --- /dev/null +++ b/src/plugins/vis_type_metric/config.ts @@ -0,0 +1,26 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), +}); + +export type ConfigSchema = TypeOf; diff --git a/src/plugins/vis_type_metric/kibana.json b/src/plugins/vis_type_metric/kibana.json new file mode 100644 index 0000000000000..24135d257b317 --- /dev/null +++ b/src/plugins/vis_type_metric/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "visTypeMetric", + "version": "8.0.0", + "kibanaVersion": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["data", "visualizations", "charts","expressions"] +} diff --git a/src/legacy/core_plugins/vis_type_metric/public/__snapshots__/metric_vis_fn.test.ts.snap b/src/plugins/vis_type_metric/public/__snapshots__/metric_vis_fn.test.ts.snap similarity index 100% rename from src/legacy/core_plugins/vis_type_metric/public/__snapshots__/metric_vis_fn.test.ts.snap rename to src/plugins/vis_type_metric/public/__snapshots__/metric_vis_fn.test.ts.snap diff --git a/src/legacy/core_plugins/vis_type_metric/public/_metric_vis.scss b/src/plugins/vis_type_metric/public/_metric_vis.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_metric/public/_metric_vis.scss rename to src/plugins/vis_type_metric/public/_metric_vis.scss diff --git a/src/legacy/core_plugins/vis_type_metric/public/components/__snapshots__/metric_vis_component.test.tsx.snap b/src/plugins/vis_type_metric/public/components/__snapshots__/metric_vis_component.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/vis_type_metric/public/components/__snapshots__/metric_vis_component.test.tsx.snap rename to src/plugins/vis_type_metric/public/components/__snapshots__/metric_vis_component.test.tsx.snap diff --git a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.test.tsx b/src/plugins/vis_type_metric/public/components/metric_vis_component.test.tsx similarity index 97% rename from src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.test.tsx rename to src/plugins/vis_type_metric/public/components/metric_vis_component.test.tsx index 2bd423656b0f0..3969b28d75414 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.test.tsx +++ b/src/plugins/vis_type_metric/public/components/metric_vis_component.test.tsx @@ -21,7 +21,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { MetricVisComponent, MetricVisComponentProps } from './metric_vis_component'; -import { ExprVis } from '../../../../../plugins/visualizations/public'; +import { ExprVis } from '../../../visualizations/public'; jest.mock('../services', () => ({ getFormatService: () => ({ diff --git a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.tsx b/src/plugins/vis_type_metric/public/components/metric_vis_component.tsx similarity index 96% rename from src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.tsx rename to src/plugins/vis_type_metric/public/components/metric_vis_component.tsx index de2cc66a99c79..eb3986b6388fe 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.tsx +++ b/src/plugins/vis_type_metric/public/components/metric_vis_component.tsx @@ -22,12 +22,12 @@ import React, { Component } from 'react'; import { isColorDark } from '@elastic/eui'; import { MetricVisValue } from './metric_vis_value'; import { Input } from '../metric_vis_fn'; -import { FieldFormatsContentType, IFieldFormat } from '../../../../../plugins/data/public'; -import { KibanaDatatable } from '../../../../../plugins/expressions/public'; -import { getHeatmapColors } from '../../../../../plugins/charts/public'; +import { FieldFormatsContentType, IFieldFormat } from '../../../data/public'; +import { KibanaDatatable } from '../../../expressions/public'; +import { getHeatmapColors } from '../../../charts/public'; import { VisParams, MetricVisMetric } from '../types'; import { getFormatService } from '../services'; -import { SchemaConfig, ExprVis } from '../../../../../plugins/visualizations/public'; +import { SchemaConfig, ExprVis } from '../../../visualizations/public'; export interface MetricVisComponentProps { visParams: VisParams; diff --git a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_options.tsx b/src/plugins/vis_type_metric/public/components/metric_vis_options.tsx similarity index 97% rename from src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_options.tsx rename to src/plugins/vis_type_metric/public/components/metric_vis_options.tsx index 5c3032511f09a..009d63ded39b4 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_options.tsx +++ b/src/plugins/vis_type_metric/public/components/metric_vis_options.tsx @@ -37,9 +37,9 @@ import { SwitchOption, RangeOption, SetColorSchemaOptionsValue, -} from '../../../vis_type_vislib/public'; + SetColorRangeValue, +} from '../../../charts/public'; import { MetricVisParam, VisParams } from '../types'; -import { SetColorRangeValue } from '../../../vis_type_vislib/public/components/common/color_ranges'; function MetricVisOptions({ stateParams, diff --git a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_value.test.tsx b/src/plugins/vis_type_metric/public/components/metric_vis_value.test.tsx similarity index 100% rename from src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_value.test.tsx rename to src/plugins/vis_type_metric/public/components/metric_vis_value.test.tsx diff --git a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_value.tsx b/src/plugins/vis_type_metric/public/components/metric_vis_value.tsx similarity index 100% rename from src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_value.tsx rename to src/plugins/vis_type_metric/public/components/metric_vis_value.tsx diff --git a/src/plugins/vis_type_metric/public/index.scss b/src/plugins/vis_type_metric/public/index.scss new file mode 100644 index 0000000000000..638f9ac1ef93a --- /dev/null +++ b/src/plugins/vis_type_metric/public/index.scss @@ -0,0 +1,8 @@ +// Prefix all styles with "mtr" to avoid conflicts. +// Examples +// mtrChart +// mtrChart__legend +// mtrChart__legend--small +// mtrChart__legend-isLoading + +@import 'metric_vis'; diff --git a/src/plugins/vis_type_metric/public/index.ts b/src/plugins/vis_type_metric/public/index.ts new file mode 100644 index 0000000000000..3d3e1879a51d9 --- /dev/null +++ b/src/plugins/vis_type_metric/public/index.ts @@ -0,0 +1,25 @@ +/* + * 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 './index.scss'; +import { PluginInitializerContext } from 'kibana/public'; +import { MetricVisPlugin as Plugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new Plugin(initializerContext); +} diff --git a/src/legacy/core_plugins/vis_type_metric/public/metric_vis_fn.test.ts b/src/plugins/vis_type_metric/public/metric_vis_fn.test.ts similarity index 90% rename from src/legacy/core_plugins/vis_type_metric/public/metric_vis_fn.test.ts rename to src/plugins/vis_type_metric/public/metric_vis_fn.test.ts index 4094cd4eff060..3ed8f8f79a83f 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/metric_vis_fn.test.ts +++ b/src/plugins/vis_type_metric/public/metric_vis_fn.test.ts @@ -18,10 +18,7 @@ */ import { createMetricVisFn } from './metric_vis_fn'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { functionWrapper } from '../../../../plugins/expressions/common/expression_functions/specs/tests/utils'; - -jest.mock('ui/new_platform'); +import { functionWrapper } from '../../expressions/common/expression_functions/specs/tests/utils'; describe('interpreter/functions#metric', () => { const fn = functionWrapper(createMetricVisFn()); diff --git a/src/legacy/core_plugins/vis_type_metric/public/metric_vis_fn.ts b/src/plugins/vis_type_metric/public/metric_vis_fn.ts similarity index 96% rename from src/legacy/core_plugins/vis_type_metric/public/metric_vis_fn.ts rename to src/plugins/vis_type_metric/public/metric_vis_fn.ts index 03b412c6fff15..3d16fed0fa385 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/metric_vis_fn.ts +++ b/src/plugins/vis_type_metric/public/metric_vis_fn.ts @@ -25,10 +25,9 @@ import { Range, Render, Style, -} from '../../../../plugins/expressions/public'; -import { ColorModes } from '../../vis_type_vislib/public'; +} from '../../expressions/public'; import { visType, DimensionsVisParam, VisParams } from './types'; -import { ColorSchemas, vislibColorMaps } from '../../../../plugins/charts/public'; +import { ColorSchemas, vislibColorMaps, ColorModes } from '../../charts/public'; export type Input = KibanaDatatable; diff --git a/src/legacy/core_plugins/vis_type_metric/public/metric_vis_type.test.ts b/src/plugins/vis_type_metric/public/metric_vis_type.test.ts similarity index 97% rename from src/legacy/core_plugins/vis_type_metric/public/metric_vis_type.test.ts rename to src/plugins/vis_type_metric/public/metric_vis_type.test.ts index 706693eff1007..636118c692aaa 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/metric_vis_type.test.ts +++ b/src/plugins/vis_type_metric/public/metric_vis_type.test.ts @@ -20,8 +20,6 @@ import { createMetricVisTypeDefinition } from './metric_vis_type'; import { MetricVisComponent } from './components/metric_vis_component'; -jest.mock('ui/new_platform'); - describe('metric_vis - createMetricVisTypeDefinition', () => { it('has metric vis component set', () => { const def = createMetricVisTypeDefinition(); diff --git a/src/legacy/core_plugins/vis_type_metric/public/metric_vis_type.ts b/src/plugins/vis_type_metric/public/metric_vis_type.ts similarity index 92% rename from src/legacy/core_plugins/vis_type_metric/public/metric_vis_type.ts rename to src/plugins/vis_type_metric/public/metric_vis_type.ts index 3bbb8964122e5..b7e9213283bee 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/metric_vis_type.ts +++ b/src/plugins/vis_type_metric/public/metric_vis_type.ts @@ -21,10 +21,9 @@ import { i18n } from '@kbn/i18n'; import { MetricVisComponent } from './components/metric_vis_component'; import { MetricVisOptions } from './components/metric_vis_options'; -import { ColorModes } from '../../vis_type_vislib/public'; -import { ColorSchemas, colorSchemas } from '../../../../plugins/charts/public'; -import { AggGroupNames } from '../../../../plugins/data/public'; -import { Schemas } from '../../../../plugins/vis_default_editor/public'; +import { ColorSchemas, colorSchemas, ColorModes } from '../../charts/public'; +import { AggGroupNames } from '../../data/public'; +import { Schemas } from '../../vis_default_editor/public'; export const createMetricVisTypeDefinition = () => ({ name: 'metric', diff --git a/src/plugins/vis_type_metric/public/plugin.ts b/src/plugins/vis_type_metric/public/plugin.ts new file mode 100644 index 0000000000000..a3951fa46c21c --- /dev/null +++ b/src/plugins/vis_type_metric/public/plugin.ts @@ -0,0 +1,62 @@ +/* + * 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 { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'kibana/public'; +import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; +import { VisualizationsSetup } from '../../visualizations/public'; + +import { createMetricVisFn } from './metric_vis_fn'; +import { createMetricVisTypeDefinition } from './metric_vis_type'; +import { ChartsPluginSetup } from '../../charts/public'; +import { DataPublicPluginStart } from '../../data/public'; +import { setFormatService } from './services'; +import { ConfigSchema } from '../config'; + +/** @internal */ +export interface MetricVisPluginSetupDependencies { + expressions: ReturnType; + visualizations: VisualizationsSetup; + charts: ChartsPluginSetup; +} + +/** @internal */ +export interface MetricVisPluginStartDependencies { + data: DataPublicPluginStart; +} + +/** @internal */ +export class MetricVisPlugin implements Plugin { + initializerContext: PluginInitializerContext; + + constructor(initializerContext: PluginInitializerContext) { + this.initializerContext = initializerContext; + } + + public setup( + core: CoreSetup, + { expressions, visualizations, charts }: MetricVisPluginSetupDependencies + ) { + expressions.registerFunction(createMetricVisFn); + visualizations.createReactVisualization(createMetricVisTypeDefinition()); + } + + public start(core: CoreStart, { data }: MetricVisPluginStartDependencies) { + setFormatService(data.fieldFormats); + } +} diff --git a/src/plugins/vis_type_metric/public/services.ts b/src/plugins/vis_type_metric/public/services.ts new file mode 100644 index 0000000000000..681afbaf0b268 --- /dev/null +++ b/src/plugins/vis_type_metric/public/services.ts @@ -0,0 +1,25 @@ +/* + * 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 { createGetterSetter } from '../../kibana_utils/common'; +import { DataPublicPluginStart } from '../../data/public'; + +export const [getFormatService, setFormatService] = createGetterSetter< + DataPublicPluginStart['fieldFormats'] +>('metric data.fieldFormats'); diff --git a/src/plugins/vis_type_metric/public/types.ts b/src/plugins/vis_type_metric/public/types.ts new file mode 100644 index 0000000000000..e1f2c7721a426 --- /dev/null +++ b/src/plugins/vis_type_metric/public/types.ts @@ -0,0 +1,57 @@ +/* + * 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 { Range } from '../../expressions/public'; +import { SchemaConfig } from '../../visualizations/public'; +import { ColorModes, Labels, Style, ColorSchemas } from '../../charts/public'; + +export const visType = 'metric'; + +export interface DimensionsVisParam { + metrics: SchemaConfig[]; + bucket?: SchemaConfig; +} + +export interface MetricVisParam { + percentageMode: boolean; + useRanges: boolean; + colorSchema: ColorSchemas; + metricColorMode: ColorModes; + colorsRange: Range[]; + labels: Labels; + invertColors: boolean; + style: Style; +} + +export interface VisParams { + addTooltip: boolean; + addLegend: boolean; + dimensions: DimensionsVisParam; + metric: MetricVisParam; + type: typeof visType; +} + +export interface MetricVisMetric { + value: any; + label: string; + color?: string; + bgColor?: string; + lightText: boolean; + rowIndex: number; +} diff --git a/src/plugins/vis_type_metric/server/index.ts b/src/plugins/vis_type_metric/server/index.ts new file mode 100644 index 0000000000000..ce550dc81dd85 --- /dev/null +++ b/src/plugins/vis_type_metric/server/index.ts @@ -0,0 +1,34 @@ +/* + * 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 { PluginConfigDescriptor } from 'kibana/server'; + +import { configSchema, ConfigSchema } from '../config'; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + deprecations: ({ renameFromRoot }) => [ + renameFromRoot('metric_vis.enabled', 'vis_type_metric.enabled'), + ], +}; + +export const plugin = () => ({ + setup() {}, + start() {}, +}); diff --git a/src/plugins/vis_type_timeseries/common/agg_lookup.js b/src/plugins/vis_type_timeseries/common/agg_lookup.js index 4dfdc83dcfabb..432da03e3d45d 100644 --- a/src/plugins/vis_type_timeseries/common/agg_lookup.js +++ b/src/plugins/vis_type_timeseries/common/agg_lookup.js @@ -97,6 +97,9 @@ export const lookup = { defaultMessage: 'Static Value', }), top_hit: i18n.translate('visTypeTimeseries.aggLookup.topHitLabel', { defaultMessage: 'Top Hit' }), + positive_rate: i18n.translate('visTypeTimeseries.aggLookup.positiveRateLabel', { + defaultMessage: 'Positive Rate', + }), }; const pipeline = [ diff --git a/src/plugins/vis_type_timeseries/common/calculate_label.js b/src/plugins/vis_type_timeseries/common/calculate_label.js index 756d6e57a83e8..71aa0aed7dc11 100644 --- a/src/plugins/vis_type_timeseries/common/calculate_label.js +++ b/src/plugins/vis_type_timeseries/common/calculate_label.js @@ -70,6 +70,12 @@ export function calculateLabel(metric, metrics) { defaultMessage: 'Filter Ratio', }); } + if (metric.type === 'positive_rate') { + return i18n.translate('visTypeTimeseries.calculateLabel.positiveRateLabel', { + defaultMessage: 'Positive Rate of {field}', + values: { field: metric.field }, + }); + } if (metric.type === 'static') { return i18n.translate('visTypeTimeseries.calculateLabel.staticValueLabel', { defaultMessage: 'Static Value of {metricValue}', diff --git a/src/plugins/vis_type_timeseries/kibana.json b/src/plugins/vis_type_timeseries/kibana.json index d77f4ac92da16..38662c6a7ff89 100644 --- a/src/plugins/vis_type_timeseries/kibana.json +++ b/src/plugins/vis_type_timeseries/kibana.json @@ -1,7 +1,9 @@ { - "id": "metrics", + "id": "visTypeTimeseries", "version": "8.0.0", "kibanaVersion": "kibana", "server": true, + "ui": true, + "requiredPlugins": ["data", "expressions", "visualizations"], "optionalPlugins": ["usageCollection"] } diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/_mixins.scss b/src/plugins/vis_type_timeseries/public/application/_mixins.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/_mixins.scss rename to src/plugins/vis_type_timeseries/public/application/_mixins.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/_tvb_editor.scss b/src/plugins/vis_type_timeseries/public/application/_tvb_editor.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/_tvb_editor.scss rename to src/plugins/vis_type_timeseries/public/application/_tvb_editor.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/_variables.scss b/src/plugins/vis_type_timeseries/public/application/_variables.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/_variables.scss rename to src/plugins/vis_type_timeseries/public/application/_variables.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/_annotations_editor.scss b/src/plugins/vis_type_timeseries/public/application/components/_annotations_editor.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/_annotations_editor.scss rename to src/plugins/vis_type_timeseries/public/application/components/_annotations_editor.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/_color_picker.scss b/src/plugins/vis_type_timeseries/public/application/components/_color_picker.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/_color_picker.scss rename to src/plugins/vis_type_timeseries/public/application/components/_color_picker.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/_color_rules.scss b/src/plugins/vis_type_timeseries/public/application/components/_color_rules.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/_color_rules.scss rename to src/plugins/vis_type_timeseries/public/application/components/_color_rules.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/_custom_color_picker.scss b/src/plugins/vis_type_timeseries/public/application/components/_custom_color_picker.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/_custom_color_picker.scss rename to src/plugins/vis_type_timeseries/public/application/components/_custom_color_picker.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/_error.scss b/src/plugins/vis_type_timeseries/public/application/components/_error.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/_error.scss rename to src/plugins/vis_type_timeseries/public/application/components/_error.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/_index.scss b/src/plugins/vis_type_timeseries/public/application/components/_index.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/_index.scss rename to src/plugins/vis_type_timeseries/public/application/components/_index.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/_markdown_editor.scss b/src/plugins/vis_type_timeseries/public/application/components/_markdown_editor.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/_markdown_editor.scss rename to src/plugins/vis_type_timeseries/public/application/components/_markdown_editor.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/_no_data.scss b/src/plugins/vis_type_timeseries/public/application/components/_no_data.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/_no_data.scss rename to src/plugins/vis_type_timeseries/public/application/components/_no_data.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/_series_editor.scss b/src/plugins/vis_type_timeseries/public/application/components/_series_editor.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/_series_editor.scss rename to src/plugins/vis_type_timeseries/public/application/components/_series_editor.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/_vis_editor.scss b/src/plugins/vis_type_timeseries/public/application/components/_vis_editor.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/_vis_editor.scss rename to src/plugins/vis_type_timeseries/public/application/components/_vis_editor.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/_vis_editor_visualization.scss b/src/plugins/vis_type_timeseries/public/application/components/_vis_editor_visualization.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/_vis_editor_visualization.scss rename to src/plugins/vis_type_timeseries/public/application/components/_vis_editor_visualization.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/_vis_picker.scss b/src/plugins/vis_type_timeseries/public/application/components/_vis_picker.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/_vis_picker.scss rename to src/plugins/vis_type_timeseries/public/application/components/_vis_picker.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/_vis_with_splits.scss b/src/plugins/vis_type_timeseries/public/application/components/_vis_with_splits.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/_vis_with_splits.scss rename to src/plugins/vis_type_timeseries/public/application/components/_vis_with_splits.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/add_delete_buttons.js b/src/plugins/vis_type_timeseries/public/application/components/add_delete_buttons.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/add_delete_buttons.js rename to src/plugins/vis_type_timeseries/public/application/components/add_delete_buttons.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/add_delete_buttons.test.js b/src/plugins/vis_type_timeseries/public/application/components/add_delete_buttons.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/add_delete_buttons.test.js rename to src/plugins/vis_type_timeseries/public/application/components/add_delete_buttons.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/_agg_row.scss b/src/plugins/vis_type_timeseries/public/application/components/aggs/_agg_row.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/_agg_row.scss rename to src/plugins/vis_type_timeseries/public/application/components/aggs/_agg_row.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/_index.scss b/src/plugins/vis_type_timeseries/public/application/components/aggs/_index.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/_index.scss rename to src/plugins/vis_type_timeseries/public/application/components/aggs/_index.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/agg.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/agg.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/agg.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/agg_row.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_row.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/agg_row.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/agg_row.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/agg_select.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.js similarity index 98% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/agg_select.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.js index f93dee14d0eed..8607ff184dfaa 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/agg_select.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.js @@ -49,6 +49,12 @@ const metricAggs = [ }), value: 'filter_ratio', }, + { + label: i18n.translate('visTypeTimeseries.aggSelect.metricsAggs.positiveRateLabel', { + defaultMessage: 'Positive Rate', + }), + value: 'positive_rate', + }, { label: i18n.translate('visTypeTimeseries.aggSelect.metricsAggs.maxLabel', { defaultMessage: 'Max', diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/aggs.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/aggs.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/aggs.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/aggs.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/calculation.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/calculation.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/calculation.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/calculation.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/cumulative_sum.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/cumulative_sum.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/cumulative_sum.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/cumulative_sum.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/derivative.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/derivative.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/derivative.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/derivative.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/field_select.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/field_select.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/filter_ratio.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/filter_ratio.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/math.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/math.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/math.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/math.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/metric_select.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/metric_select.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/metric_select.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/metric_select.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/moving_average.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/moving_average.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/moving_average.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/moving_average.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_rank/index.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/index.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_rank/index.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/index.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_rank/multi_value_row.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/multi_value_row.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_rank/multi_value_row.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/multi_value_row.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_rank/percentile_rank.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_rank/percentile_rank.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_rank/percentile_rank_values.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank_values.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_rank/percentile_rank_values.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank_values.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_ui.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_ui.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_ui.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_ui.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/positive_only.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_only.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/positive_only.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/positive_only.js diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js new file mode 100644 index 0000000000000..39558fa3a9224 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js @@ -0,0 +1,191 @@ +/* + * 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 PropTypes from 'prop-types'; +import React from 'react'; +import { AggSelect } from './agg_select'; +import { FieldSelect } from './field_select'; +import { AggRow } from './agg_row'; +import { createChangeHandler } from '../lib/create_change_handler'; +import { createSelectHandler } from '../lib/create_select_handler'; +import { + htmlIdGenerator, + EuiFlexGroup, + EuiFlexItem, + EuiFormLabel, + EuiFormRow, + EuiSpacer, + EuiText, + EuiLink, + EuiComboBox, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { KBN_FIELD_TYPES } from '../../../../../../plugins/data/public'; + +const UNIT_OPTIONS = [ + { + label: i18n.translate('visTypeTimeseries.units.auto', { defaultMessage: 'auto' }), + value: '', + }, + { + label: i18n.translate('visTypeTimeseries.units.perMillisecond', { + defaultMessage: 'per millisecond', + }), + value: '1ms', + }, + { + label: i18n.translate('visTypeTimeseries.units.perSecond', { defaultMessage: 'per second' }), + value: '1s', + }, + { + label: i18n.translate('visTypeTimeseries.units.perMinute', { defaultMessage: 'per minute' }), + value: '1m', + }, + { + label: i18n.translate('visTypeTimeseries.units.perHour', { defaultMessage: 'per hour' }), + value: '1h', + }, + { + label: i18n.translate('visTypeTimeseries.units.perDay', { defaultMessage: 'per day' }), + value: '1d', + }, +]; + +export const PositiveRateAgg = props => { + const defaults = { unit: '' }; + const model = { ...defaults, ...props.model }; + + const handleChange = createChangeHandler(props.onChange, model); + const handleSelectChange = createSelectHandler(handleChange); + + const htmlId = htmlIdGenerator(); + const indexPattern = + (props.series.override_index_pattern && props.series.series_index_pattern) || + props.panel.index_pattern; + + const selectedUnitOptions = UNIT_OPTIONS.filter(o => o.value === model.unit); + + return ( + + + + + + + + + + + + } + fullWidth + > + + + + + + } + fullWidth + > + + + + + + +

+ + + + ), + }} + /> +

+
+
+ ); +}; + +PositiveRateAgg.propTypes = { + disableDelete: PropTypes.bool, + fields: PropTypes.object, + model: PropTypes.object, + onAdd: PropTypes.func, + onChange: PropTypes.func, + onDelete: PropTypes.func, + panel: PropTypes.object, + series: PropTypes.object, + siblings: PropTypes.array, +}; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/serial_diff.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/serial_diff.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/serial_diff.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/serial_diff.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/series_agg.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/series_agg.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/series_agg.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/series_agg.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/static.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/static.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/static.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/static.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/std_agg.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/std_agg.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/std_agg.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/std_agg.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/std_deviation.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/std_deviation.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/std_deviation.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/std_deviation.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/std_sibling.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/std_sibling.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/std_sibling.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/std_sibling.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/temporary_unsupported_agg.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/temporary_unsupported_agg.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/temporary_unsupported_agg.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/temporary_unsupported_agg.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/top_hit.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/top_hit.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/top_hit.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/top_hit.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/unsupported_agg.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/unsupported_agg.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/unsupported_agg.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/unsupported_agg.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/vars.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/vars.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/vars.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/vars.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/annotations_editor.js b/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/annotations_editor.js rename to src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/color_picker.js b/src/plugins/vis_type_timeseries/public/application/components/color_picker.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/color_picker.js rename to src/plugins/vis_type_timeseries/public/application/components/color_picker.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/color_picker.test.js b/src/plugins/vis_type_timeseries/public/application/components/color_picker.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/color_picker.test.js rename to src/plugins/vis_type_timeseries/public/application/components/color_picker.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/color_rules.js b/src/plugins/vis_type_timeseries/public/application/components/color_rules.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/color_rules.js rename to src/plugins/vis_type_timeseries/public/application/components/color_rules.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/color_rules.test.js b/src/plugins/vis_type_timeseries/public/application/components/color_rules.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/color_rules.test.js rename to src/plugins/vis_type_timeseries/public/application/components/color_rules.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/custom_color_picker.js b/src/plugins/vis_type_timeseries/public/application/components/custom_color_picker.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/custom_color_picker.js rename to src/plugins/vis_type_timeseries/public/application/components/custom_color_picker.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/data_format_picker.js b/src/plugins/vis_type_timeseries/public/application/components/data_format_picker.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/data_format_picker.js rename to src/plugins/vis_type_timeseries/public/application/components/data_format_picker.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/error.js b/src/plugins/vis_type_timeseries/public/application/components/error.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/error.js rename to src/plugins/vis_type_timeseries/public/application/components/error.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/icon_select/__snapshots__/icon_select.test.js.snap b/src/plugins/vis_type_timeseries/public/application/components/icon_select/__snapshots__/icon_select.test.js.snap similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/icon_select/__snapshots__/icon_select.test.js.snap rename to src/plugins/vis_type_timeseries/public/application/components/icon_select/__snapshots__/icon_select.test.js.snap diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/icon_select/icon_select.js b/src/plugins/vis_type_timeseries/public/application/components/icon_select/icon_select.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/icon_select/icon_select.js rename to src/plugins/vis_type_timeseries/public/application/components/icon_select/icon_select.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/icon_select/icon_select.test.js b/src/plugins/vis_type_timeseries/public/application/components/icon_select/icon_select.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/icon_select/icon_select.test.js rename to src/plugins/vis_type_timeseries/public/application/components/icon_select/icon_select.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/index_pattern.js b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/index_pattern.js rename to src/plugins/vis_type_timeseries/public/application/components/index_pattern.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/agg_to_component.js b/src/plugins/vis_type_timeseries/public/application/components/lib/agg_to_component.js similarity index 96% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/agg_to_component.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/agg_to_component.js index ca40d60f20848..a53192afafdcc 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/agg_to_component.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/agg_to_component.js @@ -33,6 +33,7 @@ import { PercentileRankAgg } from '../aggs/percentile_rank'; import { Static } from '../aggs/static'; import { MathAgg } from '../aggs/math'; import { TopHitAgg } from '../aggs/top_hit'; +import { PositiveRateAgg } from '../aggs/positive_rate'; export const aggToComponent = { count: StandardAgg, @@ -65,4 +66,5 @@ export const aggToComponent = { static: Static, math: MathAgg, top_hit: TopHitAgg, + positive_rate: PositiveRateAgg, }; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/calculate_siblings.js b/src/plugins/vis_type_timeseries/public/application/components/lib/calculate_siblings.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/calculate_siblings.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/calculate_siblings.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/calculate_siblings.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/calculate_siblings.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/calculate_siblings.test.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/calculate_siblings.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/charts.js b/src/plugins/vis_type_timeseries/public/application/components/lib/charts.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/charts.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/charts.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/collection_actions.js b/src/plugins/vis_type_timeseries/public/application/components/lib/collection_actions.js similarity index 82% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/collection_actions.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/collection_actions.js index 79cbe98b3d3db..3eae18111bb4d 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/collection_actions.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/collection_actions.js @@ -18,7 +18,6 @@ */ import uuid from 'uuid'; -import _ from 'lodash'; const newFn = () => ({ id: uuid.v1() }); @@ -30,9 +29,7 @@ export function handleChange(props, doc) { if (row.id === doc.id) return doc; return row; }); - if (_.isFunction(props.onChange)) { - props.onChange(_.assign({}, model, part)); - } + props.onChange?.({ ...model, ...part }); } export function handleDelete(props, doc) { @@ -40,20 +37,15 @@ export function handleDelete(props, doc) { const collection = model[name] || []; const part = {}; part[name] = collection.filter(row => row.id !== doc.id); - if (_.isFunction(props.onChange)) { - props.onChange(_.assign({}, model, part)); - } + props.onChange?.({ ...model, ...part }); } export function handleAdd(props, fn = newFn) { - if (!_.isFunction(fn)) fn = newFn; const { model, name } = props; const collection = model[name] || []; const part = {}; part[name] = collection.concat([fn()]); - if (_.isFunction(props.onChange)) { - props.onChange(_.assign({}, model, part)); - } + props.onChange?.({ ...model, ...part }); } export const collectionActions = { handleAdd, handleDelete, handleChange }; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/collection_actions.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/collection_actions.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/collection_actions.test.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/collection_actions.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/convert_series_to_vars.js b/src/plugins/vis_type_timeseries/public/application/components/lib/convert_series_to_vars.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/convert_series_to_vars.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/convert_series_to_vars.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/convert_series_to_vars.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/convert_series_to_vars.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/convert_series_to_vars.test.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/convert_series_to_vars.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_change_handler.js b/src/plugins/vis_type_timeseries/public/application/components/lib/create_change_handler.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_change_handler.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/create_change_handler.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_number_handler.js b/src/plugins/vis_type_timeseries/public/application/components/lib/create_number_handler.js similarity index 92% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_number_handler.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/create_number_handler.js index fd432add43295..3bc8fd87f73dd 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_number_handler.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/create_number_handler.js @@ -25,8 +25,6 @@ export const createNumberHandler = handleChange => { if (!detectIE() || e.keyCode === 13) e.preventDefault(); const value = Number(_.get(e, 'target.value', defaultValue)); - if (_.isFunction(handleChange)) { - return handleChange({ [name]: value }); - } + return handleChange?.({ [name]: value }); }; }; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_number_handler.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/create_number_handler.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_number_handler.test.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/create_number_handler.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_select_handler.js b/src/plugins/vis_type_timeseries/public/application/components/lib/create_select_handler.js similarity index 86% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_select_handler.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/create_select_handler.js index 30937b97d353f..cfc6a4dc57871 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_select_handler.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/create_select_handler.js @@ -21,10 +21,8 @@ import _ from 'lodash'; export const createSelectHandler = handleChange => { return name => selectedOptions => { - if (_.isFunction(handleChange)) { - return handleChange({ - [name]: _.get(selectedOptions, '[0].value', null), - }); - } + return handleChange?.({ + [name]: _.get(selectedOptions, '[0].value', null), + }); }; }; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_select_handler.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/create_select_handler.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_select_handler.test.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/create_select_handler.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_text_handler.js b/src/plugins/vis_type_timeseries/public/application/components/lib/create_text_handler.js similarity index 92% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_text_handler.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/create_text_handler.js index 8e2624488d954..82cc071b59d51 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_text_handler.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/create_text_handler.js @@ -26,8 +26,6 @@ export const createTextHandler = handleChange => { if (!detectIE() || e.keyCode === 13) e.preventDefault(); const value = _.get(e, 'target.value', defaultValue); - if (_.isFunction(handleChange)) { - return handleChange({ [name]: value }); - } + return handleChange?.({ [name]: value }); }; }; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_text_handler.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/create_text_handler.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_text_handler.test.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/create_text_handler.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_xaxis_formatter.js b/src/plugins/vis_type_timeseries/public/application/components/lib/create_xaxis_formatter.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/create_xaxis_formatter.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/create_xaxis_formatter.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/detect_ie.js b/src/plugins/vis_type_timeseries/public/application/components/lib/detect_ie.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/detect_ie.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/detect_ie.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/durations.js b/src/plugins/vis_type_timeseries/public/application/components/lib/durations.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/durations.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/durations.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/durations.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/durations.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/durations.test.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/durations.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/get_axis_label_string.js b/src/plugins/vis_type_timeseries/public/application/components/lib/get_axis_label_string.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/get_axis_label_string.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/get_axis_label_string.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/get_axis_label_string.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/get_axis_label_string.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/get_axis_label_string.test.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/get_axis_label_string.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/get_default_query_language.js b/src/plugins/vis_type_timeseries/public/application/components/lib/get_default_query_language.js similarity index 94% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/get_default_query_language.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/get_default_query_language.js index 26723da5ab5c9..972f937ad109d 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/get_default_query_language.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/get_default_query_language.js @@ -17,7 +17,7 @@ * under the License. */ -import { getUISettings } from '../../services'; +import { getUISettings } from '../../../services'; export function getDefaultQueryLanguage() { return getUISettings().get('search:queryLanguage'); diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/get_display_name.js b/src/plugins/vis_type_timeseries/public/application/components/lib/get_display_name.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/get_display_name.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/get_display_name.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/get_interval.js b/src/plugins/vis_type_timeseries/public/application/components/lib/get_interval.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/get_interval.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/get_interval.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/new_metric_agg_fn.js b/src/plugins/vis_type_timeseries/public/application/components/lib/new_metric_agg_fn.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/new_metric_agg_fn.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/new_metric_agg_fn.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/new_series_fn.js b/src/plugins/vis_type_timeseries/public/application/components/lib/new_series_fn.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/new_series_fn.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/new_series_fn.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/re_id_series.js b/src/plugins/vis_type_timeseries/public/application/components/lib/re_id_series.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/re_id_series.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/re_id_series.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/re_id_series.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/re_id_series.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/re_id_series.test.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/re_id_series.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/reorder.js b/src/plugins/vis_type_timeseries/public/application/components/lib/reorder.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/reorder.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/reorder.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/replace_vars.js b/src/plugins/vis_type_timeseries/public/application/components/lib/replace_vars.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/replace_vars.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/replace_vars.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/replace_vars.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/replace_vars.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/replace_vars.test.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/replace_vars.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/series_change_handler.js b/src/plugins/vis_type_timeseries/public/application/components/lib/series_change_handler.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/series_change_handler.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/series_change_handler.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/stacked.js b/src/plugins/vis_type_timeseries/public/application/components/lib/stacked.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/stacked.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/stacked.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/tick_formatter.js b/src/plugins/vis_type_timeseries/public/application/components/lib/tick_formatter.js similarity index 97% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/tick_formatter.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/tick_formatter.js index 3ab8e0f6b885e..fd316e66a16fb 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/tick_formatter.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/tick_formatter.js @@ -20,7 +20,7 @@ import handlebars from 'handlebars/dist/handlebars'; import { isNumber } from 'lodash'; import { inputFormats, outputFormats, isDuration } from '../lib/durations'; -import { getFieldFormats } from '../../services'; +import { getFieldFormats } from '../../../services'; export const createTickFormatter = (format = '0,0.[00]', template, getConfig = null) => { const fieldFormats = getFieldFormats(); diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/tick_formatter.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/tick_formatter.test.js similarity index 98% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/lib/tick_formatter.test.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/tick_formatter.test.js index e87cba126bb46..ee10b254a9e15 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/lib/tick_formatter.test.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/tick_formatter.test.js @@ -19,7 +19,7 @@ import { createTickFormatter } from './tick_formatter'; import { getFieldFormatsRegistry } from '../../../../../../test_utils/public/stub_field_formats'; -import { setFieldFormats } from '../../services'; +import { setFieldFormats } from '../../../services'; const mockUiSettings = { get: item => { diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/markdown_editor.js b/src/plugins/vis_type_timeseries/public/application/components/markdown_editor.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/markdown_editor.js rename to src/plugins/vis_type_timeseries/public/application/components/markdown_editor.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/no_data.js b/src/plugins/vis_type_timeseries/public/application/components/no_data.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/no_data.js rename to src/plugins/vis_type_timeseries/public/application/components/no_data.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config.js b/src/plugins/vis_type_timeseries/public/application/components/panel_config.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config.js rename to src/plugins/vis_type_timeseries/public/application/components/panel_config.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/_index.scss b/src/plugins/vis_type_timeseries/public/application/components/panel_config/_index.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/_index.scss rename to src/plugins/vis_type_timeseries/public/application/components/panel_config/_index.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/_panel_config.scss b/src/plugins/vis_type_timeseries/public/application/components/panel_config/_panel_config.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/_panel_config.scss rename to src/plugins/vis_type_timeseries/public/application/components/panel_config/_panel_config.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/gauge.js b/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/gauge.js rename to src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/gauge.test.js b/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/gauge.test.js rename to src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/markdown.js b/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/markdown.js rename to src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/metric.js b/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/metric.js rename to src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/table.js b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/table.js rename to src/plugins/vis_type_timeseries/public/application/components/panel_config/table.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/timeseries.js b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/timeseries.js rename to src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/top_n.js b/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/top_n.js rename to src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/query_bar_wrapper.js b/src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/query_bar_wrapper.js rename to src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/series.js b/src/plugins/vis_type_timeseries/public/application/components/series.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/series.js rename to src/plugins/vis_type_timeseries/public/application/components/series.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/series_config.js b/src/plugins/vis_type_timeseries/public/application/components/series_config.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/series_config.js rename to src/plugins/vis_type_timeseries/public/application/components/series_config.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/series_drag_handler.js b/src/plugins/vis_type_timeseries/public/application/components/series_drag_handler.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/series_drag_handler.js rename to src/plugins/vis_type_timeseries/public/application/components/series_drag_handler.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/series_editor.js b/src/plugins/vis_type_timeseries/public/application/components/series_editor.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/series_editor.js rename to src/plugins/vis_type_timeseries/public/application/components/series_editor.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/split.js b/src/plugins/vis_type_timeseries/public/application/components/split.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/split.js rename to src/plugins/vis_type_timeseries/public/application/components/split.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/splits/__snapshots__/terms.test.js.snap b/src/plugins/vis_type_timeseries/public/application/components/splits/__snapshots__/terms.test.js.snap similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/splits/__snapshots__/terms.test.js.snap rename to src/plugins/vis_type_timeseries/public/application/components/splits/__snapshots__/terms.test.js.snap diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/splits/everything.js b/src/plugins/vis_type_timeseries/public/application/components/splits/everything.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/splits/everything.js rename to src/plugins/vis_type_timeseries/public/application/components/splits/everything.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/splits/filter.js b/src/plugins/vis_type_timeseries/public/application/components/splits/filter.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/splits/filter.js rename to src/plugins/vis_type_timeseries/public/application/components/splits/filter.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/splits/filter_items.js b/src/plugins/vis_type_timeseries/public/application/components/splits/filter_items.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/splits/filter_items.js rename to src/plugins/vis_type_timeseries/public/application/components/splits/filter_items.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/splits/filters.js b/src/plugins/vis_type_timeseries/public/application/components/splits/filters.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/splits/filters.js rename to src/plugins/vis_type_timeseries/public/application/components/splits/filters.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/splits/group_by_select.js b/src/plugins/vis_type_timeseries/public/application/components/splits/group_by_select.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/splits/group_by_select.js rename to src/plugins/vis_type_timeseries/public/application/components/splits/group_by_select.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/splits/terms.js b/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/splits/terms.js rename to src/plugins/vis_type_timeseries/public/application/components/splits/terms.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/splits/terms.test.js b/src/plugins/vis_type_timeseries/public/application/components/splits/terms.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/splits/terms.test.js rename to src/plugins/vis_type_timeseries/public/application/components/splits/terms.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/splits/unsupported_split.js b/src/plugins/vis_type_timeseries/public/application/components/splits/unsupported_split.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/splits/unsupported_split.js rename to src/plugins/vis_type_timeseries/public/application/components/splits/unsupported_split.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/svg/bomb_icon.js b/src/plugins/vis_type_timeseries/public/application/components/svg/bomb_icon.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/svg/bomb_icon.js rename to src/plugins/vis_type_timeseries/public/application/components/svg/bomb_icon.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/svg/fire_icon.js b/src/plugins/vis_type_timeseries/public/application/components/svg/fire_icon.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/svg/fire_icon.js rename to src/plugins/vis_type_timeseries/public/application/components/svg/fire_icon.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor.js b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js similarity index 99% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_editor.js index b4845696fc8c0..7075e86eb56bf 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js @@ -30,7 +30,7 @@ import { createBrushHandler } from '../lib/create_brush_handler'; import { fetchFields } from '../lib/fetch_fields'; import { extractIndexPatterns } from '../../../../../plugins/vis_type_timeseries/common/extract_index_patterns'; import { esKuery } from '../../../../../plugins/data/public'; -import { getSavedObjectsClient, getUISettings, getDataStart, getCoreStart } from '../services'; +import { getSavedObjectsClient, getUISettings, getDataStart, getCoreStart } from '../../services'; import { CoreStartContextProvider } from '../contexts/query_input_bar_context'; import { KibanaContextProvider } from '../../../../../plugins/kibana_react/public'; @@ -96,7 +96,7 @@ export class VisEditor extends Component { return true; }; - handleChange = async partialModel => { + handleChange = partialModel => { if (isEmpty(partialModel)) { return; } diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor_visualization.js b/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor_visualization.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_picker.js b/src/plugins/vis_type_timeseries/public/application/components/vis_picker.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_picker.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_picker.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/_index.scss b/src/plugins/vis_type_timeseries/public/application/components/vis_types/_index.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/_index.scss rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/_index.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/_vis_types.scss b/src/plugins/vis_type_timeseries/public/application/components/vis_types/_vis_types.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/_vis_types.scss rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/_vis_types.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/gauge/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/series.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/gauge/series.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/series.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/gauge/series.test.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/series.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/gauge/series.test.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/series.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/gauge/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/vis.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/gauge/vis.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/vis.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/_markdown.scss b/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/_markdown.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/_markdown.scss rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/_markdown.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/series.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/series.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/series.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/vis.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/vis.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/metric/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/series.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/metric/series.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/series.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/metric/series.test.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/series.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/metric/series.test.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/series.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/metric/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/vis.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/metric/vis.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/vis.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/table/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/table/config.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/table/is_sortable.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/is_sortable.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/table/is_sortable.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/table/is_sortable.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/table/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/series.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/table/series.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/table/series.js diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js new file mode 100644 index 0000000000000..c6f1db149928c --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js @@ -0,0 +1,257 @@ +/* + * 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 _, { isArray, last, get } from 'lodash'; +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { createTickFormatter } from '../../lib/tick_formatter'; +import { calculateLabel } from '../../../../../../../plugins/vis_type_timeseries/common/calculate_label'; +import { isSortable } from './is_sortable'; +import { EuiToolTip, EuiIcon } from '@elastic/eui'; +import { replaceVars } from '../../lib/replace_vars'; +import { fieldFormats } from '../../../../../../../plugins/data/public'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { getFieldFormats } from '../../../../services'; + +import { METRIC_TYPES } from '../../../../../../../plugins/vis_type_timeseries/common/metric_types'; + +function getColor(rules, colorKey, value) { + let color; + if (rules) { + rules.forEach(rule => { + if (rule.operator && rule.value != null) { + if (_[rule.operator](value, rule.value)) { + color = rule[colorKey]; + } + } + }); + } + return color; +} + +export class TableVis extends Component { + constructor(props) { + super(props); + + const fieldFormatsService = getFieldFormats(); + const DateFormat = fieldFormatsService.getType(fieldFormats.FIELD_FORMAT_IDS.DATE); + + this.dateFormatter = new DateFormat({}, this.props.getConfig); + } + + get visibleSeries() { + return get(this.props, 'model.series', []).filter(series => !series.hidden); + } + + renderRow = row => { + const { model } = this.props; + let rowDisplay = model.pivot_type === 'date' ? this.dateFormatter.convert(row.key) : row.key; + if (model.drilldown_url) { + const url = replaceVars(model.drilldown_url, {}, { key: row.key }); + rowDisplay =
{rowDisplay}; + } + const columns = row.series + .filter(item => item) + .map(item => { + const column = this.visibleSeries.find(c => c.id === item.id); + if (!column) return null; + const formatter = createTickFormatter( + column.formatter, + column.value_template, + this.props.getConfig + ); + const value = formatter(item.last); + let trend; + if (column.trend_arrows) { + const trendIcon = item.slope > 0 ? 'sortUp' : 'sortDown'; + trend = ( + +   + + ); + } + const style = { color: getColor(column.color_rules, 'text', item.last) }; + return ( +
+ ); + }); + return ( + + + {columns} + + ); + }; + + renderHeader() { + const { model, uiState, onUiState } = this.props; + const stateKey = `${model.type}.sort`; + const sort = uiState.get(stateKey, { + column: '_default_', + order: 'asc', + }); + + const calculateHeaderLabel = (metric, item) => { + const defaultLabel = item.label || calculateLabel(metric, item.metrics); + + switch (metric.type) { + case METRIC_TYPES.PERCENTILE: + return `${defaultLabel} (${last(metric.percentiles).value || 0})`; + case METRIC_TYPES.PERCENTILE_RANK: + return `${defaultLabel} (${last(metric.values) || 0})`; + default: + return defaultLabel; + } + }; + + const columns = this.visibleSeries.map(item => { + const metric = last(item.metrics); + const label = calculateHeaderLabel(metric, item); + + const handleClick = () => { + if (!isSortable(metric)) return; + let order; + if (sort.column === item.id) { + order = sort.order === 'asc' ? 'desc' : 'asc'; + } else { + order = 'asc'; + } + onUiState(stateKey, { column: item.id, order }); + }; + let sortComponent; + if (isSortable(metric)) { + let sortIcon; + if (sort.column === item.id) { + sortIcon = sort.order === 'asc' ? 'sortUp' : 'sortDown'; + } else { + sortIcon = 'empty'; + } + sortComponent = ; + } + let headerContent = ( + + {label} {sortComponent} + + ); + if (!isSortable(metric)) { + headerContent = ( + + } + > + {headerContent} + + ); + } + + return ( + + ); + }); + const label = model.pivot_label || model.pivot_field || model.pivot_id; + let sortIcon; + if (sort.column === '_default_') { + sortIcon = sort.order === 'asc' ? 'sortUp' : 'sortDown'; + } else { + sortIcon = 'empty'; + } + const sortComponent = ; + const handleSortClick = () => { + let order; + if (sort.column === '_default_') { + order = sort.order === 'asc' ? 'desc' : 'asc'; + } else { + order = 'asc'; + } + onUiState(stateKey, { column: '_default_', order }); + }; + return ( + + + {columns} + + ); + } + + render() { + const { visData, model } = this.props; + const header = this.renderHeader(); + let rows; + + if (isArray(visData.series) && visData.series.length) { + rows = visData.series.map(this.renderRow); + } else { + const message = model.pivot_id ? ( + + ) : ( + + ); + rows = ( + + + + ); + } + return ( +
+
+ {value} + {trend} +
{rowDisplay}
+ {headerContent} +
+ {label} {sortComponent} +
{message}
+ {header} + {rows} +
+
+ ); + } +} + +TableVis.defaultProps = { + sort: {}, +}; + +TableVis.propTypes = { + visData: PropTypes.object, + model: PropTypes.object, + backgroundColor: PropTypes.string, + onPaginate: PropTypes.func, + onUiState: PropTypes.func, + uiState: PropTypes.object, + pageNumber: PropTypes.number, + getConfig: PropTypes.func, +}; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/config.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/series.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/series.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/series.js diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js new file mode 100644 index 0000000000000..1004f7ee96a54 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js @@ -0,0 +1,260 @@ +/* + * 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 PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import reactCSS from 'reactcss'; + +import { startsWith, get, cloneDeep, map } from 'lodash'; +import { htmlIdGenerator } from '@elastic/eui'; +import { ScaleType } from '@elastic/charts'; + +import { createTickFormatter } from '../../lib/tick_formatter'; +import { TimeSeries } from '../../../visualizations/views/timeseries'; +import { MarkdownSimple } from '../../../../../../../plugins/kibana_react/public'; +import { replaceVars } from '../../lib/replace_vars'; +import { getAxisLabelString } from '../../lib/get_axis_label_string'; +import { getInterval } from '../../lib/get_interval'; +import { areFieldsDifferent } from '../../lib/charts'; +import { createXaxisFormatter } from '../../lib/create_xaxis_formatter'; +import { STACKED_OPTIONS } from '../../../visualizations/constants'; +import { getCoreStart, getUISettings } from '../../../../services'; + +export class TimeseriesVisualization extends Component { + static propTypes = { + model: PropTypes.object, + onBrush: PropTypes.func, + visData: PropTypes.object, + dateFormat: PropTypes.string, + getConfig: PropTypes.func, + }; + + xAxisFormatter = interval => val => { + const { scaledDataFormat, dateFormat } = this.props.visData; + + if (!scaledDataFormat || !dateFormat) { + return val; + } + + const formatter = createXaxisFormatter(interval, scaledDataFormat, dateFormat); + + return formatter(val); + }; + + yAxisStackedByPercentFormatter = val => { + const n = Number(val) * 100; + + return `${(Number.isNaN(n) ? 0 : n).toFixed(0)}%`; + }; + + applyDocTo = template => doc => { + const vars = replaceVars(template, null, doc); + + if (vars instanceof Error) { + this.showToastNotification = vars.error.caused_by; + + return template; + } + + return vars; + }; + + static getYAxisDomain = model => { + const axisMin = get(model, 'axis_min', '').toString(); + const axisMax = get(model, 'axis_max', '').toString(); + const fit = model.series + ? model.series.filter(({ hidden }) => !hidden).every(({ fill }) => fill === '0') + : model.fill === '0'; + + return { + min: axisMin.length ? Number(axisMin) : undefined, + max: axisMax.length ? Number(axisMax) : undefined, + fit, + }; + }; + + static addYAxis = (yAxis, { id, groupId, position, tickFormatter, domain, hide }) => { + yAxis.push({ + id, + groupId, + position, + tickFormatter, + domain, + hide, + }); + }; + + static getAxisScaleType = model => + get(model, 'axis_scale') === 'log' ? ScaleType.Log : ScaleType.Linear; + + static getTickFormatter = (model, getConfig) => + createTickFormatter(get(model, 'formatter'), get(model, 'value_template'), getConfig); + + componentDidUpdate() { + const toastNotifications = getCoreStart().notifications.toasts; + if ( + this.showToastNotification && + this.notificationReason !== this.showToastNotification.reason + ) { + if (this.notification) { + toastNotifications.remove(this.notification); + } + + this.notificationReason = this.showToastNotification.reason; + this.notification = toastNotifications.addDanger({ + title: this.showToastNotification.title, + text: {this.showToastNotification.reason}, + }); + } + + if (!this.showToastNotification && this.notification) { + toastNotifications.remove(this.notification); + this.notificationReason = null; + this.notification = null; + } + } + + prepareAnnotations = () => { + const { model, visData } = this.props; + + return map(model.annotations, ({ id, color, icon, template }) => { + const annotationData = get(visData, `${model.id}.annotations.${id}`, []); + const applyDocToTemplate = this.applyDocTo(template); + + return { + id, + color, + icon, + data: annotationData.map(({ docs, ...rest }) => ({ + ...rest, + docs: docs.map(applyDocToTemplate), + })), + }; + }); + }; + + render() { + const { model, visData, onBrush } = this.props; + const styles = reactCSS({ + default: { + tvbVis: { + backgroundColor: get(model, 'background_color'), + }, + }, + }); + const series = get(visData, `${model.id}.series`, []); + const interval = getInterval(visData, model); + const yAxisIdGenerator = htmlIdGenerator('yaxis'); + const mainAxisGroupId = yAxisIdGenerator('main_group'); + + const seriesModel = model.series.filter(s => !s.hidden).map(s => cloneDeep(s)); + const enableHistogramMode = areFieldsDifferent('chart_type')(seriesModel); + const firstSeries = seriesModel.find(s => s.formatter && !s.separate_axis); + + const mainAxisScaleType = TimeseriesVisualization.getAxisScaleType(model); + const mainAxisDomain = TimeseriesVisualization.getYAxisDomain(model); + const tickFormatter = TimeseriesVisualization.getTickFormatter( + firstSeries, + this.props.getConfig + ); + const yAxis = []; + let mainDomainAdded = false; + + this.showToastNotification = null; + + seriesModel.forEach(seriesGroup => { + const isStackedWithinSeries = seriesGroup.stacked === STACKED_OPTIONS.STACKED_WITHIN_SERIES; + const hasSeparateAxis = Boolean(seriesGroup.separate_axis); + const groupId = hasSeparateAxis || isStackedWithinSeries ? seriesGroup.id : mainAxisGroupId; + const domain = hasSeparateAxis + ? TimeseriesVisualization.getYAxisDomain(seriesGroup) + : undefined; + const isCustomDomain = groupId !== mainAxisGroupId; + const seriesGroupTickFormatter = TimeseriesVisualization.getTickFormatter( + seriesGroup, + this.props.getConfig + ); + const yScaleType = hasSeparateAxis + ? TimeseriesVisualization.getAxisScaleType(seriesGroup) + : mainAxisScaleType; + + if (seriesGroup.stacked === STACKED_OPTIONS.PERCENT) { + seriesGroup.separate_axis = true; + seriesGroup.axisFormatter = 'percent'; + seriesGroup.axis_min = seriesGroup.axis_min || 0; + seriesGroup.axis_max = seriesGroup.axis_max || 1; + seriesGroup.axis_position = model.axis_position; + } + + series + .filter(r => startsWith(r.id, seriesGroup.id)) + .forEach(seriesDataRow => { + seriesDataRow.tickFormatter = seriesGroupTickFormatter; + seriesDataRow.groupId = groupId; + seriesDataRow.yScaleType = yScaleType; + seriesDataRow.hideInLegend = Boolean(seriesGroup.hide_in_legend); + seriesDataRow.useDefaultGroupDomain = !isCustomDomain; + }); + + if (isCustomDomain) { + TimeseriesVisualization.addYAxis(yAxis, { + domain, + groupId, + id: yAxisIdGenerator(seriesGroup.id), + position: seriesGroup.axis_position, + hide: isStackedWithinSeries, + tickFormatter: + seriesGroup.stacked === STACKED_OPTIONS.PERCENT + ? this.yAxisStackedByPercentFormatter + : seriesGroupTickFormatter, + }); + } else if (!mainDomainAdded) { + TimeseriesVisualization.addYAxis(yAxis, { + tickFormatter, + id: yAxisIdGenerator('main'), + groupId: mainAxisGroupId, + position: model.axis_position, + domain: mainAxisDomain, + }); + + mainDomainAdded = true; + } + }); + + const darkMode = getUISettings().get('theme:darkMode'); + return ( +
+ +
+ ); + } +} diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/top_n/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/series.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/top_n/series.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/series.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/top_n/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/vis.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/top_n/vis.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/vis.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_with_splits.js b/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/vis_with_splits.js rename to src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/visualization.js b/src/plugins/vis_type_timeseries/public/application/components/visualization.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/visualization.js rename to src/plugins/vis_type_timeseries/public/application/components/visualization.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/yes_no.js b/src/plugins/vis_type_timeseries/public/application/components/yes_no.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/yes_no.js rename to src/plugins/vis_type_timeseries/public/application/components/yes_no.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/yes_no.test.js b/src/plugins/vis_type_timeseries/public/application/components/yes_no.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/components/yes_no.test.js rename to src/plugins/vis_type_timeseries/public/application/components/yes_no.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/contexts/form_validation_context.js b/src/plugins/vis_type_timeseries/public/application/contexts/form_validation_context.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/contexts/form_validation_context.js rename to src/plugins/vis_type_timeseries/public/application/contexts/form_validation_context.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/contexts/query_input_bar_context.ts b/src/plugins/vis_type_timeseries/public/application/contexts/query_input_bar_context.ts similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/contexts/query_input_bar_context.ts rename to src/plugins/vis_type_timeseries/public/application/contexts/query_input_bar_context.ts diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/contexts/vis_data_context.js b/src/plugins/vis_type_timeseries/public/application/contexts/vis_data_context.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/contexts/vis_data_context.js rename to src/plugins/vis_type_timeseries/public/application/contexts/vis_data_context.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/editor_controller.js b/src/plugins/vis_type_timeseries/public/application/editor_controller.js similarity index 94% rename from src/legacy/core_plugins/vis_type_timeseries/public/editor_controller.js rename to src/plugins/vis_type_timeseries/public/application/editor_controller.js index 16a6348712065..af50d3a06d1fc 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/editor_controller.js +++ b/src/plugins/vis_type_timeseries/public/application/editor_controller.js @@ -20,7 +20,8 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { fetchIndexPatternFields } from './lib/fetch_fields'; -import { getSavedObjectsClient, getUISettings, getI18n } from './services'; +import { getSavedObjectsClient, getUISettings, getI18n } from '../services'; +import { VisEditor } from './components/vis_editor'; export class EditorController { constructor(el, vis, eventEmitter, embeddableHandler) { @@ -55,19 +56,14 @@ export class EditorController { this.state.isLoaded = true; }; - getComponent = () => { - return this.state.vis.type.editorConfig.component; - }; - async render(params) { - const Component = this.getComponent(); const I18nContext = getI18n().Context; !this.state.isLoaded && (await this.fetchDefaultParams()); render( - getUISettings().get('theme:darkMode'); diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/lib/validate_interval.js b/src/plugins/vis_type_timeseries/public/application/lib/validate_interval.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/lib/validate_interval.js rename to src/plugins/vis_type_timeseries/public/application/lib/validate_interval.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/constants/chart.js b/src/plugins/vis_type_timeseries/public/application/visualizations/constants/chart.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/constants/chart.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/constants/chart.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/constants/icons.js b/src/plugins/vis_type_timeseries/public/application/visualizations/constants/icons.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/constants/icons.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/constants/icons.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/constants/index.js b/src/plugins/vis_type_timeseries/public/application/visualizations/constants/index.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/constants/index.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/constants/index.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/lib/active_cursor.js b/src/plugins/vis_type_timeseries/public/application/visualizations/lib/active_cursor.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/lib/active_cursor.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/lib/active_cursor.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/lib/calc_dimensions.js b/src/plugins/vis_type_timeseries/public/application/visualizations/lib/calc_dimensions.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/lib/calc_dimensions.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/lib/calc_dimensions.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/lib/calculate_coordinates.js b/src/plugins/vis_type_timeseries/public/application/visualizations/lib/calculate_coordinates.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/lib/calculate_coordinates.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/lib/calculate_coordinates.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/lib/get_value_by.js b/src/plugins/vis_type_timeseries/public/application/visualizations/lib/get_value_by.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/lib/get_value_by.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/lib/get_value_by.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/_annotation.scss b/src/plugins/vis_type_timeseries/public/application/visualizations/views/_annotation.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/_annotation.scss rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/_annotation.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/_gauge.scss b/src/plugins/vis_type_timeseries/public/application/visualizations/views/_gauge.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/_gauge.scss rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/_gauge.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/_index.scss b/src/plugins/vis_type_timeseries/public/application/visualizations/views/_index.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/_index.scss rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/_index.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/_metric.scss b/src/plugins/vis_type_timeseries/public/application/visualizations/views/_metric.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/_metric.scss rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/_metric.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/_top_n.scss b/src/plugins/vis_type_timeseries/public/application/visualizations/views/_top_n.scss similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/_top_n.scss rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/_top_n.scss diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/annotation.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/annotation.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/annotation.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/annotation.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/gauge.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/gauge.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/gauge_vis.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge_vis.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/gauge_vis.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge_vis.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/metric.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/metric.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/metric.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/metric.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/__mocks__/@elastic/charts.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/__mocks__/@elastic/charts.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/__mocks__/@elastic/charts.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/__mocks__/@elastic/charts.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/__snapshots__/area_decorator.test.js.snap b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/decorators/__snapshots__/area_decorator.test.js.snap similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/__snapshots__/area_decorator.test.js.snap rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/decorators/__snapshots__/area_decorator.test.js.snap diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/__snapshots__/bar_decorator.test.js.snap b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/decorators/__snapshots__/bar_decorator.test.js.snap similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/__snapshots__/bar_decorator.test.js.snap rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/decorators/__snapshots__/bar_decorator.test.js.snap diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/area_decorator.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/decorators/area_decorator.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/area_decorator.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/decorators/area_decorator.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/area_decorator.test.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/decorators/area_decorator.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/area_decorator.test.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/decorators/area_decorator.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/bar_decorator.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/decorators/bar_decorator.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/bar_decorator.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/decorators/bar_decorator.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/bar_decorator.test.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/decorators/bar_decorator.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/bar_decorator.test.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/decorators/bar_decorator.test.js diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js new file mode 100644 index 0000000000000..b1c3c7ac6b67a --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js @@ -0,0 +1,267 @@ +/* + * 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, { useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { + Axis, + Chart, + Position, + Settings, + AnnotationDomainTypes, + LineAnnotation, + TooltipType, +} from '@elastic/charts'; +import { EuiIcon } from '@elastic/eui'; +import { getTimezone } from '../../../lib/get_timezone'; +import { eventBus, ACTIVE_CURSOR } from '../../lib/active_cursor'; +import { getUISettings } from '../../../../services'; +import { GRID_LINE_CONFIG, ICON_TYPES_MAP, STACKED_OPTIONS } from '../../constants'; +import { AreaSeriesDecorator } from './decorators/area_decorator'; +import { BarSeriesDecorator } from './decorators/bar_decorator'; +import { getStackAccessors } from './utils/stack_format'; +import { getTheme, getChartClasses } from './utils/theme'; + +const generateAnnotationData = (values, formatter) => + values.map(({ key, docs }) => ({ + dataValue: key, + details: docs[0], + header: formatter({ + value: key, + }), + })); + +const decorateFormatter = formatter => ({ value }) => formatter(value); + +const handleCursorUpdate = cursor => { + eventBus.trigger(ACTIVE_CURSOR, cursor); +}; + +export const TimeSeries = ({ + darkMode, + backgroundColor, + showGrid, + legend, + legendPosition, + xAxisLabel, + series, + yAxis, + onBrush, + xAxisFormatter, + annotations, + enableHistogramMode, +}) => { + const chartRef = useRef(); + const updateCursor = (_, cursor) => { + if (chartRef.current) { + chartRef.current.dispatchExternalPointerEvent(cursor); + } + }; + + useEffect(() => { + eventBus.on(ACTIVE_CURSOR, updateCursor); + + return () => { + eventBus.off(ACTIVE_CURSOR, undefined, updateCursor); + }; + }, []); // eslint-disable-line + + const tooltipFormatter = decorateFormatter(xAxisFormatter); + const uiSettings = getUISettings(); + const timeZone = getTimezone(uiSettings); + const hasBarChart = series.some(({ bars }) => bars.show); + + // compute the theme based on the bg color + const theme = getTheme(darkMode, backgroundColor); + // apply legend style change if bgColor is configured + const classes = classNames('tvbVisTimeSeries', getChartClasses(backgroundColor)); + + return ( + + + + {annotations.map(({ id, data, icon, color }) => { + const dataValues = generateAnnotationData(data, tooltipFormatter); + const style = { line: { stroke: color } }; + + return ( + } + hideLinesTooltips={true} + style={style} + /> + ); + })} + + {series.map( + ( + { + id, + label, + bars, + lines, + data, + hideInLegend, + xScaleType, + yScaleType, + groupId, + color, + stack, + points, + useDefaultGroupDomain, + y1AccessorFormat, + y0AccessorFormat, + }, + sortIndex + ) => { + const stackAccessors = getStackAccessors(stack); + const isPercentage = stack === STACKED_OPTIONS.PERCENT; + const key = `${id}-${label}`; + + if (bars.show) { + return ( + + ); + } + + if (lines.show) { + return ( + + ); + } + + return null; + } + )} + + {yAxis.map(({ id, groupId, position, tickFormatter, domain, hide }) => ( + + ))} + + + + ); +}; + +TimeSeries.defaultProps = { + showGrid: true, + legend: true, + legendPosition: 'right', +}; + +TimeSeries.propTypes = { + darkMode: PropTypes.bool, + backgroundColor: PropTypes.string, + showGrid: PropTypes.bool, + legend: PropTypes.bool, + legendPosition: PropTypes.string, + xAxisLabel: PropTypes.string, + series: PropTypes.array, + yAxis: PropTypes.array, + onBrush: PropTypes.func, + xAxisFormatter: PropTypes.func, + annotations: PropTypes.array, + enableHistogramMode: PropTypes.bool.isRequired, +}; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/model/__snapshots__/charts.test.js.snap b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/model/__snapshots__/charts.test.js.snap similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/model/__snapshots__/charts.test.js.snap rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/model/__snapshots__/charts.test.js.snap diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/model/charts.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/model/charts.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/model/charts.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/model/charts.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/model/charts.test.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/model/charts.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/model/charts.test.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/model/charts.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/__snapshots__/series_styles.test.js.snap b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/utils/__snapshots__/series_styles.test.js.snap similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/__snapshots__/series_styles.test.js.snap rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/utils/__snapshots__/series_styles.test.js.snap diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/series_styles.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/utils/series_styles.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/series_styles.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/utils/series_styles.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/series_styles.test.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/utils/series_styles.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/series_styles.test.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/utils/series_styles.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/stack_format.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/utils/stack_format.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/stack_format.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/utils/stack_format.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/stack_format.test.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/utils/stack_format.test.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/stack_format.test.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/utils/stack_format.test.js diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/theme.test.ts b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/utils/theme.test.ts similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/theme.test.ts rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/utils/theme.test.ts diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/theme.ts b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/utils/theme.ts similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/theme.ts rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/utils/theme.ts diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/top_n.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js similarity index 100% rename from src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/top_n.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js diff --git a/src/plugins/vis_type_timeseries/public/index.ts b/src/plugins/vis_type_timeseries/public/index.ts new file mode 100644 index 0000000000000..fbf4a81b6ad1b --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/index.ts @@ -0,0 +1,25 @@ +/* + * 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 { PluginInitializerContext } from '../../../core/public'; +import { MetricsPlugin as Plugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new Plugin(initializerContext); +} diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/metrics_fn.ts b/src/plugins/vis_type_timeseries/public/metrics_fn.ts similarity index 92% rename from src/legacy/core_plugins/vis_type_timeseries/public/metrics_fn.ts rename to src/plugins/vis_type_timeseries/public/metrics_fn.ts index 1f9cbecc2a354..008b13cce6565 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/metrics_fn.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_fn.ts @@ -19,12 +19,8 @@ import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { - ExpressionFunctionDefinition, - KibanaContext, - Render, -} from '../../../../plugins/expressions/public'; -import { PersistedState } from '../../../../plugins/visualizations/public'; +import { ExpressionFunctionDefinition, KibanaContext, Render } from '../../expressions/public'; +import { PersistedState } from '../../visualizations/public'; // @ts-ignore import { metricsRequestHandler } from './request_handler'; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts similarity index 85% rename from src/legacy/core_plugins/vis_type_timeseries/public/metrics_type.ts rename to src/plugins/vis_type_timeseries/public/metrics_type.ts index 1db35c406eb13..f83881a619a59 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/metrics_type.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts @@ -21,11 +21,10 @@ import { i18n } from '@kbn/i18n'; // @ts-ignore import { metricsRequestHandler } from './request_handler'; +import { EditorController } from './application'; // @ts-ignore -import { EditorController } from './editor_controller'; -// @ts-ignore -import { PANEL_TYPES } from '../../../../plugins/vis_type_timeseries/common/panel_types'; -import { defaultFeedbackMessage } from '../../../../plugins/kibana_utils/public'; +import { PANEL_TYPES } from '../common/panel_types'; +import { defaultFeedbackMessage } from '../../kibana_utils/public'; export const metricsVisDefinition = { name: 'metrics', @@ -69,12 +68,9 @@ export const metricsVisDefinition = { show_legend: 1, show_grid: 1, }, - component: require('./components/vis_editor').VisEditor, + component: require('./application/components/vis_editor').VisEditor, }, editor: EditorController, - editorConfig: { - component: require('./components/vis_editor').VisEditor, - }, options: { showQueryBar: false, showFilterBar: false, diff --git a/src/plugins/vis_type_timeseries/public/plugin.ts b/src/plugins/vis_type_timeseries/public/plugin.ts new file mode 100644 index 0000000000000..e6c92c31450e7 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/plugin.ts @@ -0,0 +1,73 @@ +/* + * 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 './application/index.scss'; + +import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'kibana/public'; +import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; +import { VisualizationsSetup } from '../../visualizations/public'; + +import { createMetricsFn } from './metrics_fn'; +import { metricsVisDefinition } from './metrics_type'; +import { + setSavedObjectsClient, + setUISettings, + setI18n, + setFieldFormats, + setCoreStart, + setDataStart, +} from './services'; +import { DataPublicPluginStart } from '../../data/public'; + +/** @internal */ +export interface MetricsPluginSetupDependencies { + expressions: ReturnType; + visualizations: VisualizationsSetup; +} + +/** @internal */ +export interface MetricsPluginStartDependencies { + data: DataPublicPluginStart; +} + +/** @internal */ +export class MetricsPlugin implements Plugin, void> { + initializerContext: PluginInitializerContext; + + constructor(initializerContext: PluginInitializerContext) { + this.initializerContext = initializerContext; + } + + public async setup( + core: CoreSetup, + { expressions, visualizations }: MetricsPluginSetupDependencies + ) { + expressions.registerFunction(createMetricsFn); + setUISettings(core.uiSettings); + visualizations.createReactVisualization(metricsVisDefinition); + } + + public start(core: CoreStart, { data }: MetricsPluginStartDependencies) { + setSavedObjectsClient(core.savedObjects); + setI18n(core.i18n); + setFieldFormats(data.fieldFormats); + setDataStart(data); + setCoreStart(core); + } +} diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/request_handler.js b/src/plugins/vis_type_timeseries/public/request_handler.js similarity index 95% rename from src/legacy/core_plugins/vis_type_timeseries/public/request_handler.js rename to src/plugins/vis_type_timeseries/public/request_handler.js index 2cac1567a6eb7..bd6c6d9553930 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/request_handler.js +++ b/src/plugins/vis_type_timeseries/public/request_handler.js @@ -17,8 +17,7 @@ * under the License. */ -import { validateInterval } from './lib/validate_interval'; -import { getTimezone } from './lib/get_timezone'; +import { getTimezone, validateInterval } from './application'; import { getUISettings, getDataStart, getCoreStart } from './services'; export const metricsRequestHandler = async ({ diff --git a/src/plugins/vis_type_timeseries/public/services.ts b/src/plugins/vis_type_timeseries/public/services.ts new file mode 100644 index 0000000000000..d93a376584eac --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/services.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 { I18nStart, SavedObjectsStart, IUiSettingsClient, CoreStart } from 'src/core/public'; +import { createGetterSetter } from '../../kibana_utils/public'; +import { DataPublicPluginStart } from '../../data/public'; + +export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); + +export const [getFieldFormats, setFieldFormats] = createGetterSetter< + DataPublicPluginStart['fieldFormats'] +>('FieldFormats'); + +export const [getSavedObjectsClient, setSavedObjectsClient] = createGetterSetter( + 'SavedObjectsClient' +); + +export const [getCoreStart, setCoreStart] = createGetterSetter('CoreStart'); + +export const [getDataStart, setDataStart] = createGetterSetter('DataStart'); + +export const [getI18n, setI18n] = createGetterSetter('I18n'); diff --git a/src/plugins/vis_type_timeseries/server/config.ts b/src/plugins/vis_type_timeseries/server/config.ts new file mode 100644 index 0000000000000..f4668eff8fa04 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/config.ts @@ -0,0 +1,31 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; + +export const config = schema.object({ + enabled: schema.boolean({ defaultValue: true }), + + /** @deprecated **/ + chartResolution: schema.number({ defaultValue: 150 }), + /** @deprecated **/ + minimumBucketSize: schema.number({ defaultValue: 10 }), +}); + +export type VisTypeTimeseriesConfig = TypeOf; diff --git a/src/plugins/vis_type_timeseries/server/index.ts b/src/plugins/vis_type_timeseries/server/index.ts index fa74b6e965971..f460257caf5e3 100644 --- a/src/plugins/vis_type_timeseries/server/index.ts +++ b/src/plugins/vis_type_timeseries/server/index.ts @@ -17,18 +17,25 @@ * under the License. */ -import { schema, TypeOf } from '@kbn/config-schema'; -import { PluginInitializerContext } from 'src/core/server'; +import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; +import { VisTypeTimeseriesConfig, config as configSchema } from './config'; import { VisTypeTimeseriesPlugin } from './plugin'; + export { VisTypeTimeseriesSetup, Framework } from './plugin'; -export const config = { - schema: schema.object({ - enabled: schema.boolean({ defaultValue: true }), - }), -}; +export const config: PluginConfigDescriptor = { + deprecations: ({ unused, renameFromRoot }) => [ + // In Kibana v7.8 plugin id was renamed from 'metrics' to 'vis_type_timeseries': + renameFromRoot('metrics.enabled', 'vis_type_timeseries.enabled', true), + renameFromRoot('metrics.chartResolution', 'vis_type_timeseries.chartResolution', true), + renameFromRoot('metrics.minimumBucketSize', 'vis_type_timeseries.minimumBucketSize', true), -export type VisTypeTimeseriesConfig = TypeOf; + // Unused properties which should be removed after releasing Kibana v8.0: + unused('chartResolution'), + unused('minimumBucketSize'), + ], + schema: configSchema, +}; export { ValidationTelemetryServiceSetup } from './validation_telemetry'; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/index.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/index.js index 4b0b8f33716a2..c727a3131f5df 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/index.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/index.js @@ -26,6 +26,7 @@ import { dateHistogram } from './date_histogram'; import { metricBuckets } from './metric_buckets'; import { siblingBuckets } from './sibling_buckets'; import { ratios as filterRatios } from './filter_ratios'; +import { positiveRate } from './positive_rate'; import { normalizeQuery } from './normalize_query'; export const processors = [ @@ -38,5 +39,6 @@ export const processors = [ metricBuckets, siblingBuckets, filterRatios, + positiveRate, normalizeQuery, ]; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js new file mode 100644 index 0000000000000..1ff548cc19e02 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js @@ -0,0 +1,68 @@ +/* + * 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 { getBucketSize } from '../../helpers/get_bucket_size'; +import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; +import { bucketTransform } from '../../helpers/bucket_transform'; +import { set } from 'lodash'; + +export const filter = metric => metric.type === 'positive_rate'; + +export const createPositiveRate = (doc, intervalString, aggRoot) => metric => { + const maxFn = bucketTransform.max; + const derivativeFn = bucketTransform.derivative; + const positiveOnlyFn = bucketTransform.positive_only; + + const maxMetric = { id: `${metric.id}-positive-rate-max`, type: 'max', field: metric.field }; + const derivativeMetric = { + id: `${metric.id}-positive-rate-derivative`, + type: 'derivative', + field: `${metric.id}-positive-rate-max`, + unit: metric.unit, + }; + const positiveOnlyMetric = { + id: metric.id, + type: 'positive_only', + field: `${metric.id}-positive-rate-derivative`, + }; + + const fakeSeriesMetrics = [maxMetric, derivativeMetric, positiveOnlyMetric]; + + const maxBucket = maxFn(maxMetric, fakeSeriesMetrics, intervalString); + const derivativeBucket = derivativeFn(derivativeMetric, fakeSeriesMetrics, intervalString); + const positiveOnlyBucket = positiveOnlyFn(positiveOnlyMetric, fakeSeriesMetrics, intervalString); + + set(doc, `${aggRoot}.timeseries.aggs.${metric.id}-positive-rate-max`, maxBucket); + set(doc, `${aggRoot}.timeseries.aggs.${metric.id}-positive-rate-derivative`, derivativeBucket); + set(doc, `${aggRoot}.timeseries.aggs.${metric.id}`, positiveOnlyBucket); +}; + +export function positiveRate(req, panel, series, esQueryConfig, indexPatternObject, capabilities) { + return next => doc => { + const { interval } = getIntervalAndTimefield(panel, series, indexPatternObject); + const { intervalString } = getBucketSize(req, interval, capabilities); + if (series.metrics.some(filter)) { + series.metrics + .filter(filter) + .forEach(createPositiveRate(doc, intervalString, `aggs.${series.id}.aggs`)); + return next(doc); + } + return next(doc); + }; +} diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.test.js new file mode 100644 index 0000000000000..946884c05c722 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.test.js @@ -0,0 +1,95 @@ +/* + * 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 { positiveRate } from './positive_rate'; +describe('positiveRate(req, panel, series)', () => { + let panel; + let series; + let req; + beforeEach(() => { + panel = { + time_field: 'timestamp', + }; + series = { + id: 'test', + split_mode: 'terms', + terms_size: 10, + terms_field: 'host', + metrics: [ + { + id: 'metric-1', + type: 'positive_rate', + field: 'system.network.out.bytes', + unit: '1s', + }, + ], + }; + req = { + payload: { + timerange: { + min: '2017-01-01T00:00:00Z', + max: '2017-01-01T01:00:00Z', + }, + }, + }; + }); + + test('calls next when finished', () => { + const next = jest.fn(); + positiveRate(req, panel, series)(next)({}); + expect(next.mock.calls.length).toEqual(1); + }); + + test('returns positive rate aggs', () => { + const next = doc => doc; + const doc = positiveRate(req, panel, series)(next)({}); + expect(doc).toEqual({ + aggs: { + test: { + aggs: { + timeseries: { + aggs: { + 'metric-1-positive-rate-max': { + max: { field: 'system.network.out.bytes' }, + }, + 'metric-1-positive-rate-derivative': { + derivative: { + buckets_path: 'metric-1-positive-rate-max', + gap_policy: 'skip', + unit: '1s', + }, + }, + 'metric-1': { + bucket_script: { + buckets_path: { value: 'metric-1-positive-rate-derivative[normalized_value]' }, + script: { + source: 'params.value > 0.0 ? params.value : 0.0', + lang: 'painless', + }, + gap_policy: 'skip', + }, + }, + }, + }, + }, + }, + }, + }); + }); +}); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/index.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/index.js index a62533ae7a37c..5864d2538005d 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/index.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/index.js @@ -26,6 +26,7 @@ import { metricBuckets } from './metric_buckets'; import { siblingBuckets } from './sibling_buckets'; import { ratios as filterRatios } from './filter_ratios'; import { normalizeQuery } from './normalize_query'; +import { positiveRate } from './positive_rate'; export const processors = [ query, @@ -36,5 +37,6 @@ export const processors = [ metricBuckets, siblingBuckets, filterRatios, + positiveRate, normalizeQuery, ]; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js new file mode 100644 index 0000000000000..da4b834822d70 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js @@ -0,0 +1,35 @@ +/* + * 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 { getBucketSize } from '../../helpers/get_bucket_size'; +import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; +import { calculateAggRoot } from './calculate_agg_root'; +import { createPositiveRate, filter } from '../series/positive_rate'; + +export function positiveRate(req, panel, esQueryConfig, indexPatternObject) { + return next => doc => { + const { interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); + const { intervalString } = getBucketSize(req, interval); + panel.series.forEach(column => { + const aggRoot = calculateAggRoot(doc, column); + column.metrics.filter(filter).forEach(createPositiveRate(doc, intervalString, aggRoot)); + }); + return next(doc); + }; +} diff --git a/src/plugins/vis_type_timeseries/server/plugin.ts b/src/plugins/vis_type_timeseries/server/plugin.ts index 6ef6362c6e37b..05257cb79a75c 100644 --- a/src/plugins/vis_type_timeseries/server/plugin.ts +++ b/src/plugins/vis_type_timeseries/server/plugin.ts @@ -29,7 +29,7 @@ import { } from 'src/core/server'; import { Observable } from 'rxjs'; import { Server } from 'hapi'; -import { VisTypeTimeseriesConfig } from '.'; +import { VisTypeTimeseriesConfig } from './config'; import { getVisData, GetVisData, GetVisDataOptions } from './lib/get_vis_data'; import { ValidationTelemetryService } from './validation_telemetry'; import { UsageCollectionSetup } from '../../usage_collection/server'; diff --git a/src/plugins/vis_type_timeseries/server/validation_telemetry/saved_object_type.ts b/src/plugins/vis_type_timeseries/server/validation_telemetry/saved_object_type.ts index 77b49e824334f..f18fa1e4cc2fa 100644 --- a/src/plugins/vis_type_timeseries/server/validation_telemetry/saved_object_type.ts +++ b/src/plugins/vis_type_timeseries/server/validation_telemetry/saved_object_type.ts @@ -31,7 +31,7 @@ const resetCount: SavedObjectMigrationFn = doc => ({ export const tsvbTelemetrySavedObjectType: SavedObjectsType = { name: 'tsvb-validation-telemetry', hidden: false, - namespaceAgnostic: true, + namespaceType: 'agnostic', mappings: { properties: { failedRequests: { diff --git a/src/plugins/vis_type_xy/kibana.json b/src/plugins/vis_type_xy/kibana.json new file mode 100644 index 0000000000000..ca02da45e9112 --- /dev/null +++ b/src/plugins/vis_type_xy/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "visTypeXy", + "version": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["charts", "expressions", "visualizations"] +} diff --git a/src/plugins/vis_type_xy/public/index.ts b/src/plugins/vis_type_xy/public/index.ts new file mode 100644 index 0000000000000..9af75ce9059e9 --- /dev/null +++ b/src/plugins/vis_type_xy/public/index.ts @@ -0,0 +1,27 @@ +/* + * 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 { PluginInitializerContext } from '../../../core/public'; +import { VisTypeXyPlugin as Plugin } from './plugin'; + +export { VisTypeXyPluginSetup } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new Plugin(initializerContext); +} diff --git a/src/plugins/vis_type_xy/public/plugin.ts b/src/plugins/vis_type_xy/public/plugin.ts new file mode 100644 index 0000000000000..667018c1e6e30 --- /dev/null +++ b/src/plugins/vis_type_xy/public/plugin.ts @@ -0,0 +1,87 @@ +/* + * 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 { + CoreSetup, + CoreStart, + Plugin, + IUiSettingsClient, + PluginInitializerContext, +} from 'kibana/public'; + +import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; +import { VisualizationsSetup, VisualizationsStart } from '../../visualizations/public'; +import { ChartsPluginSetup } from '../../charts/public'; + +export interface VisTypeXyDependencies { + uiSettings: IUiSettingsClient; + charts: ChartsPluginSetup; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface VisTypeXyPluginSetup {} + +/** @internal */ +export interface VisTypeXyPluginSetupDependencies { + expressions: ReturnType; + visualizations: VisualizationsSetup; + charts: ChartsPluginSetup; +} + +/** @internal */ +export interface VisTypeXyPluginStartDependencies { + expressions: ReturnType; + visualizations: VisualizationsStart; +} + +type VisTypeXyCoreSetup = CoreSetup; + +/** @internal */ +export class VisTypeXyPlugin implements Plugin { + constructor(public initializerContext: PluginInitializerContext) {} + + public async setup( + core: VisTypeXyCoreSetup, + { expressions, visualizations, charts }: VisTypeXyPluginSetupDependencies + ) { + // eslint-disable-next-line no-console + console.warn( + 'The visTypeXy plugin is enabled\n\n', + 'This may negatively alter existing vislib visualization configurations if saved.' + ); + const visualizationDependencies: Readonly = { + uiSettings: core.uiSettings, + charts, + }; + + const visTypeDefinitions: any[] = []; + const visFunctions: any = []; + + visFunctions.forEach((fn: any) => expressions.registerFunction(fn)); + visTypeDefinitions.forEach((vis: any) => + visualizations.createBaseVisualization(vis(visualizationDependencies)) + ); + + return {}; + } + + public start(core: CoreStart, deps: VisTypeXyPluginStartDependencies) { + // nothing to do here + } +} diff --git a/src/plugins/vis_type_xy/server/index.ts b/src/plugins/vis_type_xy/server/index.ts new file mode 100644 index 0000000000000..afc879dc9c845 --- /dev/null +++ b/src/plugins/vis_type_xy/server/index.ts @@ -0,0 +1,29 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +export const config = { + schema: schema.object({ enabled: schema.boolean({ defaultValue: false }) }), +}; + +export const plugin = () => ({ + setup() {}, + start() {}, +}); diff --git a/src/plugins/visualizations/public/index.scss b/src/plugins/visualizations/public/index.scss index eada763b63c4d..2b61535f3e7f2 100644 --- a/src/plugins/visualizations/public/index.scss +++ b/src/plugins/visualizations/public/index.scss @@ -1,2 +1,3 @@ @import 'wizard/index'; @import 'embeddable/index'; +@import 'components/index'; diff --git a/src/plugins/visualizations/server/saved_objects/visualization.ts b/src/plugins/visualizations/server/saved_objects/visualization.ts index 9f4782f3ec730..cd2211c185530 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization.ts +++ b/src/plugins/visualizations/server/saved_objects/visualization.ts @@ -23,7 +23,7 @@ import { visualizationSavedObjectTypeMigrations } from './visualization_migratio export const visualizationSavedObjectType: SavedObjectsType = { name: 'visualization', hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', management: { icon: 'visualizeApp', defaultSearchField: 'title', diff --git a/src/plugins/visualize/kibana.json b/src/plugins/visualize/kibana.json new file mode 100644 index 0000000000000..a7afa0697a5eb --- /dev/null +++ b/src/plugins/visualize/kibana.json @@ -0,0 +1,17 @@ +{ + "id": "visualize", + "version": "kibana", + "server": false, + "ui": true, + "requiredPlugins": [ + "data", + "kibanaLegacy", + "navigation", + "savedObjects", + "visualizations" + ], + "optionalPlugins": [ + "home", + "share" + ] +} diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts b/src/plugins/visualize/public/application/application.ts similarity index 95% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts rename to src/plugins/visualize/public/application/application.ts index 241397884c8fe..9d8a1b98ef023 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts +++ b/src/plugins/visualize/public/application/application.ts @@ -17,16 +17,18 @@ * under the License. */ +import './index.scss'; + import angular, { IModule } from 'angular'; import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular'; import { AppMountContext } from 'kibana/public'; -import { configureAppAngularModule } from '../legacy_imports'; -import { NavigationPublicPluginStart as NavigationStart } from '../../../../../../plugins/navigation/public'; +import { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public'; import { + configureAppAngularModule, createTopNavDirective, createTopNavHelper, -} from '../../../../../../plugins/kibana_legacy/public'; +} from '../../../kibana_legacy/public'; // @ts-ignore import { initVisualizeApp } from './legacy_app'; diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/breadcrumbs.ts b/src/plugins/visualize/public/application/breadcrumbs.ts similarity index 86% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/breadcrumbs.ts rename to src/plugins/visualize/public/application/breadcrumbs.ts index b6a63d50b205b..972bdc1462b2c 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/breadcrumbs.ts +++ b/src/plugins/visualize/public/application/breadcrumbs.ts @@ -24,7 +24,7 @@ import { VisualizeConstants } from './visualize_constants'; export function getLandingBreadcrumbs() { return [ { - text: i18n.translate('kbn.visualize.listing.breadcrumb', { + text: i18n.translate('visualize.listing.breadcrumb', { defaultMessage: 'Visualize', }), href: `#${VisualizeConstants.LANDING_PAGE_PATH}`, @@ -36,7 +36,7 @@ export function getWizardStep1Breadcrumbs() { return [ ...getLandingBreadcrumbs(), { - text: i18n.translate('kbn.visualize.wizard.step1Breadcrumb', { + text: i18n.translate('visualize.wizard.step1Breadcrumb', { defaultMessage: 'Create', }), }, @@ -47,7 +47,7 @@ export function getWizardStep2Breadcrumbs() { return [ ...getLandingBreadcrumbs(), { - text: i18n.translate('kbn.visualize.wizard.step2Breadcrumb', { + text: i18n.translate('visualize.wizard.step2Breadcrumb', { defaultMessage: 'Create', }), }, @@ -58,7 +58,7 @@ export function getCreateBreadcrumbs() { return [ ...getLandingBreadcrumbs(), { - text: i18n.translate('kbn.visualize.editor.createBreadcrumb', { + text: i18n.translate('visualize.editor.createBreadcrumb', { defaultMessage: 'Create', }), }, diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/_editor.scss b/src/plugins/visualize/public/application/editor/_editor.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/_editor.scss rename to src/plugins/visualize/public/application/editor/_editor.scss diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/_index.scss b/src/plugins/visualize/public/application/editor/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/_index.scss rename to src/plugins/visualize/public/application/editor/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html b/src/plugins/visualize/public/application/editor/editor.html similarity index 98% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html rename to src/plugins/visualize/public/application/editor/editor.html index 0dcacd30fba4e..a031d70ef9a83 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html +++ b/src/plugins/visualize/public/application/editor/editor.html @@ -80,7 +80,7 @@

) : null; @@ -229,10 +220,10 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState : []), { id: 'share', - label: i18n.translate('kbn.topNavMenu.shareVisualizationButtonLabel', { + label: i18n.translate('visualize.topNavMenu.shareVisualizationButtonLabel', { defaultMessage: 'share', }), - description: i18n.translate('kbn.visualize.topNavMenu.shareVisualizationButtonAriaLabel', { + description: i18n.translate('visualize.topNavMenu.shareVisualizationButtonAriaLabel', { defaultMessage: 'Share Visualization', }), testId: 'shareTopNavButton', @@ -252,13 +243,15 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState isDirty: hasUnappliedChanges || hasUnsavedChanges, }); }, + // disable the Share button if no action specified + disableButton: !share, }, { id: 'inspector', - label: i18n.translate('kbn.topNavMenu.openInspectorButtonLabel', { + label: i18n.translate('visualize.topNavMenu.openInspectorButtonLabel', { defaultMessage: 'inspect', }), - description: i18n.translate('kbn.visualize.topNavMenu.openInspectorButtonAriaLabel', { + description: i18n.translate('visualize.topNavMenu.openInspectorButtonAriaLabel', { defaultMessage: 'Open Inspector for visualization', }), testId: 'openInspectorButton', @@ -274,23 +267,12 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState }, tooltip() { if (!embeddableHandler.hasInspector || !embeddableHandler.hasInspector()) { - return i18n.translate('kbn.visualize.topNavMenu.openInspectorDisabledButtonTooltip', { + return i18n.translate('visualize.topNavMenu.openInspectorDisabledButtonTooltip', { defaultMessage: `This visualization doesn't support any inspectors.`, }); } }, }, - { - id: 'refresh', - label: i18n.translate('kbn.topNavMenu.refreshButtonLabel', { defaultMessage: 'refresh' }), - description: i18n.translate('kbn.visualize.topNavMenu.refreshButtonAriaLabel', { - defaultMessage: 'Refresh', - }), - run: function() { - embeddableHandler.reload(); - }, - testId: 'visualizeRefreshButton', - }, ]; if (savedVis.id) { @@ -330,7 +312,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState stopAllSyncing(); toastNotifications.addWarning({ - title: i18n.translate('kbn.visualize.visualizationTypeInvalidNotificationMessage', { + title: i18n.translate('visualize.visualizationTypeInvalidNotificationMessage', { defaultMessage: 'Invalid visualization type', }), text: toMountPoint({error.message}), @@ -370,7 +352,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState return; } - savedQueryService.getSavedQuery(savedQueryId).then(savedQuery => { + queryService.savedQueries.getSavedQuery(savedQueryId).then(savedQuery => { $scope.$evalAsync(() => { $scope.updateSavedQuery(savedQuery); }); @@ -633,7 +615,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState if (id) { toastNotifications.addSuccess({ title: i18n.translate( - 'kbn.visualize.topNavMenu.saveVisualization.successNotificationText', + 'visualize.topNavMenu.saveVisualization.successNotificationText', { defaultMessage: `Saved '{visTitle}'`, values: { @@ -672,15 +654,12 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState // eslint-disable-next-line console.error(error); toastNotifications.addDanger({ - title: i18n.translate( - 'kbn.visualize.topNavMenu.saveVisualization.failureNotificationText', - { - defaultMessage: `Error on saving '{visTitle}'`, - values: { - visTitle: savedVis.title, - }, - } - ), + title: i18n.translate('visualize.topNavMenu.saveVisualization.failureNotificationText', { + defaultMessage: `Error on saving '{visTitle}'`, + values: { + visTitle: savedVis.title, + }, + }), text: error.message, 'data-test-subj': 'saveVisualizationError', }); @@ -703,7 +682,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState }); toastNotifications.addSuccess( - i18n.translate('kbn.visualize.linkedToSearch.unlinkSuccessNotificationText', { + i18n.translate('visualize.linkedToSearch.unlinkSuccessNotificationText', { defaultMessage: `Unlinked from saved search '{searchTitle}'`, values: { searchTitle: savedSearch.title, @@ -715,7 +694,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState $scope.getAdditionalMessage = () => { return ( '' + - i18n.translate('kbn.visualize.experimentalVisInfoText', { + i18n.translate('visualize.experimentalVisInfoText', { defaultMessage: 'This visualization is marked as experimental.', }) + ' ' + diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/index.ts b/src/plugins/visualize/public/application/editor/lib/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/index.ts rename to src/plugins/visualize/public/application/editor/lib/index.ts diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/make_stateful.ts b/src/plugins/visualize/public/application/editor/lib/make_stateful.ts similarity index 91% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/make_stateful.ts rename to src/plugins/visualize/public/application/editor/lib/make_stateful.ts index 8384585108a59..d071df314d99c 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/make_stateful.ts +++ b/src/plugins/visualize/public/application/editor/lib/make_stateful.ts @@ -17,8 +17,8 @@ * under the License. */ -import { PersistedState } from '../../../../../../../../plugins/visualizations/public'; -import { ReduxLikeStateContainer } from '../../../../../../../../plugins/kibana_utils/public'; +import { PersistedState } from '../../../../../visualizations/public'; +import { ReduxLikeStateContainer } from '../../../../../kibana_utils/public'; import { VisualizeAppState, VisualizeAppStateTransitions } from '../../types'; /** diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/migrate_app_state.ts b/src/plugins/visualize/public/application/editor/lib/migrate_app_state.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/migrate_app_state.ts rename to src/plugins/visualize/public/application/editor/lib/migrate_app_state.ts diff --git a/src/plugins/visualize/public/application/editor/lib/url_helper.test.ts b/src/plugins/visualize/public/application/editor/lib/url_helper.test.ts new file mode 100644 index 0000000000000..09609e3d7e362 --- /dev/null +++ b/src/plugins/visualize/public/application/editor/lib/url_helper.test.ts @@ -0,0 +1,39 @@ +/* + * 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 { addEmbeddableToDashboardUrl } from './url_helper'; + +describe('', () => { + it('addEmbeddableToDashboardUrl when dashboard is not saved', () => { + const id = '123eb456cd'; + const url = + "/pep/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!())"; + expect(addEmbeddableToDashboardUrl(url, id)).toEqual( + `/dashboard?_a=%28description%3A%27%27%2Cfilters%3A%21%28%29%29&_g=%28refreshInterval%3A%28pause%3A%21t%2Cvalue%3A0%29%2Ctime%3A%28from%3Anow-15m%2Cto%3Anow%29%29&addEmbeddableId=${id}&addEmbeddableType=visualization` + ); + }); + it('addEmbeddableToDashboardUrl when dashboard is saved', () => { + const id = '123eb456cd'; + const url = + "/pep/app/kibana#/dashboard/9b780cd0-3dd3-11e8-b2b9-5d5dc1715159?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!())"; + expect(addEmbeddableToDashboardUrl(url, id)).toEqual( + `/dashboard/9b780cd0-3dd3-11e8-b2b9-5d5dc1715159?_a=%28description%3A%27%27%2Cfilters%3A%21%28%29%29&_g=%28refreshInterval%3A%28pause%3A%21t%2Cvalue%3A0%29%2Ctime%3A%28from%3Anow-15m%2Cto%3Anow%29%29&addEmbeddableId=${id}&addEmbeddableType=visualization` + ); + }); +}); diff --git a/src/plugins/visualize/public/application/editor/lib/url_helper.ts b/src/plugins/visualize/public/application/editor/lib/url_helper.ts new file mode 100644 index 0000000000000..84e1ef9687cd0 --- /dev/null +++ b/src/plugins/visualize/public/application/editor/lib/url_helper.ts @@ -0,0 +1,40 @@ +/* + * 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 { parseUrl, stringify } from 'query-string'; +import { DashboardConstants } from '../../../../../dashboard/public'; +import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../../visualizations/public'; + +/** * + * Returns relative dashboard URL with added embeddableType and embeddableId query params + * eg. + * input: url: lol/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)), embeddableId: 12345 + * output: /dashboard?addEmbeddableType=visualization&addEmbeddableId=12345&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)) + * @param url dasbhoard absolute url + * @param embeddableId id of the saved visualization + */ +export function addEmbeddableToDashboardUrl(dashboardUrl: string, embeddableId: string) { + const { url, query } = parseUrl(dashboardUrl); + const [, dashboardId] = url.split(DashboardConstants.CREATE_NEW_DASHBOARD_URL); + + query[DashboardConstants.ADD_EMBEDDABLE_TYPE] = VISUALIZE_EMBEDDABLE_TYPE; + query[DashboardConstants.ADD_EMBEDDABLE_ID] = embeddableId; + + return `${DashboardConstants.CREATE_NEW_DASHBOARD_URL}${dashboardId}?${stringify(query)}`; +} diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/visualize_app_state.ts b/src/plugins/visualize/public/application/editor/lib/visualize_app_state.ts similarity index 98% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/visualize_app_state.ts rename to src/plugins/visualize/public/application/editor/lib/visualize_app_state.ts index 86f39ea76dd3a..fe2a19b7315c3 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/lib/visualize_app_state.ts +++ b/src/plugins/visualize/public/application/editor/lib/visualize_app_state.ts @@ -24,7 +24,7 @@ import { createStateContainer, syncState, IKbnUrlStateStorage, -} from '../../../../../../../../plugins/kibana_utils/public'; +} from '../../../../../kibana_utils/public'; import { PureVisState, VisualizeAppState, VisualizeAppStateTransitions } from '../../types'; const STATE_STORAGE_KEY = '_a'; diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization.js b/src/plugins/visualize/public/application/editor/visualization.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization.js rename to src/plugins/visualize/public/application/editor/visualization.js diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js b/src/plugins/visualize/public/application/editor/visualization_editor.js similarity index 98% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js rename to src/plugins/visualize/public/application/editor/visualization_editor.js index ef174dbaa5865..874b69532ec11 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js +++ b/src/plugins/visualize/public/application/editor/visualization_editor.js @@ -44,7 +44,6 @@ export function initVisEditorDirective(app, deps) { editor.render({ core: deps.core, data: deps.data, - embeddable: deps.embeddable, uiState: $scope.uiState, timeRange: $scope.timeRange, filters: $scope.filters, diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/help_menu/help_menu_util.js b/src/plugins/visualize/public/application/help_menu/help_menu_util.js similarity index 94% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/help_menu/help_menu_util.js rename to src/plugins/visualize/public/application/help_menu/help_menu_util.js index 9c00947d7663c..c297326f2e264 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/help_menu/help_menu_util.js +++ b/src/plugins/visualize/public/application/help_menu/help_menu_util.js @@ -21,7 +21,7 @@ import { i18n } from '@kbn/i18n'; export function addHelpMenuToAppChrome(chrome, docLinks) { chrome.setHelpExtension({ - appName: i18n.translate('kbn.visualize.helpMenu.appName', { + appName: i18n.translate('visualize.helpMenu.appName', { defaultMessage: 'Visualize', }), links: [ diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/_index.scss b/src/plugins/visualize/public/application/index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/_index.scss rename to src/plugins/visualize/public/application/index.scss diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/legacy_app.js b/src/plugins/visualize/public/application/legacy_app.js similarity index 94% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/legacy_app.js rename to src/plugins/visualize/public/application/legacy_app.js index a710d3e318749..7c5e3ce9408f0 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/legacy_app.js +++ b/src/plugins/visualize/public/application/legacy_app.js @@ -25,7 +25,8 @@ import { createKbnUrlStateStorage, redirectWhenMissing, ensureDefaultIndexPattern, -} from '../../../../../../plugins/kibana_utils/public'; +} from '../../../kibana_utils/public'; +import { createSavedSearchesLoader } from '../../../discover/public'; import editorTemplate from './editor/editor.html'; import visualizeListingTemplate from './listing/visualize_listing.html'; @@ -40,7 +41,6 @@ import { getCreateBreadcrumbs, getEditBreadcrumbs, } from './breadcrumbs'; -import { createSavedSearchesLoader } from '../../../../../../plugins/discover/public'; const getResolvedResults = deps => { const { core, data, visualizations, createVisEmbeddableFromObject } = deps; @@ -93,7 +93,7 @@ export function initVisualizeApp(app, deps) { app.factory('kbnUrlStateStorage', history => createKbnUrlStateStorage({ history, - useHash: deps.uiSettings.get('state:storeInSessionStorage'), + useHash: deps.core.uiSettings.get('state:storeInSessionStorage'), }) ); @@ -107,10 +107,10 @@ export function initVisualizeApp(app, deps) { } return { - text: i18n.translate('kbn.visualize.badge.readOnly.text', { + text: i18n.translate('visualize.badge.readOnly.text', { defaultMessage: 'Read only', }), - tooltip: i18n.translate('kbn.visualize.badge.readOnly.tooltip', { + tooltip: i18n.translate('visualize.badge.readOnly.tooltip', { defaultMessage: 'Unable to save visualizations', }), iconType: 'glasses', @@ -156,7 +156,7 @@ export function initVisualizeApp(app, deps) { if (shouldHaveIndex && !hasIndex) { throw new Error( i18n.translate( - 'kbn.visualize.createVisualization.noIndexPatternOrSavedSearchIdErrorMessage', + 'visualize.createVisualization.noIndexPatternOrSavedSearchIdErrorMessage', { defaultMessage: 'You must provide either an indexPattern or a savedSearchId', } diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/_index.scss b/src/plugins/visualize/public/application/listing/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/_index.scss rename to src/plugins/visualize/public/application/listing/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/_listing.scss b/src/plugins/visualize/public/application/listing/_listing.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/_listing.scss rename to src/plugins/visualize/public/application/listing/_listing.scss diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.html b/src/plugins/visualize/public/application/listing/visualize_listing.html similarity index 100% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.html rename to src/plugins/visualize/public/application/listing/visualize_listing.html diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.js b/src/plugins/visualize/public/application/listing/visualize_listing.js similarity index 93% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.js rename to src/plugins/visualize/public/application/listing/visualize_listing.js index 098633d046062..900c17fa394de 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.js +++ b/src/plugins/visualize/public/application/listing/visualize_listing.js @@ -24,7 +24,7 @@ import { VisualizeConstants } from '../visualize_constants'; import { i18n } from '@kbn/i18n'; import { getServices } from '../../kibana_services'; -import { syncQueryStateWithUrl } from '../../../../../../../plugins/data/public'; +import { syncQueryStateWithUrl } from '../../../../data/public'; export function initListingDirective(app, I18nContext) { app.directive('visualizeListingTable', reactDirective => @@ -40,9 +40,8 @@ export function VisualizeListingController($scope, createNewVis, kbnUrlStateStor savedVisualizations, data: { query }, toastNotifications, - uiSettings, visualizations, - core: { docLinks, savedObjects }, + core: { docLinks, savedObjects, uiSettings }, } = getServices(); // syncs `_g` portion of url with query services @@ -110,7 +109,7 @@ export function VisualizeListingController($scope, createNewVis, kbnUrlStateStor }) ).catch(error => { toastNotifications.addError(error, { - title: i18n.translate('kbn.visualize.visualizeListingDeleteErrorTitle', { + title: i18n.translate('visualize.visualizeListingDeleteErrorTitle', { defaultMessage: 'Error deleting visualization', }), }); @@ -119,7 +118,7 @@ export function VisualizeListingController($scope, createNewVis, kbnUrlStateStor chrome.setBreadcrumbs([ { - text: i18n.translate('kbn.visualize.visualizeListingBreadcrumbsTitle', { + text: i18n.translate('visualize.visualizeListingBreadcrumbsTitle', { defaultMessage: 'Visualize', }), }, diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing_table.js b/src/plugins/visualize/public/application/listing/visualize_listing_table.js similarity index 83% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing_table.js rename to src/plugins/visualize/public/application/listing/visualize_listing_table.js index 932ac8996e97e..100becdd3a80f 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing_table.js +++ b/src/plugins/visualize/public/application/listing/visualize_listing_table.js @@ -21,7 +21,7 @@ import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { TableListView } from '../../../../../../../plugins/kibana_react/public'; +import { TableListView } from '../../../../kibana_react/public'; import { EuiIcon, EuiBetaBadge, EuiLink, EuiButton, EuiEmptyPrompt } from '@elastic/eui'; @@ -33,7 +33,7 @@ class VisualizeListingTable extends Component { } render() { - const { visualizeCapabilities, uiSettings, toastNotifications } = getServices(); + const { visualizeCapabilities, core, toastNotifications } = getServices(); return ( item.canDelete} initialFilter={''} noItemsFragment={this.getNoItemsMessage()} - entityName={i18n.translate('kbn.visualize.listing.table.entityName', { + entityName={i18n.translate('visualize.listing.table.entityName', { defaultMessage: 'visualization', })} - entityNamePlural={i18n.translate('kbn.visualize.listing.table.entityNamePlural', { + entityNamePlural={i18n.translate('visualize.listing.table.entityNamePlural', { defaultMessage: 'visualizations', })} - tableListTitle={i18n.translate('kbn.visualize.listing.table.listTitle', { + tableListTitle={i18n.translate('visualize.listing.table.listTitle', { defaultMessage: 'Visualizations', })} toastNotifications={toastNotifications} - uiSettings={uiSettings} + uiSettings={core.uiSettings} /> ); } @@ -67,7 +67,7 @@ class VisualizeListingTable extends Component { const tableColumns = [ { field: 'title', - name: i18n.translate('kbn.visualize.listing.table.titleColumnName', { + name: i18n.translate('visualize.listing.table.titleColumnName', { defaultMessage: 'Title', }), sortable: true, @@ -82,7 +82,7 @@ class VisualizeListingTable extends Component { }, { field: 'typeTitle', - name: i18n.translate('kbn.visualize.listing.table.typeColumnName', { + name: i18n.translate('visualize.listing.table.typeColumnName', { defaultMessage: 'Type', }), sortable: true, @@ -96,7 +96,7 @@ class VisualizeListingTable extends Component { }, { field: 'description', - name: i18n.translate('kbn.dashboard.listing.table.descriptionColumnName', { + name: i18n.translate('visualize.listing.table.descriptionColumnName', { defaultMessage: 'Description', }), sortable: true, @@ -116,7 +116,7 @@ class VisualizeListingTable extends Component { title={

@@ -133,7 +133,7 @@ class VisualizeListingTable extends Component { title={

@@ -142,7 +142,7 @@ class VisualizeListingTable extends Component {

@@ -156,7 +156,7 @@ class VisualizeListingTable extends Component { data-test-subj="createVisualizationPromptButton" > @@ -192,10 +192,10 @@ class VisualizeListingTable extends Component { ( + prop: T, + value: VisualizeAppState[T] + ) => VisualizeAppState; + setVis: (state: VisualizeAppState) => (vis: Partial) => VisualizeAppState; + removeSavedQuery: (state: VisualizeAppState) => (defaultQuery: Query) => VisualizeAppState; + unlinkSavedSearch: ( + state: VisualizeAppState + ) => ({ query, parentFilters }: { query?: Query; parentFilters?: Filter[] }) => VisualizeAppState; + updateVisState: (state: VisualizeAppState) => (vis: PureVisState) => VisualizeAppState; + updateFromSavedQuery: (state: VisualizeAppState) => (savedQuery: SavedQuery) => VisualizeAppState; +} + +export interface EditorRenderProps { + core: CoreStart; + data: DataPublicPluginStart; + filters: Filter[]; + timeRange: TimeRange; + query?: Query; + savedSearch?: SavedSearch; + uiState: PersistedState; + /** + * Flag to determine if visualiztion is linked to the saved search + */ + linked: boolean; +} + +export interface SavedVisualizations { + urlFor: (id: string) => string; + get: (id: string) => Promise; +} diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/visualize_app.ts b/src/plugins/visualize/public/application/visualize_app.ts similarity index 95% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/visualize_app.ts rename to src/plugins/visualize/public/application/visualize_app.ts index a4afac23f4842..1a4e1534db9e2 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/visualize_app.ts +++ b/src/plugins/visualize/public/application/visualize_app.ts @@ -27,5 +27,5 @@ import { initListingDirective } from './listing/visualize_listing'; export function initVisualizeAppDirective(app: IModule, deps: VisualizeKibanaServices) { initEditorDirective(app, deps); - initListingDirective(app, deps.core.i18n.Context); + initListingDirective(app, deps.I18nContext); } diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/visualize_constants.ts b/src/plugins/visualize/public/application/visualize_constants.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/visualize/np_ready/visualize_constants.ts rename to src/plugins/visualize/public/application/visualize_constants.ts diff --git a/src/plugins/visualize/public/index.ts b/src/plugins/visualize/public/index.ts new file mode 100644 index 0000000000000..f7bbcb1456da4 --- /dev/null +++ b/src/plugins/visualize/public/index.ts @@ -0,0 +1,28 @@ +/* + * 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 { PluginInitializerContext } from 'kibana/public'; +import { VisualizePlugin } from './plugin'; + +export { EditorRenderProps } from './application/types'; +export { VisualizeConstants, createVisualizeEditUrl } from './application/visualize_constants'; + +export const plugin = (context: PluginInitializerContext) => { + return new VisualizePlugin(context); +}; diff --git a/src/plugins/visualize/public/kibana_services.ts b/src/plugins/visualize/public/kibana_services.ts new file mode 100644 index 0000000000000..765e9a82ae899 --- /dev/null +++ b/src/plugins/visualize/public/kibana_services.ts @@ -0,0 +1,75 @@ +/* + * 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 { + ChromeStart, + CoreStart, + SavedObjectsClientContract, + ToastsStart, + PluginInitializerContext, + I18nStart, +} from 'kibana/public'; + +import { NavigationPublicPluginStart as NavigationStart } from '../../navigation/public'; +import { Storage } from '../../kibana_utils/public'; +import { SharePluginStart } from '../../share/public'; +import { DataPublicPluginStart } from '../../data/public'; +import { VisualizationsStart } from '../../visualizations/public'; +import { SavedVisualizations } from './application/types'; +import { KibanaLegacyStart } from '../../kibana_legacy/public'; +import { DefaultEditorController } from '../../vis_default_editor/public'; + +export interface VisualizeKibanaServices { + pluginInitializerContext: PluginInitializerContext; + addBasePath: (url: string) => string; + chrome: ChromeStart; + core: CoreStart; + data: DataPublicPluginStart; + localStorage: Storage; + navigation: NavigationStart; + toastNotifications: ToastsStart; + savedObjectsClient: SavedObjectsClientContract; + savedVisualizations: SavedVisualizations; + share?: SharePluginStart; + config: KibanaLegacyStart['config']; + visualizeCapabilities: any; + visualizations: VisualizationsStart; + I18nContext: I18nStart['Context']; + setActiveUrl: (newUrl: string) => void; + DefaultVisualizationEditor: typeof DefaultEditorController; + createVisEmbeddableFromObject: VisualizationsStart['__LEGACY']['createVisEmbeddableFromObject']; +} + +let services: VisualizeKibanaServices | null = null; +export function setServices(newServices: VisualizeKibanaServices) { + services = newServices; +} + +export function getServices() { + if (!services) { + throw new Error( + 'Kibana services not set - are you trying to import this module from outside of the visualize app?' + ); + } + return services; +} + +export function clearServices() { + services = null; +} diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts new file mode 100644 index 0000000000000..ab64e083a553d --- /dev/null +++ b/src/plugins/visualize/public/plugin.ts @@ -0,0 +1,158 @@ +/* + * 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 { BehaviorSubject } from 'rxjs'; +import { i18n } from '@kbn/i18n'; +import { filter, map } from 'rxjs/operators'; + +import { + AppMountParameters, + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, +} from 'kibana/public'; + +import { Storage, createKbnUrlTracker } from '../../kibana_utils/public'; +import { DataPublicPluginStart, DataPublicPluginSetup, esFilters } from '../../data/public'; +import { NavigationPublicPluginStart as NavigationStart } from '../../navigation/public'; +import { SharePluginStart } from '../../share/public'; +import { KibanaLegacySetup, AngularRenderedAppUpdater } from '../../kibana_legacy/public'; +import { VisualizationsStart } from '../../visualizations/public'; +import { VisualizeConstants } from './application/visualize_constants'; +import { setServices, VisualizeKibanaServices } from './kibana_services'; +import { FeatureCatalogueCategory, HomePublicPluginSetup } from '../../home/public'; +import { DefaultEditorController } from '../../vis_default_editor/public'; + +export interface VisualizePluginStartDependencies { + data: DataPublicPluginStart; + navigation: NavigationStart; + share?: SharePluginStart; + visualizations: VisualizationsStart; +} + +export interface VisualizePluginSetupDependencies { + home?: HomePublicPluginSetup; + kibanaLegacy: KibanaLegacySetup; + data: DataPublicPluginSetup; +} + +export class VisualizePlugin + implements + Plugin { + private appStateUpdater = new BehaviorSubject(() => ({})); + private stopUrlTracking: (() => void) | undefined = undefined; + + constructor(private initializerContext: PluginInitializerContext) {} + + public async setup( + core: CoreSetup, + { home, kibanaLegacy, data }: VisualizePluginSetupDependencies + ) { + const { appMounted, appUnMounted, stop: stopUrlTracker, setActiveUrl } = createKbnUrlTracker({ + baseUrl: core.http.basePath.prepend('/app/kibana'), + defaultSubUrl: '#/visualize', + storageKey: `lastUrl:${core.http.basePath.get()}:visualize`, + navLinkUpdater$: this.appStateUpdater, + toastNotifications: core.notifications.toasts, + stateParams: [ + { + kbnUrlKey: '_g', + stateUpdate$: data.query.state$.pipe( + filter( + ({ changes }) => !!(changes.globalFilters || changes.time || changes.refreshInterval) + ), + map(({ state }) => ({ + ...state, + filters: state.filters?.filter(esFilters.isFilterPinned), + })) + ), + }, + ], + }); + this.stopUrlTracking = () => { + stopUrlTracker(); + }; + + kibanaLegacy.registerLegacyApp({ + id: 'visualize', + title: 'Visualize', + updater$: this.appStateUpdater.asObservable(), + navLinkId: 'kibana:visualize', + mount: async (params: AppMountParameters) => { + const [coreStart, pluginsStart] = await core.getStartServices(); + + appMounted(); + + const deps: VisualizeKibanaServices = { + pluginInitializerContext: this.initializerContext, + addBasePath: coreStart.http.basePath.prepend, + core: coreStart, + config: kibanaLegacy.config, + chrome: coreStart.chrome, + data: pluginsStart.data, + localStorage: new Storage(localStorage), + navigation: pluginsStart.navigation, + savedObjectsClient: coreStart.savedObjects.client, + savedVisualizations: pluginsStart.visualizations.savedVisualizationsLoader, + share: pluginsStart.share, + toastNotifications: coreStart.notifications.toasts, + visualizeCapabilities: coreStart.application.capabilities.visualize, + visualizations: pluginsStart.visualizations, + I18nContext: coreStart.i18n.Context, + setActiveUrl, + DefaultVisualizationEditor: DefaultEditorController, + createVisEmbeddableFromObject: + pluginsStart.visualizations.__LEGACY.createVisEmbeddableFromObject, + }; + setServices(deps); + + const { renderApp } = await import('./application/application'); + const unmount = renderApp(params.element, params.appBasePath, deps); + return () => { + unmount(); + appUnMounted(); + }; + }, + }); + + if (home) { + home.featureCatalogue.register({ + id: 'visualize', + title: 'Visualize', + description: i18n.translate('visualize.visualizeDescription', { + defaultMessage: + 'Create visualizations and aggregate data stores in your Elasticsearch indices.', + }), + icon: 'visualizeApp', + path: `/app/kibana#${VisualizeConstants.LANDING_PAGE_PATH}`, + showOnHomePage: true, + category: FeatureCatalogueCategory.DATA, + }); + } + } + + public start(core: CoreStart, plugins: VisualizePluginStartDependencies) {} + + stop() { + if (this.stopUrlTracking) { + this.stopUrlTracking(); + } + } +} diff --git a/test/api_integration/apis/saved_objects/bulk_create.js b/test/api_integration/apis/saved_objects/bulk_create.js index afa5153336ff7..2d77fdf266793 100644 --- a/test/api_integration/apis/saved_objects/bulk_create.js +++ b/test/api_integration/apis/saved_objects/bulk_create.js @@ -58,7 +58,9 @@ export default function({ getService }) { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', error: { - message: 'version conflict, document already exists', + error: 'Conflict', + message: + 'Saved object [visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab] conflict', statusCode: 409, }, }, diff --git a/test/api_integration/apis/saved_objects/bulk_get.js b/test/api_integration/apis/saved_objects/bulk_get.js index 984ac781d3e46..67e511f2bf548 100644 --- a/test/api_integration/apis/saved_objects/bulk_get.js +++ b/test/api_integration/apis/saved_objects/bulk_get.js @@ -80,8 +80,9 @@ export default function({ getService }) { id: 'does not exist', type: 'dashboard', error: { + error: 'Not Found', + message: 'Saved object [dashboard/does not exist] not found', statusCode: 404, - message: 'Not found', }, }, { @@ -123,24 +124,28 @@ export default function({ getService }) { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', type: 'visualization', error: { + error: 'Not Found', + message: + 'Saved object [visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab] not found', statusCode: 404, - message: 'Not found', }, }, { id: 'does not exist', type: 'dashboard', error: { + error: 'Not Found', + message: 'Saved object [dashboard/does not exist] not found', statusCode: 404, - message: 'Not found', }, }, { id: '7.0.0-alpha1', type: 'config', error: { + error: 'Not Found', + message: 'Saved object [config/7.0.0-alpha1] not found', statusCode: 404, - message: 'Not found', }, }, ], diff --git a/test/api_integration/apis/saved_objects/export.js b/test/api_integration/apis/saved_objects/export.js index fc9ab8140869c..9558e82880deb 100644 --- a/test/api_integration/apis/saved_objects/export.js +++ b/test/api_integration/apis/saved_objects/export.js @@ -170,8 +170,9 @@ export default function({ getService }) { id: '1', type: 'dashboard', error: { + error: 'Not Found', + message: 'Saved object [dashboard/1] not found', statusCode: 404, - message: 'Not found', }, }, ], diff --git a/test/api_integration/apis/saved_objects/migrations.js b/test/api_integration/apis/saved_objects/migrations.js index bd207ccb41b20..9d908b95ca575 100644 --- a/test/api_integration/apis/saved_objects/migrations.js +++ b/test/api_integration/apis/saved_objects/migrations.js @@ -383,7 +383,7 @@ function migrationsToTypes(migrations) { return Object.entries(migrations).map(([type, migrations]) => ({ name: type, hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', mappings: { properties: {} }, migrations: { ...migrations }, })); diff --git a/test/common/config.js b/test/common/config.js index faf8cef027170..ca80dfb01012f 100644 --- a/test/common/config.js +++ b/test/common/config.js @@ -56,6 +56,10 @@ export default function() { `--elasticsearch.password=${kibanaServerTestUser.password}`, `--home.disableWelcomeScreen=true`, '--telemetry.banner=false', + '--telemetry.optIn=false', + // These are *very* important to have them pointing to staging + '--telemetry.url=https://telemetry-staging.elastic.co/xpack/v2/send', + '--telemetry.optInStatusUrl=https://telemetry-staging.elastic.co/opt_in_status/v2/send', `--server.maxPayloadBytes=1679958`, // newsfeed mock service `--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'newsfeed')}`, diff --git a/test/examples/embeddables/adding_children.ts b/test/examples/embeddables/adding_children.ts index 110b8ce573332..5fe88b5dd33f0 100644 --- a/test/examples/embeddables/adding_children.ts +++ b/test/examples/embeddables/adding_children.ts @@ -23,6 +23,7 @@ import { PluginFunctionalProviderContext } from 'test/plugin_functional/services // eslint-disable-next-line import/no-default-export export default function({ getService }: PluginFunctionalProviderContext) { const testSubjects = getService('testSubjects'); + const flyout = getService('flyout'); describe('creating and adding children', () => { before(async () => { @@ -39,5 +40,15 @@ export default function({ getService }: PluginFunctionalProviderContext) { const tasks = await testSubjects.getVisibleTextAll('todoEmbeddableTask'); expect(tasks).to.eql(['Goes out on Wednesdays!', 'new task']); }); + + it('Can add a child backed off a saved object', async () => { + await testSubjects.click('embeddablePanelToggleMenuIcon'); + await testSubjects.click('embeddablePanelAction-ACTION_ADD_PANEL'); + await testSubjects.click('savedObjectTitleGarbage'); + await testSubjects.moveMouseTo('euiFlyoutCloseButton'); + await flyout.ensureClosed('dashboardAddPanel'); + const tasks = await testSubjects.getVisibleTextAll('todoEmbeddableTask'); + expect(tasks).to.eql(['Goes out on Wednesdays!', 'new task', 'Take the garbage out']); + }); }); } diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.js b/test/functional/apps/dashboard/create_and_add_embeddables.js index 3ce8e353e61fc..410acdcb5680d 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.js +++ b/test/functional/apps/dashboard/create_and_add_embeddables.js @@ -19,7 +19,7 @@ import expect from '@kbn/expect'; -import { VisualizeConstants } from '../../../../src/legacy/core_plugins/kibana/public/visualize/np_ready/visualize_constants'; +import { VisualizeConstants } from '../../../../src/plugins/visualize/public/application/visualize_constants'; export default function({ getService, getPageObjects }) { const retry = getService('retry'); diff --git a/test/functional/apps/dashboard/dashboard_filter_bar.js b/test/functional/apps/dashboard/dashboard_filter_bar.js index 6d2a30fa85325..f6089871010c3 100644 --- a/test/functional/apps/dashboard/dashboard_filter_bar.js +++ b/test/functional/apps/dashboard/dashboard_filter_bar.js @@ -27,6 +27,7 @@ export default function({ getService, getPageObjects }) { const pieChart = getService('pieChart'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); + const browser = getService('browser'); const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'visualize', 'timePicker']); describe('dashboard filter bar', () => { @@ -126,9 +127,46 @@ export default function({ getService, getPageObjects }) { const filterCount = await filterBar.getFilterCount(); expect(filterCount).to.equal(1); + await pieChart.expectPieSliceCount(1); + }); + it("restoring filters doesn't break back button", async () => { + await browser.goBack(); + await PageObjects.dashboard.expectExistsDashboardLandingPage(); + await browser.goForward(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.waitForRenderComplete(); await pieChart.expectPieSliceCount(1); }); + + it("saving with pinned filter doesn't unpin them", async () => { + const filterKey = 'bytes'; + await filterBar.toggleFilterPinned(filterKey); + await PageObjects.dashboard.switchToEditMode(); + await PageObjects.dashboard.saveDashboard('saved with pinned filters', { + saveAsNew: true, + }); + expect(await filterBar.isFilterPinned(filterKey)).to.be(true); + await pieChart.expectPieSliceCount(1); + }); + + it("navigating to a dashboard with global filter doesn't unpin it if same filter is saved with dashboard", async () => { + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.dashboard.loadSavedDashboard('with filters'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await filterBar.isFilterPinned('bytes')).to.be(true); + await pieChart.expectPieSliceCount(1); + }); + + it("pinned filters aren't saved", async () => { + await filterBar.removeFilter('bytes'); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.dashboard.loadSavedDashboard('saved with pinned filters'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await filterBar.getFilterCount()).to.be(0); + await pieChart.expectPieSliceCount(5); + }); }); describe('saved search filtering', function() { diff --git a/test/functional/apps/dashboard/panel_controls.js b/test/functional/apps/dashboard/panel_controls.js index f30f58913bd97..52c4a11360355 100644 --- a/test/functional/apps/dashboard/panel_controls.js +++ b/test/functional/apps/dashboard/panel_controls.js @@ -24,7 +24,7 @@ import { AREA_CHART_VIS_NAME, LINE_CHART_VIS_NAME, } from '../../page_objects/dashboard_page'; -import { VisualizeConstants } from '../../../../src/legacy/core_plugins/kibana/public/visualize/np_ready/visualize_constants'; +import { VisualizeConstants } from '../../../../src/plugins/visualize/public/application/visualize_constants'; export default function({ getService, getPageObjects }) { const browser = getService('browser'); diff --git a/test/functional/apps/discover/_discover_histogram.js b/test/functional/apps/discover/_discover_histogram.js index f815c505a8c27..eeef3333aab0f 100644 --- a/test/functional/apps/discover/_discover_histogram.js +++ b/test/functional/apps/discover/_discover_histogram.js @@ -66,9 +66,8 @@ export default function({ getService, getPageObjects }) { }); it('should visualize monthly data with different day intervals', async () => { - //Nov 1, 2017 @ 01:00:00.000 - Mar 21, 2018 @ 02:00:00.000 - const fromTime = '2017-11-01 00:00:00.000'; - const toTime = '2018-03-21 00:00:00.000'; + const fromTime = 'Nov 01, 2017 @ 00:00:00.000'; + const toTime = 'Mar 21, 2018 @ 00:00:00.000'; await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); await PageObjects.discover.setChartInterval('Monthly'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -76,24 +75,25 @@ export default function({ getService, getPageObjects }) { expect(chartCanvasExist).to.be(true); }); it('should visualize weekly data with within DST changes', async () => { - //Nov 1, 2017 @ 01:00:00.000 - Mar 21, 2018 @ 02:00:00.000 - const fromTime = '2018-03-01 00:00:00.000'; - const toTime = '2018-05-01 00:00:00.000'; + const fromTime = 'Mar 01, 2018 @ 00:00:00.000'; + const toTime = 'May 01, 2018 @ 00:00:00.000'; await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); await PageObjects.discover.setChartInterval('Weekly'); await PageObjects.header.waitUntilLoadingHasFinished(); const chartCanvasExist = await elasticChart.canvasExists(); expect(chartCanvasExist).to.be(true); }); - it('should visualize monthly data with different years Scaled to 30d', async () => { - //Nov 1, 2017 @ 01:00:00.000 - Mar 21, 2018 @ 02:00:00.000 - const fromTime = '2010-01-01 00:00:00.000'; - const toTime = '2018-03-21 00:00:00.000'; + it('should visualize monthly data with different years Scaled to 30 days', async () => { + const fromTime = 'Jan 01, 2010 @ 00:00:00.000'; + const toTime = 'Mar 21, 2019 @ 00:00:00.000'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); await PageObjects.discover.setChartInterval('Daily'); await PageObjects.header.waitUntilLoadingHasFinished(); const chartCanvasExist = await elasticChart.canvasExists(); expect(chartCanvasExist).to.be(true); + const chartIntervalScaledDesc = await PageObjects.discover.getChartIntervalScaledToDesc(); + expect(chartIntervalScaledDesc).to.be('Scaled to 30 days'); }); }); } diff --git a/test/functional/apps/discover/_saved_queries.js b/test/functional/apps/discover/_saved_queries.js index 3cdaccf32cdc3..76f3a3aea365f 100644 --- a/test/functional/apps/discover/_saved_queries.js +++ b/test/functional/apps/discover/_saved_queries.js @@ -147,6 +147,25 @@ export default function({ getService, getPageObjects }) { await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); expect(await queryBar.getQueryString()).to.eql(''); }); + + // https://github.com/elastic/kibana/issues/63505 + it('allows clearing if non default language was remembered in localstorage', async () => { + await queryBar.switchQueryLanguage('lucene'); + await PageObjects.common.navigateToApp('discover'); // makes sure discovered is reloaded without any state in url + await queryBar.expectQueryLanguageOrFail('lucene'); // make sure lucene is remembered after refresh (comes from localstorage) + await savedQueryManagementComponent.loadSavedQuery('OkResponse'); + await queryBar.expectQueryLanguageOrFail('kql'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + await queryBar.expectQueryLanguageOrFail('lucene'); + }); + + // fails: bug in discover https://github.com/elastic/kibana/issues/63561 + // unskip this test when bug is fixed + it.skip('changing language removes saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OkResponse'); + await queryBar.switchQueryLanguage('lucene'); + expect(await queryBar.getQueryString()).to.eql(''); + }); }); }); } diff --git a/test/functional/apps/home/_navigation.ts b/test/functional/apps/home/_navigation.ts index efc0dad394464..2c927e9a2f4c7 100644 --- a/test/functional/apps/home/_navigation.ts +++ b/test/functional/apps/home/_navigation.ts @@ -52,6 +52,7 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { describe('Kibana browser back navigation should work', function describeIndexTests() { before(async () => { + await esArchiver.loadIfNeeded('discover'); await esArchiver.loadIfNeeded('logstash_functional'); if (browser.isInternetExplorer) { await kibanaServer.uiSettings.replace({ 'state:storeInSessionStorage': false }); diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index f06baeb7a4167..862e5127bb670 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -247,25 +247,11 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo } currentUrl = (await browser.getCurrentUrl()).replace(/\/\/\w+:\w+@/, '//'); - const maxAdditionalLengthOnNavUrl = 230; - - // On several test failures at the end of the TileMap test we try to navigate back to - // Visualize so we can create the next Vertical Bar Chart, but we can see from the - // logging and the screenshot that it's still on the TileMap page. Why didn't the "get" - // with a new timestamped URL go? I thought that sleep(700) between the get and the - // refresh would solve the problem but didn't seem to always work. - // So this hack fails the navSuccessful check if the currentUrl doesn't match the - // appUrl plus up to 230 other chars. - // Navigating to Settings when there is a default index pattern has a URL length of 196 - // (from debug output). Some other tabs may also be long. But a rather simple configured - // visualization is about 1000 chars long. So at least we catch that case. - - // Browsers don't show the ':port' if it's 80 or 443 so we have to - // remove that part so we can get a match in the tests. - const navSuccessful = new RegExp( - appUrl.replace(':80/', '/').replace(':443/', '/') + - `.{0,${maxAdditionalLengthOnNavUrl}}$` - ).test(currentUrl); + + const navSuccessful = currentUrl + .replace(':80/', '/') + .replace(':443/', '/') + .startsWith(appUrl); if (!navSuccessful) { const msg = `App failed to load: ${appName} in ${defaultFindTimeout}ms appUrl=${appUrl} currentUrl=${currentUrl}`; diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 00bf87621864a..e43a774940391 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -162,6 +162,11 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider return selectedOption.getVisibleText(); } + public async getChartIntervalScaledToDesc() { + await header.waitUntilLoadingHasFinished(); + return await testSubjects.getVisibleText('discoverIntervalSelectScaledToDesc'); + } + public async setChartInterval(interval: string) { const optionElement = await find.byCssSelector(`option[label="${interval}"]`, 5000); await optionElement.click(); diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index 3b63fa68d71ee..220c2d8f6b363 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -18,7 +18,7 @@ */ import { FtrProviderContext } from '../ftr_provider_context'; -import { VisualizeConstants } from '../../../src/legacy/core_plugins/kibana/public/visualize/np_ready/visualize_constants'; +import { VisualizeConstants } from '../../../src/plugins/visualize/public/application/visualize_constants'; export function VisualizePageProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); diff --git a/test/functional/services/filter_bar.ts b/test/functional/services/filter_bar.ts index 9d494b1e6d950..a463a593e9e04 100644 --- a/test/functional/services/filter_bar.ts +++ b/test/functional/services/filter_bar.ts @@ -32,10 +32,16 @@ export function FilterBarProvider({ getService, getPageObjects }: FtrProviderCon * @param value filter value * @param enabled filter status */ - public async hasFilter(key: string, value: string, enabled: boolean = true): Promise { + public async hasFilter( + key: string, + value: string, + enabled: boolean = true, + pinned: boolean = false + ): Promise { const filterActivationState = enabled ? 'enabled' : 'disabled'; + const filterPinnedState = pinned ? 'pinned' : 'unpinned'; return testSubjects.exists( - `filter filter-${filterActivationState} filter-key-${key} filter-value-${value}`, + `filter filter-${filterActivationState} filter-key-${key} filter-value-${value} filter-${filterPinnedState}`, { allowHidden: true, } @@ -80,6 +86,11 @@ export function FilterBarProvider({ getService, getPageObjects }: FtrProviderCon await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); } + public async isFilterPinned(key: string): Promise { + const filter = await testSubjects.find(`~filter & ~filter-key-${key}`); + return (await filter.getAttribute('data-test-subj')).includes('filter-pinned'); + } + public async getFilterCount(): Promise { const filters = await testSubjects.findAll('~filter'); return filters.length; diff --git a/test/functional/services/query_bar.ts b/test/functional/services/query_bar.ts index ace8b97155c09..7c7fd2d81f170 100644 --- a/test/functional/services/query_bar.ts +++ b/test/functional/services/query_bar.ts @@ -17,6 +17,7 @@ * under the License. */ +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function QueryBarProvider({ getService, getPageObjects }: FtrProviderContext) { @@ -25,6 +26,7 @@ export function QueryBarProvider({ getService, getPageObjects }: FtrProviderCont const log = getService('log'); const PageObjects = getPageObjects(['header', 'common']); const find = getService('find'); + const browser = getService('browser'); class QueryBar { async getQueryString(): Promise { @@ -62,6 +64,24 @@ export function QueryBarProvider({ getService, getPageObjects }: FtrProviderCont public async clickQuerySubmitButton(): Promise { await testSubjects.click('querySubmitButton'); } + + public async switchQueryLanguage(lang: 'kql' | 'lucene'): Promise { + await testSubjects.click('switchQueryLanguageButton'); + const kqlToggle = await testSubjects.find('languageToggle'); + const currentLang = + (await kqlToggle.getAttribute('aria-checked')) === 'true' ? 'kql' : 'lucene'; + if (lang !== currentLang) { + await kqlToggle.click(); + } + + await browser.pressKeys(browser.keys.ESCAPE); // close popover + await this.expectQueryLanguageOrFail(lang); // make sure lang is switched + } + + public async expectQueryLanguageOrFail(lang: 'kql' | 'lucene'): Promise { + const queryLanguageButton = await testSubjects.find('switchQueryLanguageButton'); + expect((await queryLanguageButton.getVisibleText()).toLowerCase()).to.eql(lang); + } } return new QueryBar(); diff --git a/test/functional/services/remote/remote.ts b/test/functional/services/remote/remote.ts index b0724488cb5db..933b08f7681e8 100644 --- a/test/functional/services/remote/remote.ts +++ b/test/functional/services/remote/remote.ts @@ -64,13 +64,10 @@ export async function RemoteProvider({ getService }: FtrProviderContext) { lifecycle, config.get('browser.logPollingMs') ); - const isW3CEnabled = (driver as any).executor_.w3c; const caps = await driver.getCapabilities(); - const browserVersion = caps.get( - isW3CEnabled || browserType === Browsers.ChromiumEdge ? 'browserVersion' : 'version' - ); + const browserVersion = caps.get(isW3CEnabled ? 'browserVersion' : 'version'); log.info( `Remote initialized: ${caps.get( diff --git a/test/functional/services/remote/webdriver.ts b/test/functional/services/remote/webdriver.ts index fc0b5bbb787c8..1b7ef2c1855d0 100644 --- a/test/functional/services/remote/webdriver.ts +++ b/test/functional/services/remote/webdriver.ts @@ -30,13 +30,13 @@ import geckoDriver from 'geckodriver'; import { Builder, Capabilities, By, logging, until } from 'selenium-webdriver'; import chrome from 'selenium-webdriver/chrome'; import firefox from 'selenium-webdriver/firefox'; -// @ts-ignore internal modules are not typed import edge from 'selenium-webdriver/edge'; -import { installDriver } from 'ms-chromium-edge-driver'; // @ts-ignore internal modules are not typed import { Executor } from 'selenium-webdriver/lib/http'; // @ts-ignore internal modules are not typed import { getLogger } from 'selenium-webdriver/lib/logging'; +import { installDriver } from 'ms-chromium-edge-driver'; + import { pollForLogEntry$ } from './poll_for_log_entry'; import { createStdoutSocket } from './create_stdout_stream'; import { preventParallelCalls } from './prevent_parallel_calls'; @@ -77,46 +77,6 @@ async function attemptToCreateCommand( const buildDriverInstance = async () => { switch (browserType) { - case 'msedge': { - if (edgePaths && edgePaths.browserPath && edgePaths.driverPath) { - const edgeOptions = new edge.Options(); - if (headlessBrowser === '1') { - // @ts-ignore internal modules are not typed - edgeOptions.headless(); - } - // @ts-ignore internal modules are not typed - edgeOptions.setEdgeChromium(true); - // @ts-ignore internal modules are not typed - edgeOptions.setBinaryPath(edgePaths.browserPath); - const session = await new Builder() - .forBrowser('MicrosoftEdge') - .setEdgeOptions(edgeOptions) - .setEdgeService(new edge.ServiceBuilder(edgePaths.driverPath)) - .build(); - return { - session, - consoleLog$: pollForLogEntry$( - session, - logging.Type.BROWSER, - logPollingMs, - lifecycle.cleanup.after$ - ).pipe( - takeUntil(lifecycle.cleanup.after$), - map(({ message, level: { name: level } }) => ({ - message: message.replace(/\\n/g, '\n'), - level, - })) - ), - }; - } else { - throw new Error( - `Chromium Edge session requires browser or driver path to be defined: ${JSON.stringify( - edgePaths - )}` - ); - } - } - case 'chrome': { const chromeCapabilities = Capabilities.chrome(); const chromeOptions = [ @@ -179,6 +139,46 @@ async function attemptToCreateCommand( }; } + case 'msedge': { + if (edgePaths && edgePaths.browserPath && edgePaths.driverPath) { + const edgeOptions = new edge.Options(); + if (headlessBrowser === '1') { + // @ts-ignore internal modules are not typed + edgeOptions.headless(); + } + // @ts-ignore internal modules are not typed + edgeOptions.setEdgeChromium(true); + // @ts-ignore internal modules are not typed + edgeOptions.setBinaryPath(edgePaths.browserPath); + const session = await new Builder() + .forBrowser('MicrosoftEdge') + .setEdgeOptions(edgeOptions) + .setEdgeService(new edge.ServiceBuilder(edgePaths.driverPath)) + .build(); + return { + session, + consoleLog$: pollForLogEntry$( + session, + logging.Type.BROWSER, + logPollingMs, + lifecycle.cleanup.after$ + ).pipe( + takeUntil(lifecycle.cleanup.after$), + map(({ message, level: { name: level } }) => ({ + message: message.replace(/\\n/g, '\n'), + level, + })) + ), + }; + } else { + throw new Error( + `Chromium Edge session requires browser or driver path to be defined: ${JSON.stringify( + edgePaths + )}` + ); + } + } + case 'firefox': { const firefoxOptions = new firefox.Options(); // Firefox 65+ supports logging console output to stdout diff --git a/test/plugin_functional/plugins/core_plugin_a/public/application.tsx b/test/plugin_functional/plugins/core_plugin_a/public/application.tsx index abea970749cbc..159bb54f50903 100644 --- a/test/plugin_functional/plugins/core_plugin_a/public/application.tsx +++ b/test/plugin_functional/plugins/core_plugin_a/public/application.tsx @@ -95,7 +95,7 @@ const Nav = withRouter(({ history, navigateToApp }: NavProps) => ( { id: 'home', name: 'Home', - onClick: () => history.push('/'), + onClick: () => history.push(''), 'data-test-subj': 'fooNavHome', }, { diff --git a/test/plugin_functional/plugins/core_plugin_b/public/application.tsx b/test/plugin_functional/plugins/core_plugin_b/public/application.tsx index 447307920c04c..01a63f9782563 100644 --- a/test/plugin_functional/plugins/core_plugin_b/public/application.tsx +++ b/test/plugin_functional/plugins/core_plugin_b/public/application.tsx @@ -102,7 +102,7 @@ const Nav = withRouter(({ history, navigateToApp }: NavProps) => ( { id: 'home', name: 'Home', - onClick: () => navigateToApp('bar', { path: '/' }), + onClick: () => navigateToApp('bar', { path: '' }), 'data-test-subj': 'barNavHome', }, { diff --git a/test/plugin_functional/plugins/kbn_sample_panel_action/public/sample_panel_link.ts b/test/plugin_functional/plugins/kbn_sample_panel_action/public/sample_panel_link.ts index b0f1219a815a3..faa774b8485b1 100644 --- a/test/plugin_functional/plugins/kbn_sample_panel_action/public/sample_panel_link.ts +++ b/test/plugin_functional/plugins/kbn_sample_panel_action/public/sample_panel_link.ts @@ -26,6 +26,8 @@ export const createSamplePanelLink = (): Action => createAction({ type: SAMPLE_PANEL_LINK, getDisplayName: () => 'Sample panel Link', - execute: async () => {}, - getHref: () => 'https://example.com/kibana/test', + execute: async () => { + window.location.href = 'https://example.com/kibana/test'; + }, + getHref: async () => 'https://example.com/kibana/test', }); diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_input.ts b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_input.ts index bb8951680be35..37ef8cad948cb 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_input.ts +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_input.ts @@ -47,7 +47,7 @@ export const dashboardInput: DashboardContainerInput = { explicitInput: { id: '2', firstName: 'Sue', - } as any, + }, }, '822cd0f0-ce7c-419d-aeaa-1171cf452745': { gridData: { @@ -60,8 +60,8 @@ export const dashboardInput: DashboardContainerInput = { type: 'visualization', explicitInput: { id: '822cd0f0-ce7c-419d-aeaa-1171cf452745', + savedObjectId: '3fe22200-3dcb-11e8-8660-4d65aa086b3c', }, - savedObjectId: '3fe22200-3dcb-11e8-8660-4d65aa086b3c', }, '66f0a265-7b06-4974-accd-d05f74f7aa82': { gridData: { @@ -74,8 +74,8 @@ export const dashboardInput: DashboardContainerInput = { type: 'visualization', explicitInput: { id: '66f0a265-7b06-4974-accd-d05f74f7aa82', + savedObjectId: '4c0f47e0-3dcd-11e8-8660-4d65aa086b3c', }, - savedObjectId: '4c0f47e0-3dcd-11e8-8660-4d65aa086b3c', }, 'b2861741-40b9-4dc8-b82b-080c6e29a551': { gridData: { @@ -88,8 +88,8 @@ export const dashboardInput: DashboardContainerInput = { type: 'search', explicitInput: { id: 'b2861741-40b9-4dc8-b82b-080c6e29a551', + savedObjectId: 'be5accf0-3dca-11e8-8660-4d65aa086b3c', }, - savedObjectId: 'be5accf0-3dca-11e8-8660-4d65aa086b3c', }, }, isFullScreenMode: false, diff --git a/test/plugin_functional/plugins/rendering_plugin/server/plugin.ts b/test/plugin_functional/plugins/rendering_plugin/server/plugin.ts index 3f6a8e8773e04..f0b1cde24c6fe 100644 --- a/test/plugin_functional/plugins/rendering_plugin/server/plugin.ts +++ b/test/plugin_functional/plugins/rendering_plugin/server/plugin.ts @@ -17,15 +17,13 @@ * under the License. */ -import { Plugin, CoreSetup, IRenderOptions } from 'kibana/server'; +import { Plugin, CoreSetup } from 'kibana/server'; import { schema } from '@kbn/config-schema'; export class RenderingPlugin implements Plugin { public setup(core: CoreSetup) { - const router = core.http.createRouter(); - - router.get( + core.http.resources.register( { path: '/render/{id}', validate: { @@ -41,18 +39,12 @@ export class RenderingPlugin implements Plugin { }, }, async (context, req, res) => { - const { id } = req.params; const { includeUserSettings } = req.query; - const app = { getId: () => id! }; - const options: Partial = { app, includeUserSettings }; - const body = await context.core.rendering.render(options); - return res.ok({ - body, - headers: { - 'content-security-policy': core.http.csp.header, - }, - }); + if (includeUserSettings) { + return res.renderCoreApp(); + } + return res.renderAnonymousCoreApp(); } ); } diff --git a/test/scripts/jenkins_ci_group.sh b/test/scripts/jenkins_ci_group.sh index 23807a6e98dc2..c77ffe40db553 100755 --- a/test/scripts/jenkins_ci_group.sh +++ b/test/scripts/jenkins_ci_group.sh @@ -22,7 +22,7 @@ else echo " -> running tests from the clone folder" #yarn run grunt "run:functionalTests_ciGroup${CI_GROUP}"; - node scripts/functional_tests --debug --include-tag "ciGroup$CI_GROUP" --config test/functional/config.coverage.js || true; + node scripts/functional_tests --debug --include-tag "ciGroup$CI_GROUP" --exclude-tag "skipCoverage" || true; if [[ -d target/kibana-coverage/functional ]]; then echo " -> replacing kibana${CI_GROUP} with kibana in json files" diff --git a/test/scripts/jenkins_xpack.sh b/test/scripts/jenkins_xpack.sh index 5055997df642a..7070fb1063cb5 100755 --- a/test/scripts/jenkins_xpack.sh +++ b/test/scripts/jenkins_xpack.sh @@ -39,7 +39,7 @@ else # build runtime for canvas echo "NODE_ENV=$NODE_ENV" node ./legacy/plugins/canvas/scripts/shareable_runtime - node scripts/jest --ci --verbose --coverage + node --max-old-space-size=6144 scripts/jest --ci --verbose --coverage # rename file in order to be unique one test -f ../target/kibana-coverage/jest/coverage-final.json \ && mv ../target/kibana-coverage/jest/coverage-final.json \ diff --git a/test/scripts/jenkins_xpack_ci_group.sh b/test/scripts/jenkins_xpack_ci_group.sh index 01b13293c10ba..a6e600630364e 100755 --- a/test/scripts/jenkins_xpack_ci_group.sh +++ b/test/scripts/jenkins_xpack_ci_group.sh @@ -23,7 +23,7 @@ else cd "kibana${CI_GROUP}/x-pack" echo " -> running tests from the clone folder" - node scripts/functional_tests --debug --include-tag "ciGroup$CI_GROUP" --config test/functional/config.coverage.js || true; + node scripts/functional_tests --debug --include-tag "ciGroup$CI_GROUP" --exclude-tag "skipCoverage" || true; if [[ -d ../target/kibana-coverage/functional ]]; then echo " -> replacing kibana${CI_GROUP} with kibana in json files" diff --git a/test/typings/rison_node.d.ts b/test/typings/rison_node.d.ts index 2592c36e8ae9a..a0497f421c3fe 100644 --- a/test/typings/rison_node.d.ts +++ b/test/typings/rison_node.d.ts @@ -18,7 +18,7 @@ */ declare module 'rison-node' { - export type RisonValue = null | boolean | number | string | RisonObject | RisonArray; + export type RisonValue = undefined | null | boolean | number | string | RisonObject | RisonArray; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface RisonArray extends Array {} diff --git a/typings/rison_node.d.ts b/typings/rison_node.d.ts index 2592c36e8ae9a..a0497f421c3fe 100644 --- a/typings/rison_node.d.ts +++ b/typings/rison_node.d.ts @@ -18,7 +18,7 @@ */ declare module 'rison-node' { - export type RisonValue = null | boolean | number | string | RisonObject | RisonArray; + export type RisonValue = undefined | null | boolean | number | string | RisonObject | RisonArray; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface RisonArray extends Array {} diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index ae8d61769b14c..50f36ddd21c97 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -18,13 +18,13 @@ "xpack.graph": ["legacy/plugins/graph", "plugins/graph"], "xpack.grokDebugger": "plugins/grokdebugger", "xpack.idxMgmt": "plugins/index_management", - "xpack.indexLifecycleMgmt": "legacy/plugins/index_lifecycle_management", + "xpack.indexLifecycleMgmt": "plugins/index_lifecycle_management", "xpack.infra": "plugins/infra", "xpack.ingestManager": "plugins/ingest_manager", - "xpack.lens": "legacy/plugins/lens", + "xpack.lens": "plugins/lens", "xpack.licenseMgmt": "plugins/license_management", "xpack.licensing": "plugins/licensing", - "xpack.logstash": "legacy/plugins/logstash", + "xpack.logstash": ["plugins/logstash", "legacy/plugins/logstash"], "xpack.main": "legacy/plugins/xpack_main", "xpack.maps": ["plugins/maps", "legacy/plugins/maps"], "xpack.ml": ["plugins/ml", "legacy/plugins/ml"], diff --git a/x-pack/dev-tools/jest/create_jest_config.js b/x-pack/dev-tools/jest/create_jest_config.js index 1f4311ae9bcf6..3068cdd0daa5b 100644 --- a/x-pack/dev-tools/jest/create_jest_config.js +++ b/x-pack/dev-tools/jest/create_jest_config.js @@ -28,6 +28,7 @@ export function createJestConfig({ kibanaDirectory, xPackKibanaDirectory }) { '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': fileMockPath, '\\.module.(css|scss)$': `${kibanaDirectory}/src/dev/jest/mocks/css_module_mock.js`, '\\.(css|less|scss)$': `${kibanaDirectory}/src/dev/jest/mocks/style_mock.js`, + '\\.ace\\.worker.js$': `${kibanaDirectory}/src/dev/jest/mocks/ace_worker_module_mock.js`, '^test_utils/enzyme_helpers': `${xPackKibanaDirectory}/test_utils/enzyme_helpers.tsx`, '^test_utils/find_test_subject': `${xPackKibanaDirectory}/test_utils/find_test_subject.ts`, '^test_utils/stub_web_worker': `${xPackKibanaDirectory}/test_utils/stub_web_worker.ts`, diff --git a/x-pack/index.js b/x-pack/index.js index 6fab13d726fa6..61fd4f1752316 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -16,7 +16,6 @@ import { beats } from './legacy/plugins/beats_management'; import { apm } from './legacy/plugins/apm'; import { maps } from './legacy/plugins/maps'; import { indexManagement } from './legacy/plugins/index_management'; -import { indexLifecycleManagement } from './legacy/plugins/index_lifecycle_management'; import { spaces } from './legacy/plugins/spaces'; import { canvas } from './legacy/plugins/canvas'; import { infra } from './legacy/plugins/infra'; @@ -30,7 +29,6 @@ import { uptime } from './legacy/plugins/uptime'; import { encryptedSavedObjects } from './legacy/plugins/encrypted_saved_objects'; import { actions } from './legacy/plugins/actions'; import { alerting } from './legacy/plugins/alerting'; -import { lens } from './legacy/plugins/lens'; import { ingestManager } from './legacy/plugins/ingest_manager'; import { triggersActionsUI } from './legacy/plugins/triggers_actions_ui'; @@ -50,7 +48,6 @@ module.exports = function(kibana) { maps(kibana), canvas(kibana), indexManagement(kibana), - indexLifecycleManagement(kibana), infra(kibana), taskManager(kibana), rollup(kibana), @@ -60,7 +57,6 @@ module.exports = function(kibana) { upgradeAssistant(kibana), uptime(kibana), encryptedSavedObjects(kibana), - lens(kibana), actions(kibana), alerting(kibana), ingestManager(kibana), diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx index a1462c7637358..cc5c62e25b491 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx @@ -57,7 +57,8 @@ export class MachineLearningFlyout extends Component { }; public addErrorToast = () => { - const core = this.context; + const { core } = this.context; + const { urlParams } = this.props; const { serviceName } = urlParams; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx index a8d3b843a1f3d..31c227d8bbcab 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx @@ -85,6 +85,7 @@ storiesOf('app/ServiceMap/Cytoscape', module) } }, { data: { id: 'external', 'span.type': 'external' } }, + { data: { id: 'ext', 'span.type': 'ext' } }, { data: { id: 'messaging', 'span.type': 'messaging' } }, { data: { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index e4b656ae8160d..53c86f92ee557 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -211,7 +211,9 @@ export function Cytoscape({ resetConnectedEdgeStyle(event.target); }; const unselectHandler: cytoscape.EventHandler = event => { - resetConnectedEdgeStyle(); + resetConnectedEdgeStyle( + serviceName ? event.cy.getElementById(serviceName) : undefined + ); }; const debugHandler: cytoscape.EventHandler = event => { const debugEnabled = sessionStorage.getItem('apm_debug') === 'true'; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx index 52263878ca915..491ebdc5aad15 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx @@ -27,6 +27,30 @@ interface ContentsProps { selectedNodeServiceName: string; } +// IE 11 does not handle flex properties as expected. With browser detection, +// we can use regular div elements to render contents that are almost identical. +// +// This method of detecting IE is from a Stack Overflow answer: +// https://stackoverflow.com/a/21825207 +// +// @ts-ignore `documentMode` is not recognized as a valid property of `document`. +const isIE11 = !!window.MSInputMethodContext && !!document.documentMode; + +const FlexColumnGroup = (props: { + children: React.ReactNode; + style: React.CSSProperties; + direction: 'column'; + gutterSize: 's'; +}) => { + if (isIE11) { + const { direction, gutterSize, ...rest } = props; + return
; + } + return ; +}; +const FlexColumnItem = (props: { children: React.ReactNode }) => + isIE11 ?
: ; + export function Contents({ selectedNodeData, isService, @@ -36,18 +60,18 @@ export function Contents({ }: ContentsProps) { const frameworkName = selectedNodeData[SERVICE_FRAMEWORK_NAME]; return ( - - +

{label}

-
- + + {isService ? ( )} - + {isService && ( )} -
+ ); } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index 92f66f698f044..bf0e052b951ae 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -12,6 +12,17 @@ import { } from '../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; import { defaultIcon, iconForNode } from './icons'; +// IE 11 does not properly load some SVGs or draw certain shapes. This causes +// a runtime error and the map fails work at all. We would prefer to do some +// kind of feature detection rather than browser detection, but some of these +// limitations are not well documented for older browsers. +// +// This method of detecting IE is from a Stack Overflow answer: +// https://stackoverflow.com/a/21825207 +// +// @ts-ignore `documentMode` is not recognized as a valid property of `document`. +const isIE11 = !!window.MSInputMethodContext && !!document.documentMode; + export const animationOptions: cytoscape.AnimationOptions = { duration: parseInt(theme.euiAnimSpeedNormal, 10), // @ts-ignore The cubic-bezier options here are not recognized by the cytoscape types @@ -37,8 +48,9 @@ const style: cytoscape.Stylesheet[] = [ // used here. // // @ts-ignore - 'background-image': (el: cytoscape.NodeSingular) => - iconForNode(el) ?? defaultIcon, + 'background-image': isIE11 + ? undefined + : (el: cytoscape.NodeSingular) => iconForNode(el) ?? defaultIcon, 'background-height': (el: cytoscape.NodeSingular) => isService(el) ? '60%' : '40%', 'background-width': (el: cytoscape.NodeSingular) => @@ -65,7 +77,7 @@ const style: cytoscape.Stylesheet[] = [ 'min-zoomed-font-size': parseInt(theme.euiSizeL, 10), 'overlay-opacity': 0, shape: (el: cytoscape.NodeSingular) => - isService(el) ? 'ellipse' : 'diamond', + isService(el) ? (isIE11 ? 'rectangle' : 'ellipse') : 'diamond', 'text-background-color': theme.euiColorLightestShade, 'text-background-opacity': 0, 'text-background-padding': theme.paddingSizes.xs, @@ -87,12 +99,12 @@ const style: cytoscape.Stylesheet[] = [ 'line-color': lineColor, 'overlay-opacity': 0, 'target-arrow-color': lineColor, - 'target-arrow-shape': 'triangle', + 'target-arrow-shape': isIE11 ? 'none' : 'triangle', // The DefinitelyTyped definitions don't specify this property since it's // fairly new. // // @ts-ignore - 'target-distance-from-node': theme.paddingSizes.xs, + 'target-distance-from-node': isIE11 ? undefined : theme.paddingSizes.xs, width: 1, 'source-arrow-shape': 'none', 'z-index': zIndexEdge @@ -101,12 +113,16 @@ const style: cytoscape.Stylesheet[] = [ { selector: 'edge[bidirectional]', style: { - 'source-arrow-shape': 'triangle', + 'source-arrow-shape': isIE11 ? 'none' : 'triangle', 'source-arrow-color': lineColor, - 'target-arrow-shape': 'triangle', + 'target-arrow-shape': isIE11 ? 'none' : 'triangle', // @ts-ignore - 'source-distance-from-node': parseInt(theme.paddingSizes.xs, 10), - 'target-distance-from-node': parseInt(theme.paddingSizes.xs, 10) + 'source-distance-from-node': isIE11 + ? undefined + : parseInt(theme.paddingSizes.xs, 10), + 'target-distance-from-node': isIE11 + ? undefined + : parseInt(theme.paddingSizes.xs, 10) } }, // @ts-ignore DefinitelyTyped says visibility is "none" but it's diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts index dd9b48d312725..095c2d9250e27 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts @@ -32,6 +32,7 @@ export const defaultIcon = defaultIconImport; const icons: { [key: string]: string } = { cache: databaseIcon, db: databaseIcon, + ext: globeIcon, external: globeIcon, messaging: documentsIcon, resource: globeIcon diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx index 30c772bf5f634..baab600145b81 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx @@ -73,7 +73,10 @@ function FormRow({ return ( onChange(setting.key, e.target.value)} /> @@ -105,7 +108,7 @@ function FormRow({ defaultMessage: 'Select unit' })} value={unit} - options={setting.units?.map(text => ({ text }))} + options={setting.units?.map(text => ({ text, value: text }))} onChange={e => onChange( setting.key, diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx index 2b3a5cbe87992..87cb171518ea4 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx @@ -8,6 +8,7 @@ import { EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { NotificationsStart } from 'kibana/public'; import React, { useState } from 'react'; +import { px, unit } from '../../../../../../style/variables'; import { callApmApi } from '../../../../../../services/rest/createCallApmApi'; import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext'; @@ -31,6 +32,7 @@ export function DeleteButton({ onDelete, customLinkId }: Props) { setIsDeleting(false); onDelete(); }} + style={{ marginRight: px(unit) }} > {i18n.translate('xpack.apm.settings.customizeUI.customLink.delete', { defaultMessage: 'Delete' diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx index cb27221309812..96505d639bcdd 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx @@ -40,29 +40,23 @@ export const FlyoutFooter = ({ )}
- - - {customLinkId && ( - - - + + {customLinkId && ( + + )} + + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.save', + { + defaultMessage: 'Save' + } )} - - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.flyout.save', - { - defaultMessage: 'Save' - } - )} - - - + diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx index a20bc7e21cfc5..3aed1b7ac2953 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx @@ -19,6 +19,7 @@ import { ManageCustomLink } from './ManageCustomLink'; import { px } from '../../../../style/variables'; const ScrollableContainer = styled.div` + -ms-overflow-style: none; max-height: ${px(535)}; overflow: scroll; `; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx index e3fbcf8485d54..7ebfe26b83630 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx @@ -124,7 +124,7 @@ export const TransactionActionMenu: FunctionComponent = ({ setIsActionPopoverOpen(true)} /> } > -
+
{isCustomLinksPopoverOpen ? ( (http, { method: 'POST', pathname: `/api/ml/modules/setup/apm_transaction`, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts index 4b045b0c5edcf..cba19ce7da80f 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts @@ -5,8 +5,6 @@ */ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; -import { TimeRange, Filter as DataFilter } from 'src/plugins/data/public'; -import { EmbeddableInput } from 'src/plugins/embeddable/public'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; import { Filter, MapCenter, TimeRange as TimeRangeArg } from '../../../types'; import { @@ -15,6 +13,7 @@ import { EmbeddableExpression, } from '../../expression_types'; import { getFunctionHelp } from '../../../i18n'; +import { MapEmbeddableInput } from '../../../../../plugins/maps/public'; interface Arguments { id: string; @@ -24,32 +23,12 @@ interface Arguments { timerange: TimeRangeArg | null; } -// Map embeddable is missing proper typings, so type is just to document what we -// are expecting to pass to the embeddable -export type SavedMapInput = EmbeddableInput & { - id: string; - isLayerTOCOpen: boolean; - timeRange?: TimeRange; - refreshConfig: { - isPaused: boolean; - interval: number; - }; - hideFilterActions: true; - filters: DataFilter[]; - mapCenter?: { - lat: number; - lon: number; - zoom: number; - }; - hiddenLayers?: string[]; -}; - const defaultTimeRange = { from: 'now-15m', to: 'now', }; -type Output = EmbeddableExpression; +type Output = EmbeddableExpression; export function savedMap(): ExpressionFunctionDefinition< 'savedMap', @@ -108,8 +87,8 @@ export function savedMap(): ExpressionFunctionDefinition< filters: getQueryFilters(filters), timeRange: args.timerange || defaultTimeRange, refreshConfig: { - isPaused: false, - interval: 0, + pause: false, + value: 0, }, mapCenter: center, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/plugin.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/plugin.ts index 7cd1efe9e27c8..a654c6b28b350 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/plugin.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/plugin.ts @@ -6,11 +6,14 @@ import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { CanvasSetup } from '../public'; +import { EmbeddableStart } from '../../../../../src/plugins/embeddable/public'; +import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; +import { Start as InspectorStart } from '../../../../../src/plugins/inspector/public'; import { functions } from './functions/browser'; import { typeFunctions } from './expression_types'; // @ts-ignore: untyped local -import { renderFunctions } from './renderers'; +import { renderFunctions, renderFunctionFactories } from './renderers'; import { elementSpecs } from './elements'; // @ts-ignore Untyped Local @@ -30,13 +33,26 @@ interface SetupDeps { canvas: CanvasSetup; } +export interface StartDeps { + embeddable: EmbeddableStart; + uiActions: UiActionsStart; + inspector: InspectorStart; +} + /** @internal */ -export class CanvasSrcPlugin implements Plugin<{}, {}, SetupDeps, {}> { - public setup(core: CoreSetup, plugins: SetupDeps) { +export class CanvasSrcPlugin implements Plugin { + public setup(core: CoreSetup, plugins: SetupDeps) { plugins.canvas.addFunctions(functions); plugins.canvas.addTypes(typeFunctions); + plugins.canvas.addRenderers(renderFunctions); + core.getStartServices().then(([coreStart, depsStart]) => { + plugins.canvas.addRenderers( + renderFunctionFactories.map((factory: any) => factory(coreStart, depsStart)) + ); + }); + plugins.canvas.addElements(elementSpecs); plugins.canvas.addDatasourceUIs(datasourceSpecs); plugins.canvas.addModelUIs(modelSpecs); @@ -45,11 +61,7 @@ export class CanvasSrcPlugin implements Plugin<{}, {}, SetupDeps, {}> { plugins.canvas.addTagUIs(tagSpecs); plugins.canvas.addTemplates(templateSpecs); plugins.canvas.addTransformUIs(transformSpecs); - - return {}; } - public start(core: CoreStart, plugins: {}) { - return {}; - } + public start(core: CoreStart, plugins: StartDeps) {} } diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index 817be6e144fc8..a1096d50c1653 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -7,7 +7,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { I18nContext } from 'ui/i18n'; -import { npStart } from 'ui/new_platform'; +import { CoreStart } from '../../../../../../../src/core/public'; +import { StartDeps } from '../../plugin'; import { IEmbeddable, EmbeddableFactory, @@ -28,86 +29,88 @@ const embeddablesRegistry: { [key: string]: IEmbeddable; } = {}; -const renderEmbeddable = (embeddableObject: IEmbeddable, domNode: HTMLElement) => { - return ( -
- - - -
- ); +const renderEmbeddableFactory = (core: CoreStart, plugins: StartDeps) => { + return (embeddableObject: IEmbeddable, domNode: HTMLElement) => { + return ( +
+ + + +
+ ); + }; }; -const embeddable = () => ({ - name: 'embeddable', - displayName: strings.getDisplayName(), - help: strings.getHelpDescription(), - reuseDomNode: true, - render: async ( - domNode: HTMLElement, - { input, embeddableType }: EmbeddableExpression, - handlers: RendererHandlers - ) => { - const uniqueId = handlers.getElementId(); - - if (!embeddablesRegistry[uniqueId]) { - const factory = Array.from(npStart.plugins.embeddable.getEmbeddableFactories()).find( - embeddableFactory => embeddableFactory.type === embeddableType - ) as EmbeddableFactory; - - if (!factory) { - handlers.done(); - throw new EmbeddableFactoryNotFoundError(embeddableType); - } - - const embeddableObject = await factory.createFromSavedObject(input.id, input); +export const embeddableRendererFactory = (core: CoreStart, plugins: StartDeps) => { + const renderEmbeddable = renderEmbeddableFactory(core, plugins); + return () => ({ + name: 'embeddable', + displayName: strings.getDisplayName(), + help: strings.getHelpDescription(), + reuseDomNode: true, + render: async ( + domNode: HTMLElement, + { input, embeddableType }: EmbeddableExpression, + handlers: RendererHandlers + ) => { + const uniqueId = handlers.getElementId(); + + if (!embeddablesRegistry[uniqueId]) { + const factory = Array.from(plugins.embeddable.getEmbeddableFactories()).find( + embeddableFactory => embeddableFactory.type === embeddableType + ) as EmbeddableFactory; + + if (!factory) { + handlers.done(); + throw new EmbeddableFactoryNotFoundError(embeddableType); + } - embeddablesRegistry[uniqueId] = embeddableObject; - ReactDOM.unmountComponentAtNode(domNode); + const embeddableObject = await factory.createFromSavedObject(input.id, input); - const subscription = embeddableObject.getInput$().subscribe(function(updatedInput) { - const updatedExpression = embeddableInputToExpression(updatedInput, embeddableType); + embeddablesRegistry[uniqueId] = embeddableObject; + ReactDOM.unmountComponentAtNode(domNode); - if (updatedExpression) { - handlers.onEmbeddableInputChange(updatedExpression); - } - }); + const subscription = embeddableObject.getInput$().subscribe(function(updatedInput) { + const updatedExpression = embeddableInputToExpression(updatedInput, embeddableType); - ReactDOM.render(renderEmbeddable(embeddableObject, domNode), domNode, () => handlers.done()); + if (updatedExpression) { + handlers.onEmbeddableInputChange(updatedExpression); + } + }); - handlers.onResize(() => { ReactDOM.render(renderEmbeddable(embeddableObject, domNode), domNode, () => handlers.done() ); - }); - handlers.onDestroy(() => { - subscription.unsubscribe(); - handlers.onEmbeddableDestroyed(); + handlers.onResize(() => { + ReactDOM.render(renderEmbeddable(embeddableObject, domNode), domNode, () => + handlers.done() + ); + }); - delete embeddablesRegistry[uniqueId]; + handlers.onDestroy(() => { + subscription.unsubscribe(); + handlers.onEmbeddableDestroyed(); - return ReactDOM.unmountComponentAtNode(domNode); - }); - } else { - embeddablesRegistry[uniqueId].updateInput(input); - } - }, -}); + delete embeddablesRegistry[uniqueId]; -export { embeddable }; + return ReactDOM.unmountComponentAtNode(domNode); + }); + } else { + embeddablesRegistry[uniqueId].updateInput(input); + } + }, + }); +}; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.test.ts index 4c294fb37c2db..f9ff94ee7d8f1 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.test.ts @@ -5,7 +5,7 @@ */ import { toExpression } from './map'; -import { SavedMapInput } from '../../../functions/common/saved_map'; +import { MapEmbeddableInput } from '../../../../../maps/public'; import { fromExpression, Ast } from '@kbn/interpreter/common'; const baseSavedMapInput = { @@ -13,15 +13,15 @@ const baseSavedMapInput = { filters: [], isLayerTOCOpen: false, refreshConfig: { - isPaused: true, - interval: 0, + pause: true, + value: 0, }, hideFilterActions: true as true, }; describe('toExpression', () => { it('converts to a savedMap expression', () => { - const input: SavedMapInput = { + const input: MapEmbeddableInput = { ...baseSavedMapInput, }; @@ -39,7 +39,7 @@ describe('toExpression', () => { }); it('includes optional input values', () => { - const input: SavedMapInput = { + const input: MapEmbeddableInput = { ...baseSavedMapInput, mapCenter: { lat: 1, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.ts index e3f9eca61ae28..e0cb71c17774c 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedMapInput } from '../../../functions/common/saved_map'; +import { MapEmbeddableInput } from '../../../../../maps/public'; -export function toExpression(input: SavedMapInput): string { +export function toExpression(input: MapEmbeddableInput): string { const expressionParts = [] as string[]; expressionParts.push('savedMap'); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/index.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/index.js index 48364be06e539..84f92f5149893 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/index.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/index.js @@ -7,7 +7,7 @@ import { advancedFilter } from './advanced_filter'; import { debug } from './debug'; import { dropdownFilter } from './dropdown_filter'; -import { embeddable } from './embeddable/embeddable'; +import { embeddableRendererFactory } from './embeddable/embeddable'; import { error } from './error'; import { image } from './image'; import { markdown } from './markdown'; @@ -26,7 +26,6 @@ export const renderFunctions = [ advancedFilter, debug, dropdownFilter, - embeddable, error, image, markdown, @@ -41,3 +40,5 @@ export const renderFunctions = [ text, timeFilter, ]; + +export const renderFunctionFactories = [embeddableRendererFactory]; diff --git a/x-pack/legacy/plugins/canvas/index.js b/x-pack/legacy/plugins/canvas/index.js index 489b9600f200e..a1d4b35826b00 100644 --- a/x-pack/legacy/plugins/canvas/index.js +++ b/x-pack/legacy/plugins/canvas/index.js @@ -7,9 +7,7 @@ import { resolve } from 'path'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; import { init } from './init'; -import { mappings } from './server/mappings'; import { CANVAS_APP, CANVAS_TYPE, CUSTOM_ELEMENT_TYPE } from './common/lib'; -import { migrations } from './migrations'; export function canvas(kibana) { return new kibana.Plugin({ @@ -33,8 +31,6 @@ export function canvas(kibana) { 'plugins/canvas/lib/window_error_handler.js', ], home: ['plugins/canvas/legacy_register_feature'], - mappings, - migrations, savedObjectsManagement: { [CANVAS_TYPE]: { icon: 'canvasApp', diff --git a/x-pack/legacy/plugins/canvas/migrations.js b/x-pack/legacy/plugins/canvas/migrations.js deleted file mode 100644 index d5b3d3fb1ce2a..0000000000000 --- a/x-pack/legacy/plugins/canvas/migrations.js +++ /dev/null @@ -1,18 +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 { CANVAS_TYPE } from './common/lib'; - -export const migrations = { - [CANVAS_TYPE]: { - '7.0.0': doc => { - if (doc.attributes) { - delete doc.attributes.id; - } - return doc; - }, - }, -}; diff --git a/x-pack/legacy/plugins/canvas/migrations.test.js b/x-pack/legacy/plugins/canvas/migrations.test.js deleted file mode 100644 index 182ef3b18cce7..0000000000000 --- a/x-pack/legacy/plugins/canvas/migrations.test.js +++ /dev/null @@ -1,37 +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 { migrations } from './migrations'; -import { CANVAS_TYPE } from './common/lib'; - -describe(`${CANVAS_TYPE}`, () => { - describe('7.0.0', () => { - const migrate = doc => migrations[CANVAS_TYPE]['7.0.0'](doc); - - it('does not throw error on empty object', () => { - const migratedDoc = migrate({}); - expect(migratedDoc).toMatchInlineSnapshot(`Object {}`); - }); - - it('removes id from "attributes"', () => { - const migratedDoc = migrate({ - foo: true, - attributes: { - id: '123', - bar: true, - }, - }); - expect(migratedDoc).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "bar": true, - }, - "foo": true, -} -`); - }); - }); -}); diff --git a/x-pack/legacy/plugins/canvas/public/application.tsx b/x-pack/legacy/plugins/canvas/public/application.tsx index 79b3918fef99b..f75b3b427c41b 100644 --- a/x-pack/legacy/plugins/canvas/public/application.tsx +++ b/x-pack/legacy/plugins/canvas/public/application.tsx @@ -104,8 +104,9 @@ export const initializeCanvas = async ( href: getDocumentationLinks().canvas, }, ], - content: domNode => () => { + content: domNode => { ReactDOM.render(, domNode); + return () => ReactDOM.unmountComponentAtNode(domNode); }, }); diff --git a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/flyout.tsx b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/flyout.tsx index 08cd3084c35cf..4916a27fcbe60 100644 --- a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/flyout.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/flyout.tsx @@ -5,7 +5,6 @@ */ import React from 'react'; -import { npStart } from 'ui/new_platform'; import { EuiFlyout, EuiFlyoutHeader, EuiFlyoutBody, EuiTitle } from '@elastic/eui'; import { SavedObjectFinderUi, @@ -13,6 +12,7 @@ import { } from '../../../../../../../src/plugins/saved_objects/public/'; import { ComponentStrings } from '../../../i18n'; import { CoreStart } from '../../../../../../../src/core/public'; +import { CanvasStartDeps } from '../../plugin'; const { AddEmbeddableFlyout: strings } = ComponentStrings; @@ -22,11 +22,12 @@ export interface Props { availableEmbeddables: string[]; savedObjects: CoreStart['savedObjects']; uiSettings: CoreStart['uiSettings']; + getEmbeddableFactories: CanvasStartDeps['embeddable']['getEmbeddableFactories']; } export class AddEmbeddableFlyout extends React.Component { onAddPanel = (id: string, savedObjectType: string, name: string) => { - const embeddableFactories = npStart.plugins.embeddable.getEmbeddableFactories(); + const embeddableFactories = this.props.getEmbeddableFactories(); // Find the embeddable type from the saved object type const found = Array.from(embeddableFactories).find(embeddableFactory => { @@ -42,7 +43,7 @@ export class AddEmbeddableFlyout extends React.Component { }; render() { - const embeddableFactories = npStart.plugins.embeddable.getEmbeddableFactories(); + const embeddableFactories = this.props.getEmbeddableFactories(); const availableSavedObjects = Array.from(embeddableFactories) .filter(factory => { diff --git a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx index a86784d374f49..c13cbfd042237 100644 --- a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx @@ -105,6 +105,7 @@ export class EmbeddableFlyoutPortal extends React.Component, this.el ); diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_loader/workpad_loader.js b/x-pack/legacy/plugins/canvas/public/components/workpad_loader/workpad_loader.js index 9b30b3e1ec7ca..30d4ded8571c5 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_loader/workpad_loader.js +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_loader/workpad_loader.js @@ -266,11 +266,17 @@ export class WorkpadLoader extends React.PureComponent { data-test-subj="canvasWorkpadLoaderTable" /> - - - - - + {rows.length > 0 && ( + + + + + + )} ); diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_templates/workpad_templates.js b/x-pack/legacy/plugins/canvas/public/components/workpad_templates/workpad_templates.js index c80db544bf370..a9a157f5675f8 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_templates/workpad_templates.js +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_templates/workpad_templates.js @@ -113,11 +113,13 @@ export class WorkpadTemplates extends React.PureComponent { className="canvasWorkpad__dropzoneTable canvasWorkpad__dropzoneTable--tags" /> - - - - - + {rows.length > 0 && ( + + + + + + )} ); }; diff --git a/x-pack/legacy/plugins/canvas/public/legacy.ts b/x-pack/legacy/plugins/canvas/public/legacy.ts index a6caa1985325e..4af7c9b2bd057 100644 --- a/x-pack/legacy/plugins/canvas/public/legacy.ts +++ b/x-pack/legacy/plugins/canvas/public/legacy.ts @@ -26,7 +26,9 @@ const shimSetupPlugins: CanvasSetupDeps = { }; const shimStartPlugins: CanvasStartDeps = { ...npStart.plugins, + embeddable: npStart.plugins.embeddable, expressions: npStart.plugins.expressions, + inspector: npStart.plugins.inspector, uiActions: npStart.plugins.uiActions, __LEGACY: { // ToDo: Copy directly into canvas diff --git a/x-pack/legacy/plugins/canvas/public/plugin.tsx b/x-pack/legacy/plugins/canvas/public/plugin.tsx index d9e5e6b4b084b..3ea3ce625ca71 100644 --- a/x-pack/legacy/plugins/canvas/public/plugin.tsx +++ b/x-pack/legacy/plugins/canvas/public/plugin.tsx @@ -11,6 +11,8 @@ import { initLoadingIndicator } from './lib/loading_indicator'; import { featureCatalogueEntry } from './feature_catalogue_entry'; import { ExpressionsSetup, ExpressionsStart } from '../../../../../src/plugins/expressions/public'; import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; +import { EmbeddableStart } from '../../../../../src/plugins/embeddable/public'; +import { Start as InspectorStart } from '../../../../../src/plugins/inspector/public'; // @ts-ignore untyped local import { argTypeSpecs } from './expression_types/arg_types'; import { transitions } from './transitions'; @@ -31,7 +33,9 @@ export interface CanvasSetupDeps { } export interface CanvasStartDeps { + embeddable: EmbeddableStart; expressions: ExpressionsStart; + inspector: InspectorStart; uiActions: UiActionsStart; __LEGACY: { absoluteToParsedUrl: (url: string, basePath: string) => any; @@ -48,14 +52,19 @@ export interface CanvasStartDeps { // These interfaces are empty for now but will be populate as we need to export // things for other plugins to use at startup or runtime export type CanvasSetup = CanvasApi; -export interface CanvasStart {} // eslint-disable-line @typescript-eslint/no-empty-interface +export type CanvasStart = void; /** @internal */ export class CanvasPlugin implements Plugin { + // TODO: Do we want to completely move canvas_plugin_src into it's own plugin? + private srcPlugin = new CanvasSrcPlugin(); + public setup(core: CoreSetup, plugins: CanvasSetupDeps) { const { api: canvasApi, registries } = getPluginApi(plugins.expressions); + this.srcPlugin.setup(core, { canvas: canvasApi }); + core.application.register({ id: 'canvas', title: 'Canvas App', @@ -84,10 +93,6 @@ export class CanvasPlugin canvasApi.addElements(legacyRegistries.elements.getOriginalFns()); canvasApi.addTypes(legacyRegistries.types.getOriginalFns()); - // TODO: Do we want to completely move canvas_plugin_src into it's own plugin? - const srcPlugin = new CanvasSrcPlugin(); - srcPlugin.setup(core, { canvas: canvasApi }); - // Register core canvas stuff canvasApi.addFunctions(initFunctions({ typesRegistry: plugins.expressions.__LEGACY.types })); canvasApi.addArgumentUIs(argTypeSpecs); @@ -99,8 +104,7 @@ export class CanvasPlugin } public start(core: CoreStart, plugins: CanvasStartDeps) { + this.srcPlugin.start(core, plugins); initLoadingIndicator(core.http.addLoadingCountSource); - - return {}; } } diff --git a/x-pack/legacy/plugins/canvas/scripts/jest.js b/x-pack/legacy/plugins/canvas/scripts/jest.js index cce1b8d355846..133f775c7192f 100644 --- a/x-pack/legacy/plugins/canvas/scripts/jest.js +++ b/x-pack/legacy/plugins/canvas/scripts/jest.js @@ -36,6 +36,14 @@ run( `!${path}/**/__tests__/**/*`, '--collectCoverageFrom', // Ignore coverage on example files `!${path}/**/__examples__/**/*`, + '--collectCoverageFrom', // Ignore flot files + `!${path}/**/flot-charts/**`, + '--collectCoverageFrom', // Ignore coverage files + `!${path}/**/coverage/**`, + '--collectCoverageFrom', // Ignore scripts + `!${path}/**/scripts/**`, + '--collectCoverageFrom', // Ignore mock files + `!${path}/**/mocks/**`, '--collectCoverageFrom', // Include JS files `${path}/**/*.js`, '--collectCoverageFrom', // Include TS/X files @@ -76,7 +84,7 @@ run( --all Runs all tests and snapshots. Slower. --storybook Runs Storybook Snapshot tests only. --update Updates Storybook Snapshot tests. - --path Runs any tests at a given path. + --path Runs any tests at a given path. --coverage Collect coverage statistics. `, }, diff --git a/x-pack/legacy/plugins/canvas/server/mappings.ts b/x-pack/legacy/plugins/canvas/server/mappings.ts deleted file mode 100644 index bf2be51882b1a..0000000000000 --- a/x-pack/legacy/plugins/canvas/server/mappings.ts +++ /dev/null @@ -1,44 +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. - */ - -// @ts-ignore converting /libs/constants to TS breaks CI -import { CANVAS_TYPE, CUSTOM_ELEMENT_TYPE } from '../common/lib/constants'; - -export const mappings = { - [CANVAS_TYPE]: { - dynamic: false, - properties: { - name: { - type: 'text', - fields: { - keyword: { - type: 'keyword', - }, - }, - }, - '@timestamp': { type: 'date' }, - '@created': { type: 'date' }, - }, - }, - [CUSTOM_ELEMENT_TYPE]: { - dynamic: false, - properties: { - name: { - type: 'text', - fields: { - keyword: { - type: 'keyword', - }, - }, - }, - help: { type: 'text' }, - content: { type: 'text' }, - image: { type: 'text' }, - '@timestamp': { type: 'date' }, - '@created': { type: 'date' }, - }, - }, -}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/extend_index_management.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/extend_index_management.ts index 01c6250383fb8..4ffe0db4e3c4e 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/extend_index_management.ts +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/extend_index_management.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { get } from 'lodash'; -import { IndexMgmtSetup } from '../../../../../plugins/index_management/public'; +import { IndexManagementPluginSetup } from '../../../../../plugins/index_management/public'; const propertyPath = 'isFollowerIndex'; @@ -21,7 +21,7 @@ const followerBadgeExtension = { filterExpression: 'isFollowerIndex:true', }; -export const extendIndexManagement = (indexManagement?: IndexMgmtSetup) => { +export const extendIndexManagement = (indexManagement?: IndexManagementPluginSetup) => { if (indexManagement) { indexManagement.extensionsService.addBadge(followerBadgeExtension); } diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/plugin.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/plugin.ts index f7651cbb210a7..46259c698b282 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/plugin.ts +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/plugin.ts @@ -11,7 +11,7 @@ import { DocLinksStart, } from 'src/core/public'; -import { IndexMgmtSetup } from '../../../../../plugins/index_management/public'; +import { IndexManagementPluginSetup } from '../../../../../plugins/index_management/public'; // @ts-ignore; import { setHttpClient } from './app/services/api'; @@ -21,7 +21,7 @@ import { setNotifications } from './app/services/notifications'; import { extendIndexManagement } from './extend_index_management'; interface PluginDependencies { - indexManagement: IndexMgmtSetup; + indexManagement: IndexManagementPluginSetup; __LEGACY: { chrome: any; MANAGEMENT_BREADCRUMB: ChromeBreadcrumb; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.ts index 1012c07af3d2a..829de10ad0177 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.ts +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.ts @@ -6,7 +6,7 @@ import { Plugin, PluginInitializerContext, CoreSetup } from 'src/core/server'; -import { IndexMgmtSetup } from '../../../../../plugins/index_management/server'; +import { IndexManagementPluginSetup } from '../../../../../plugins/index_management/server'; // @ts-ignore import { registerLicenseChecker } from './lib/register_license_checker'; @@ -15,7 +15,7 @@ import { registerRoutes } from './routes/register_routes'; import { ccrDataEnricher } from './cross_cluster_replication_data'; interface PluginDependencies { - indexManagement: IndexMgmtSetup; + indexManagement: IndexManagementPluginSetup; __LEGACY: { server: any; ccrUIEnabled: boolean; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/common/constants/index.ts b/x-pack/legacy/plugins/index_lifecycle_management/common/constants/index.ts deleted file mode 100644 index 9193efb561a0f..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/common/constants/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; - -export const PLUGIN = { - ID: 'index_lifecycle_management', - TITLE: i18n.translate('xpack.indexLifecycleMgmt.appTitle', { - defaultMessage: 'Index Lifecycle Policies', - }), -}; - -export const BASE_PATH = '/management/elasticsearch/index_lifecycle_management/'; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/index.ts b/x-pack/legacy/plugins/index_lifecycle_management/index.ts deleted file mode 100644 index 9b14b7143bf44..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/index.ts +++ /dev/null @@ -1,62 +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 { Legacy } from 'kibana'; -import { resolve } from 'path'; -import { PLUGIN } from './common/constants'; -import { Plugin as IndexLifecycleManagementPlugin } from './plugin'; -import { createShim } from './shim'; - -export function indexLifecycleManagement(kibana: any) { - return new kibana.Plugin({ - id: PLUGIN.ID, - configPrefix: 'xpack.ilm', - publicDir: resolve(__dirname, 'public'), - require: ['kibana', 'elasticsearch', 'xpack_main', 'index_management'], - uiExports: { - styleSheetPaths: resolve(__dirname, 'public/np_ready/application/index.scss'), - managementSections: ['plugins/index_lifecycle_management/legacy'], - injectDefaultVars(server: Legacy.Server) { - const config = server.config(); - return { - ilmUiEnabled: config.get('xpack.ilm.ui.enabled'), - }; - }, - }, - config: (Joi: any) => { - return Joi.object({ - // display menu item - ui: Joi.object({ - enabled: Joi.boolean().default(true), - }).default(), - - // enable plugin - enabled: Joi.boolean().default(true), - - filteredNodeAttributes: Joi.array() - .items(Joi.string()) - .default([]), - }).default(); - }, - isEnabled(config: any) { - return ( - config.get('xpack.ilm.enabled') && - config.has('xpack.index_management.enabled') && - config.get('xpack.index_management.enabled') - ); - }, - init(server: Legacy.Server) { - const core = server.newPlatform.setup.core; - const plugins = {}; - const __LEGACY = createShim(server); - - const indexLifecycleManagementPlugin = new IndexLifecycleManagementPlugin(); - - // Set up plugin. - indexLifecycleManagementPlugin.setup(core, plugins, __LEGACY); - }, - }); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/plugin.ts b/x-pack/legacy/plugins/index_lifecycle_management/plugin.ts deleted file mode 100644 index 38d1bea45ce07..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/plugin.ts +++ /dev/null @@ -1,54 +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 { CoreSetup } from 'kibana/server'; -import { LegacySetup } from './shim'; -import { registerLicenseChecker } from './server/lib/register_license_checker'; -import { registerIndexRoutes } from './server/routes/api/index'; -import { registerNodesRoutes } from './server/routes/api/nodes'; -import { registerPoliciesRoutes } from './server/routes/api/policies'; -import { registerTemplatesRoutes } from './server/routes/api/templates'; - -const indexLifecycleDataEnricher = async (indicesList: any, callWithRequest: any) => { - if (!indicesList || !indicesList.length) { - return; - } - const params = { - path: '/*/_ilm/explain', - method: 'GET', - }; - const { indices: ilmIndicesData } = await callWithRequest('transport.request', params); - return indicesList.map((index: any): any => { - return { - ...index, - ilm: { ...(ilmIndicesData[index.name] || {}) }, - }; - }); -}; - -export class Plugin { - public setup(core: CoreSetup, plugins: any, __LEGACY: LegacySetup): void { - const { server } = __LEGACY; - - registerLicenseChecker(server); - - // Register routes. - registerIndexRoutes(server); - registerNodesRoutes(server); - registerPoliciesRoutes(server); - registerTemplatesRoutes(server); - - const serverPlugins = server.newPlatform.setup.plugins as any; - - if ( - server.config().get('xpack.ilm.ui.enabled') && - serverPlugins.indexManagement && - serverPlugins.indexManagement.indexDataEnricher - ) { - serverPlugins.indexManagement.indexDataEnricher.add(indexLifecycleDataEnricher); - } - } -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/legacy.ts b/x-pack/legacy/plugins/index_lifecycle_management/public/legacy.ts deleted file mode 100644 index 006e5f6098f2b..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/legacy.ts +++ /dev/null @@ -1,109 +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 { App } from 'src/core/public'; - -/* Legacy Imports */ -import { npSetup, npStart } from 'ui/new_platform'; -import chrome from 'ui/chrome'; -import routes from 'ui/routes'; -import { management } from 'ui/management'; -import { createUiStatsReporter } from '../../../../../src/legacy/core_plugins/ui_metric/public'; - -import { PLUGIN, BASE_PATH } from '../common/constants'; -import { createPlugin } from './np_ready'; -import { addAllExtensions } from './np_ready/extend_index_management'; - -if (chrome.getInjected('ilmUiEnabled')) { - // We have to initialize this outside of the NP lifecycle, otherwise these extensions won't - // be available in Index Management unless the user visits ILM first. - if ((npSetup.plugins as any).indexManagement) { - addAllExtensions((npSetup.plugins as any).indexManagement.extensionsService); - } - - // This method handles the cleanup needed when route is scope is destroyed. It also prevents Angular - // from destroying scope when route changes and both old route and new route are this same route. - const manageAngularLifecycle = ($scope: any, $route: any, unmount: () => void) => { - const lastRoute = $route.current; - const deregister = $scope.$on('$locationChangeSuccess', () => { - const currentRoute = $route.current; - // if templates are the same we are on the same route - if (lastRoute.$$route.template === currentRoute.$$route.template) { - // this prevents angular from destroying scope - $route.current = lastRoute; - } - }); - $scope.$on('$destroy', () => { - if (deregister) { - deregister(); - } - unmount(); - }); - }; - - // Once this app no longer depends upon Angular's routing (e.g. for the "redirect" service), we can - // use the Management plugin's API to register this app within the Elasticsearch section. - const esSection = management.getSection('elasticsearch'); - esSection.register('index_lifecycle_policies', { - visible: true, - display: PLUGIN.TITLE, - order: 2, - url: `#${BASE_PATH}policies`, - }); - - const REACT_ROOT_ID = 'indexLifecycleManagementReactRoot'; - - const template = ` -
- - `; - - routes.when(`${BASE_PATH}:view?/:action?/:id?`, { - template, - controllerAs: 'indexLifecycleManagement', - controller: class IndexLifecycleManagementController { - constructor($scope: any, $route: any, kbnUrl: any, $rootScope: any) { - $scope.$$postDigest(() => { - const element = document.getElementById(REACT_ROOT_ID)!; - const { core } = npSetup; - - const coreDependencies = { - ...core, - application: { - ...core.application, - async register(app: App) { - const unmountApp = await app.mount({ ...npStart } as any, { - element, - appBasePath: '', - onAppLeave: () => undefined, - // TODO: adapt to use Core's ScopedHistory - history: {} as any, - }); - manageAngularLifecycle($scope, $route, unmountApp as any); - }, - }, - }; - - // The Plugin interface won't allow us to pass __LEGACY as a third argument, so we'll just - // sneak it inside of the plugins argument for now. - const pluginDependencies = { - __LEGACY: { - redirect: (path: string) => { - $scope.$evalAsync(() => { - kbnUrl.redirect(path); - }); - }, - createUiStatsReporter, - }, - }; - - const plugin = createPlugin({} as any); - plugin.setup(coreDependencies, pluginDependencies); - }); - } - } as any, - } as any); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/_index_lifecycle_management.scss b/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/_index_lifecycle_management.scss deleted file mode 100644 index 96c6d1a938c61..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/_index_lifecycle_management.scss +++ /dev/null @@ -1,17 +0,0 @@ -.policyTable__horizontalScrollContainer { - overflow-x: auto; - max-width: 100%; -} - -.policyTable__horizontalScroll { - min-width: 800px; - width: 100%; -} - -.policyTable__link { - font-weight: 400; -} - -.ilmEditPolicyPageContent { - max-width: 1200px !important; -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/app.tsx b/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/app.tsx deleted file mode 100644 index 6738d7caa4444..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/app.tsx +++ /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 React, { useEffect } from 'react'; -import { HashRouter, Switch, Route, Redirect } from 'react-router-dom'; -import { METRIC_TYPE } from '@kbn/analytics'; - -import { BASE_PATH } from '../../../common/constants'; -import { UIM_APP_LOAD } from './constants'; -import { EditPolicy } from './sections/edit_policy'; -import { PolicyTable } from './sections/policy_table'; -import { trackUiMetric } from './services/ui_metric'; - -export const App = () => { - useEffect(() => trackUiMetric(METRIC_TYPE.LOADED, UIM_APP_LOAD), []); - - return ( - - - - - - - - ); -}; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/index.scss b/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/index.scss deleted file mode 100644 index 53e90e2aae35b..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/index.scss +++ /dev/null @@ -1,3 +0,0 @@ -// Import the EUI global scope so we can use EUI constants -@import 'src/legacy/ui/public/styles/_styling_constants'; -@import 'index_lifecycle_management'; \ No newline at end of file diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/index.tsx b/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/index.tsx deleted file mode 100644 index b87a633d65c9c..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/index.tsx +++ /dev/null @@ -1,63 +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 React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; -import { Provider } from 'react-redux'; -import { DocLinksStart, ToastsSetup, HttpSetup, FatalErrorsSetup } from 'src/core/public'; - -import { App } from './app'; -import { indexLifecycleManagementStore } from './store'; -import { init as initHttp } from './services/http'; -import { init as initNavigation } from './services/navigation'; -import { init as initDocumentation } from './services/documentation'; -import { init as initUiMetric } from './services/ui_metric'; -import { init as initNotification } from './services/notification'; - -export interface LegacySetup { - redirect: any; - createUiStatsReporter: any; -} - -interface AppDependencies { - legacy: LegacySetup; - I18nContext: any; - http: HttpSetup; - toasts: ToastsSetup; - fatalErrors: FatalErrorsSetup; - docLinks: DocLinksStart; - element: HTMLElement; -} - -export const renderApp = (appDependencies: AppDependencies) => { - const { - legacy: { redirect, createUiStatsReporter }, - I18nContext, - http, - toasts, - fatalErrors, - docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }, - element, - } = appDependencies; - - // Initialize services - initHttp(http); - initNavigation(redirect); - initDocumentation(`${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`); - initUiMetric(createUiStatsReporter); - initNotification(toasts, fatalErrors); - - render( - - - - - , - element - ); - - return () => unmountComponentAtNode(element); -}; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/api.js b/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/api.js deleted file mode 100644 index f13bbcb6162b8..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/api.js +++ /dev/null @@ -1,81 +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 { - UIM_POLICY_DELETE, - UIM_POLICY_ATTACH_INDEX, - UIM_POLICY_ATTACH_INDEX_TEMPLATE, - UIM_POLICY_DETACH_INDEX, - UIM_INDEX_RETRY_STEP, -} from '../constants'; - -import { trackUiMetric } from './ui_metric'; -import { sendGet, sendPost, sendDelete } from './http'; - -// The extend_index_management module that we support an injected httpClient here. - -export async function loadNodes(httpClient) { - return await sendGet(`nodes/list`, httpClient); -} - -export async function loadNodeDetails(selectedNodeAttrs, httpClient) { - return await sendGet(`nodes/${selectedNodeAttrs}/details`, httpClient); -} - -export async function loadIndexTemplates(httpClient) { - return await sendGet(`templates`, httpClient); -} - -export async function loadIndexTemplate(templateName, httpClient) { - if (!templateName) { - return {}; - } - return await sendGet(`templates/${templateName}`, httpClient); -} - -export async function loadPolicies(withIndices, httpClient) { - const query = withIndices ? '?withIndices=true' : ''; - return await sendGet('policies', query, httpClient); -} - -export async function savePolicy(policy, httpClient) { - return await sendPost(`policies`, policy, httpClient); -} - -export async function deletePolicy(policyName, httpClient) { - const response = await sendDelete(`policies/${encodeURIComponent(policyName)}`, httpClient); - // Only track successful actions. - trackUiMetric('count', UIM_POLICY_DELETE); - return response; -} - -export const retryLifecycleForIndex = async (indexNames, httpClient) => { - const response = await sendPost(`index/retry`, { indexNames }, httpClient); - // Only track successful actions. - trackUiMetric('count', UIM_INDEX_RETRY_STEP); - return response; -}; - -export const removeLifecycleForIndex = async (indexNames, httpClient) => { - const response = await sendPost(`index/remove`, { indexNames }, httpClient); - // Only track successful actions. - trackUiMetric('count', UIM_POLICY_DETACH_INDEX); - return response; -}; - -export const addLifecyclePolicyToIndex = async (body, httpClient) => { - const response = await sendPost(`index/add`, body, httpClient); - // Only track successful actions. - trackUiMetric('count', UIM_POLICY_ATTACH_INDEX); - return response; -}; - -export const addLifecyclePolicyToTemplate = async (body, httpClient) => { - const response = await sendPost(`template`, body, httpClient); - // Only track successful actions. - trackUiMetric('count', UIM_POLICY_ATTACH_INDEX_TEMPLATE); - return response; -}; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/http.ts b/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/http.ts deleted file mode 100644 index bbda1ebd2e0e5..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/http.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -let _httpClient: any; - -export function init(httpClient: any): void { - _httpClient = httpClient; -} - -function getFullPath(path: string): string { - const apiPrefix = '/api/index_lifecycle_management'; - - if (path) { - return `${apiPrefix}/${path}`; - } - - return apiPrefix; -} - -// The extend_index_management module requires that we support an injected httpClient here. - -export function sendPost(path: string, payload: any, httpClient = _httpClient): any { - return httpClient.post(getFullPath(path), { body: JSON.stringify(payload) }); -} - -export function sendGet(path: string, query: any, httpClient = _httpClient): any { - return httpClient.get(getFullPath(path), { query }); -} - -export function sendDelete(path: string, httpClient = _httpClient): any { - return httpClient.delete(getFullPath(path)); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/navigation.ts b/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/navigation.ts deleted file mode 100644 index 943f9a49d0ab6..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/navigation.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { BASE_PATH } from '../../../../common/constants'; - -// This depends upon Angular, which is why we use this provider pattern to access it within -// our React app. -let _redirect: any; - -export function init(redirect: any) { - _redirect = redirect; -} - -export const goToPolicyList = () => { - _redirect(`${BASE_PATH}policies`); -}; - -export const getPolicyPath = (policyName: string): string => { - return encodeURI(`#${BASE_PATH}policies/edit/${encodeURIComponent(policyName)}`); -}; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/extend_index_management/index.js b/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/extend_index_management/index.js deleted file mode 100644 index 69658d31695bc..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/extend_index_management/index.js +++ /dev/null @@ -1,248 +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 React from 'react'; -import { get, every, any } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { EuiSearchBar } from '@elastic/eui'; - -import { init as initUiMetric } from '../application/services/ui_metric'; -import { init as initNotification } from '../application/services/notification'; -import { retryLifecycleForIndex } from '../application/services/api'; -import { IndexLifecycleSummary } from './components/index_lifecycle_summary'; -import { AddLifecyclePolicyConfirmModal } from './components/add_lifecycle_confirm_modal'; -import { RemoveLifecyclePolicyConfirmModal } from './components/remove_lifecycle_confirm_modal'; - -const stepPath = 'ilm.step'; - -export const retryLifecycleActionExtension = ({ - indices, - usageCollection, - toasts, - fatalErrors, -}) => { - // These are hacks that we can remove once the New Platform migration is done. They're needed here - // because API requests and API errors require them. - const getLegacyReporter = appName => (type, name) => { - usageCollection.reportUiStats(appName, type, name); - }; - - initUiMetric(getLegacyReporter); - initNotification(toasts, fatalErrors); - - const allHaveErrors = every(indices, index => { - return index.ilm && index.ilm.failed_step; - }); - if (!allHaveErrors) { - return null; - } - const indexNames = indices.map(({ name }) => name); - return { - requestMethod: retryLifecycleForIndex, - icon: 'play', - indexNames: [indexNames], - buttonLabel: i18n.translate('xpack.indexLifecycleMgmt.retryIndexLifecycleActionButtonLabel', { - defaultMessage: 'Retry lifecycle step', - }), - successMessage: i18n.translate( - 'xpack.indexLifecycleMgmt.retryIndexLifecycleAction.retriedLifecycleMessage', - { - defaultMessage: 'Called retry lifecycle step for: {indexNames}', - values: { indexNames: indexNames.map(indexName => `"${indexName}"`).join(', ') }, - } - ), - }; -}; - -export const removeLifecyclePolicyActionExtension = ({ - indices, - reloadIndices, - createUiStatsReporter, - toasts, - fatalErrors, - httpClient, -}) => { - // These are hacks that we can remove once the New Platform migration is done. They're needed here - // because API requests and API errors require them. - initUiMetric(createUiStatsReporter); - initNotification(toasts, fatalErrors); - - const allHaveIlm = every(indices, index => { - return index.ilm && index.ilm.managed; - }); - if (!allHaveIlm) { - return null; - } - const indexNames = indices.map(({ name }) => name); - return { - renderConfirmModal: closeModal => { - return ( - - ); - }, - icon: 'stopFilled', - indexNames: [indexNames], - buttonLabel: i18n.translate('xpack.indexLifecycleMgmt.removeIndexLifecycleActionButtonLabel', { - defaultMessage: 'Remove lifecycle policy', - }), - }; -}; - -export const addLifecyclePolicyActionExtension = ({ - indices, - reloadIndices, - createUiStatsReporter, - toasts, - fatalErrors, - httpClient, -}) => { - // These are hacks that we can remove once the New Platform migration is done. They're needed here - // because API requests and API errors require them. - initUiMetric(createUiStatsReporter); - initNotification(toasts, fatalErrors); - - if (indices.length !== 1) { - return null; - } - const index = indices[0]; - const hasIlm = index.ilm && index.ilm.managed; - - if (hasIlm) { - return null; - } - const indexName = index.name; - return { - renderConfirmModal: closeModal => { - return ( - - ); - }, - icon: 'plusInCircle', - buttonLabel: i18n.translate('xpack.indexLifecycleMgmt.addLifecyclePolicyActionButtonLabel', { - defaultMessage: 'Add lifecycle policy', - }), - }; -}; - -export const ilmBannerExtension = indices => { - const { Query } = EuiSearchBar; - if (!indices.length) { - return null; - } - const indicesWithLifecycleErrors = indices.filter(index => { - return get(index, stepPath) === 'ERROR'; - }); - const numIndicesWithLifecycleErrors = indicesWithLifecycleErrors.length; - if (!numIndicesWithLifecycleErrors) { - return null; - } - return { - type: 'warning', - filter: Query.parse(`${stepPath}:ERROR`), - filterLabel: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtBanner.filterLabel', { - defaultMessage: 'Show errors', - }), - title: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtBanner.errorMessage', { - defaultMessage: `{ numIndicesWithLifecycleErrors, number} - {numIndicesWithLifecycleErrors, plural, one {index has} other {indices have} } - lifecycle errors`, - values: { numIndicesWithLifecycleErrors }, - }), - }; -}; - -export const ilmSummaryExtension = index => { - return ; -}; - -export const ilmFilterExtension = indices => { - const hasIlm = any(indices, index => index.ilm && index.ilm.managed); - if (!hasIlm) { - return []; - } else { - return [ - { - type: 'field_value_selection', - name: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.lifecycleStatusLabel', { - defaultMessage: 'Lifecycle status', - }), - multiSelect: false, - field: 'ilm.managed', - options: [ - { - value: true, - view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.managedLabel', { - defaultMessage: 'Managed', - }), - }, - { - value: false, - view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.unmanagedLabel', { - defaultMessage: 'Unmanaged', - }), - }, - ], - }, - { - type: 'field_value_selection', - field: 'ilm.phase', - name: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.lifecyclePhaseLabel', { - defaultMessage: 'Lifecycle phase', - }), - multiSelect: 'or', - options: [ - { - value: 'hot', - view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.hotLabel', { - defaultMessage: 'Hot', - }), - }, - { - value: 'warm', - view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.warmLabel', { - defaultMessage: 'Warm', - }), - }, - { - value: 'cold', - view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.coldLabel', { - defaultMessage: 'Cold', - }), - }, - { - value: 'delete', - view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.deleteLabel', { - defaultMessage: 'Delete', - }), - }, - ], - }, - ]; - } -}; - -export const addAllExtensions = extensionsService => { - extensionsService.addAction(retryLifecycleActionExtension); - extensionsService.addAction(removeLifecyclePolicyActionExtension); - extensionsService.addAction(addLifecyclePolicyActionExtension); - - extensionsService.addBanner(ilmBannerExtension); - extensionsService.addSummary(ilmSummaryExtension); - extensionsService.addFilter(ilmFilterExtension); -}; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/index.ts b/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/index.ts deleted file mode 100644 index 1af0b697a9283..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/index.ts +++ /dev/null @@ -1,10 +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 { PluginInitializerContext } from 'src/core/public'; -import { IndexLifecycleManagementPlugin } from './plugin'; - -export const createPlugin = (ctx: PluginInitializerContext) => new IndexLifecycleManagementPlugin(); diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/plugin.tsx b/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/plugin.tsx deleted file mode 100644 index e2897f09fa892..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/plugin.tsx +++ /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. - */ - -import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { PLUGIN } from '../../common/constants'; -import { LegacySetup } from './application'; - -interface PluginsSetup { - __LEGACY: LegacySetup; -} - -export class IndexLifecycleManagementPlugin implements Plugin { - setup(core: CoreSetup, plugins: PluginsSetup) { - // Extract individual core dependencies. - const { - application, - notifications: { toasts }, - fatalErrors, - http, - } = core; - - // The Plugin interface won't allow us to pass __LEGACY as a third argument, so we'll just - // sneak it inside of the plugins parameter for now. - const { __LEGACY } = plugins; - - application.register({ - id: PLUGIN.ID, - title: PLUGIN.TITLE, - async mount(config, mountPoint) { - const { - core: { - docLinks, - i18n: { Context: I18nContext }, - }, - } = config; - - const { element } = mountPoint; - const { renderApp } = await import('./application'); - - // Inject all dependencies into our app. - return renderApp({ - legacy: { ...__LEGACY }, - I18nContext, - http, - toasts, - fatalErrors, - docLinks, - element, - }); - }, - }); - } - start(core: CoreStart, plugins: any) {} - stop() {} -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/call_with_request_factory/call_with_request_factory.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/call_with_request_factory/call_with_request_factory.ts deleted file mode 100644 index 1b28dc4fde4f7..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/call_with_request_factory/call_with_request_factory.ts +++ /dev/null @@ -1,19 +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 { once } from 'lodash'; -import { Legacy } from 'kibana'; - -const callWithRequest = once((server: Legacy.Server): any => { - const cluster = server.plugins.elasticsearch.getCluster('data'); - return cluster.callWithRequest; -}); - -export const callWithRequestFactory = (server: Legacy.Server, request: any) => { - return (...args: any[]) => { - return callWithRequest(server)(request, ...args); - }; -}; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/call_with_request_factory/index.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/call_with_request_factory/index.ts deleted file mode 100644 index 787814d87dff9..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/call_with_request_factory/index.ts +++ /dev/null @@ -1,7 +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 { callWithRequestFactory } from './call_with_request_factory'; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/check_license/__tests__/check_license.js b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/check_license/__tests__/check_license.js deleted file mode 100644 index 933fda01c055d..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/check_license/__tests__/check_license.js +++ /dev/null @@ -1,145 +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 expect from '@kbn/expect'; -import { set } from 'lodash'; -import { checkLicense } from '../check_license'; - -describe('check_license', function() { - let mockLicenseInfo; - beforeEach(() => (mockLicenseInfo = {})); - - describe('license information is undefined', () => { - beforeEach(() => (mockLicenseInfo = undefined)); - - it('should set isAvailable to false', () => { - expect(checkLicense(mockLicenseInfo).isAvailable).to.be(false); - }); - - it('should set showLinks to true', () => { - expect(checkLicense(mockLicenseInfo).showLinks).to.be(true); - }); - - it('should set enableLinks to false', () => { - expect(checkLicense(mockLicenseInfo).enableLinks).to.be(false); - }); - - it('should set a message', () => { - expect(checkLicense(mockLicenseInfo).message).to.not.be(undefined); - }); - }); - - describe('license information is not available', () => { - beforeEach(() => (mockLicenseInfo.isAvailable = () => false)); - - it('should set isAvailable to false', () => { - expect(checkLicense(mockLicenseInfo).isAvailable).to.be(false); - }); - - it('should set showLinks to true', () => { - expect(checkLicense(mockLicenseInfo).showLinks).to.be(true); - }); - - it('should set enableLinks to false', () => { - expect(checkLicense(mockLicenseInfo).enableLinks).to.be(false); - }); - - it('should set a message', () => { - expect(checkLicense(mockLicenseInfo).message).to.not.be(undefined); - }); - }); - - describe('license information is available', () => { - beforeEach(() => { - mockLicenseInfo.isAvailable = () => true; - set(mockLicenseInfo, 'license.getType', () => 'basic'); - }); - - describe('& license is > basic', () => { - beforeEach(() => set(mockLicenseInfo, 'license.isOneOf', () => true)); - - describe('& license is active', () => { - beforeEach(() => set(mockLicenseInfo, 'license.isActive', () => true)); - - it('should set isAvailable to true', () => { - expect(checkLicense(mockLicenseInfo).isAvailable).to.be(true); - }); - - it('should set showLinks to true', () => { - expect(checkLicense(mockLicenseInfo).showLinks).to.be(true); - }); - - it('should set enableLinks to true', () => { - expect(checkLicense(mockLicenseInfo).enableLinks).to.be(true); - }); - - it('should not set a message', () => { - expect(checkLicense(mockLicenseInfo).message).to.be(undefined); - }); - }); - - describe('& license is expired', () => { - beforeEach(() => set(mockLicenseInfo, 'license.isActive', () => false)); - - it('should set isAvailable to false', () => { - expect(checkLicense(mockLicenseInfo).isAvailable).to.be(false); - }); - - it('should set showLinks to true', () => { - expect(checkLicense(mockLicenseInfo).showLinks).to.be(true); - }); - - it('should set enableLinks to false', () => { - expect(checkLicense(mockLicenseInfo).enableLinks).to.be(false); - }); - - it('should set a message', () => { - expect(checkLicense(mockLicenseInfo).message).to.not.be(undefined); - }); - }); - }); - - describe('& license is basic', () => { - beforeEach(() => set(mockLicenseInfo, 'license.isOneOf', () => true)); - - describe('& license is active', () => { - beforeEach(() => set(mockLicenseInfo, 'license.isActive', () => true)); - - it('should set isAvailable to true', () => { - expect(checkLicense(mockLicenseInfo).isAvailable).to.be(true); - }); - - it('should set showLinks to true', () => { - expect(checkLicense(mockLicenseInfo).showLinks).to.be(true); - }); - - it('should set enableLinks to true', () => { - expect(checkLicense(mockLicenseInfo).enableLinks).to.be(true); - }); - - it('should not set a message', () => { - expect(checkLicense(mockLicenseInfo).message).to.be(undefined); - }); - }); - - describe('& license is expired', () => { - beforeEach(() => set(mockLicenseInfo, 'license.isActive', () => false)); - - it('should set isAvailable to false', () => { - expect(checkLicense(mockLicenseInfo).isAvailable).to.be(false); - }); - - it('should set showLinks to true', () => { - expect(checkLicense(mockLicenseInfo).showLinks).to.be(true); - }); - - it('should set a message', () => { - expect(checkLicense(mockLicenseInfo).message).to.not.be(undefined); - }); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/check_license/check_license.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/check_license/check_license.ts deleted file mode 100644 index b35ab14964d55..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/check_license/check_license.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; - -export function checkLicense(xpackLicenseInfo: any): any { - const pluginName = 'Index Lifecycle Policies'; - - // If, for some reason, we cannot get the license information - // from Elasticsearch, assume worst case and disable - if (!xpackLicenseInfo || !xpackLicenseInfo.isAvailable()) { - return { - isAvailable: false, - showLinks: true, - enableLinks: false, - message: i18n.translate('xpack.indexLifecycleMgmt.checkLicense.errorUnavailableMessage', { - defaultMessage: - 'You cannot use {pluginName} because license information is not available at this time.', - values: { pluginName }, - }), - }; - } - - const VALID_LICENSE_MODES = ['basic', 'standard', 'gold', 'platinum', 'enterprise', 'trial']; - - const isLicenseModeValid = xpackLicenseInfo.license.isOneOf(VALID_LICENSE_MODES); - const isLicenseActive = xpackLicenseInfo.license.isActive(); - const licenseType = xpackLicenseInfo.license.getType(); - - // License is not valid - if (!isLicenseModeValid) { - return { - isAvailable: false, - showLinks: false, - message: i18n.translate('xpack.indexLifecycleMgmt.checkLicense.errorUnsupportedMessage', { - defaultMessage: - 'Your {licenseType} license does not support {pluginName}. Please upgrade your license.', - values: { licenseType, pluginName }, - }), - }; - } - - // License is valid but not active - if (!isLicenseActive) { - return { - isAvailable: false, - showLinks: true, - enableLinks: false, - message: i18n.translate('xpack.indexLifecycleMgmt.checkLicense.errorExpiredMessage', { - defaultMessage: - 'You cannot use {pluginName} because your {licenseType} license has expired.', - values: { pluginName, licenseType }, - }), - }; - } - - // License is valid and active - return { - isAvailable: true, - showLinks: true, - enableLinks: true, - }; -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_custom_error.js b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_custom_error.js deleted file mode 100644 index f9c102be7a1ff..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_custom_error.js +++ /dev/null @@ -1,21 +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 expect from '@kbn/expect'; -import { wrapCustomError } from '../wrap_custom_error'; - -describe('wrap_custom_error', () => { - describe('#wrapCustomError', () => { - it('should return a Boom object', () => { - const originalError = new Error('I am an error'); - const statusCode = 404; - const wrappedError = wrapCustomError(originalError, statusCode); - - expect(wrappedError.isBoom).to.be(true); - expect(wrappedError.output.statusCode).to.equal(statusCode); - }); - }); -}); diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_es_error.js b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_es_error.js deleted file mode 100644 index fe2b6cce652f1..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_es_error.js +++ /dev/null @@ -1,38 +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 expect from '@kbn/expect'; -import { wrapEsError } from '../wrap_es_error'; - -describe('wrap_es_error', () => { - describe('#wrapEsError', () => { - let originalError; - beforeEach(() => { - originalError = new Error('I am an error'); - originalError.statusCode = 404; - }); - - it('should return a Boom object', () => { - const wrappedError = wrapEsError(originalError); - - expect(wrappedError.isBoom).to.be(true); - }); - - it('should return the correct Boom object', () => { - const wrappedError = wrapEsError(originalError); - - expect(wrappedError.output.statusCode).to.be(originalError.statusCode); - expect(wrappedError.output.payload.message).to.be(originalError.message); - }); - - it('should return the correct Boom object with custom message', () => { - const wrappedError = wrapEsError(originalError, { 404: 'No encontrado!' }); - - expect(wrappedError.output.statusCode).to.be(originalError.statusCode); - expect(wrappedError.output.payload.message).to.be('No encontrado!'); - }); - }); -}); diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_unknown_error.js b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_unknown_error.js deleted file mode 100644 index 85e0b2b3033ad..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/__tests__/wrap_unknown_error.js +++ /dev/null @@ -1,19 +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 expect from '@kbn/expect'; -import { wrapUnknownError } from '../wrap_unknown_error'; - -describe('wrap_unknown_error', () => { - describe('#wrapUnknownError', () => { - it('should return a Boom object', () => { - const originalError = new Error('I am an error'); - const wrappedError = wrapUnknownError(originalError); - - expect(wrappedError.isBoom).to.be(true); - }); - }); -}); diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/index.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/index.ts deleted file mode 100644 index f275f15637091..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/index.ts +++ /dev/null @@ -1,9 +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 { wrapCustomError } from './wrap_custom_error'; -export { wrapEsError } from './wrap_es_error'; -export { wrapUnknownError } from './wrap_unknown_error'; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_custom_error.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_custom_error.ts deleted file mode 100644 index c5780e7c83fb5..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_custom_error.ts +++ /dev/null @@ -1,18 +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 Boom from 'boom'; - -/** - * Wraps a custom error into a Boom error response and returns it - * - * @param err Object error - * @param statusCode Error status code - * @return Object Boom error response - */ -export function wrapCustomError(err: any, statusCode: any): any { - return Boom.boomify(err, { statusCode }); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_es_error.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_es_error.ts deleted file mode 100644 index 6980a5afa5eac..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_es_error.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 Boom from 'boom'; - -/** - * Wraps an error thrown by the ES JS client into a Boom error response and returns it - * - * @param err Object Error thrown by ES JS client - * @param statusCodeToMessageMap Object Optional map of HTTP status codes => error messages - * @return Object Boom error response - */ -export function wrapEsError(err: any, statusCodeToMessageMap: any = {}): any { - const statusCode = err.statusCode; - - // If no custom message if specified for the error's status code, just - // wrap the error as a Boom error response and return it - if (!statusCodeToMessageMap[statusCode]) { - return Boom.boomify(err, { statusCode }); - } - - // Otherwise, use the custom message to create a Boom error response and - // return it - const message = statusCodeToMessageMap[statusCode]; - return new Boom(message, { statusCode }); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_unknown_error.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_unknown_error.ts deleted file mode 100644 index ede1baec286f3..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/error_wrappers/wrap_unknown_error.ts +++ /dev/null @@ -1,17 +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 Boom from 'boom'; - -/** - * Wraps an unknown error into a Boom error response and returns it - * - * @param err Object Unknown error - * @return Object Boom error response - */ -export function wrapUnknownError(err: any): any { - return Boom.boomify(err); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/is_es_error/index.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/is_es_error/index.ts deleted file mode 100644 index a9a3c61472d8c..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/is_es_error/index.ts +++ /dev/null @@ -1,7 +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 { isEsError } from './is_es_error'; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js deleted file mode 100644 index 4d3b33f8b3af3..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js +++ /dev/null @@ -1,69 +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 expect from '@kbn/expect'; -import { licensePreRoutingFactory } from '../license_pre_routing_factory'; - -describe('license_pre_routing_factory', () => { - describe('#reportingFeaturePreRoutingFactory', () => { - let mockServer; - let mockLicenseCheckResults; - - beforeEach(() => { - mockServer = { - plugins: { - xpack_main: { - info: { - feature: () => ({ - getLicenseCheckResults: () => mockLicenseCheckResults, - }), - }, - }, - }, - }; - }); - - it('only instantiates one instance per server', () => { - const firstInstance = licensePreRoutingFactory(mockServer); - const secondInstance = licensePreRoutingFactory(mockServer); - - expect(firstInstance).to.be(secondInstance); - }); - - describe('isAvailable is false', () => { - beforeEach(() => { - mockLicenseCheckResults = { - isAvailable: false, - }; - }); - - it('replies with 403', () => { - const licensePreRouting = licensePreRoutingFactory(mockServer); - const stubRequest = {}; - expect(() => licensePreRouting(stubRequest)).to.throwException(response => { - expect(response).to.be.an(Error); - expect(response.isBoom).to.be(true); - expect(response.output.statusCode).to.be(403); - }); - }); - }); - - describe('isAvailable is true', () => { - beforeEach(() => { - mockLicenseCheckResults = { - isAvailable: true, - }; - }); - - it('replies with nothing', () => { - const licensePreRouting = licensePreRoutingFactory(mockServer); - const stubRequest = {}; - const response = licensePreRouting(stubRequest); - expect(response).to.be(null); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/index.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/index.ts deleted file mode 100644 index 0743e443955f4..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/index.ts +++ /dev/null @@ -1,7 +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 { licensePreRoutingFactory } from './license_pre_routing_factory'; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/license_pre_routing_factory.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/license_pre_routing_factory.ts deleted file mode 100644 index e348125967c14..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/license_pre_routing_factory/license_pre_routing_factory.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 { once } from 'lodash'; -import { Legacy } from 'kibana'; - -import { PLUGIN } from '../../../common/constants'; -import { wrapCustomError } from '../error_wrappers'; - -export const licensePreRoutingFactory = once((server: Legacy.Server) => { - const xpackMainPlugin = server.plugins.xpack_main; - - // License checking and enable/disable logic - function licensePreRouting() { - const licenseCheckResults = xpackMainPlugin.info.feature(PLUGIN.ID).getLicenseCheckResults(); - if (!licenseCheckResults.isAvailable) { - const error = new Error(licenseCheckResults.message); - const statusCode = 403; - throw wrapCustomError(error, statusCode); - } - - return null; - } - - return licensePreRouting; -}); diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/register_license_checker/index.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/register_license_checker/index.ts deleted file mode 100644 index 7b0f97c38d129..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/register_license_checker/index.ts +++ /dev/null @@ -1,7 +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 { registerLicenseChecker } from './register_license_checker'; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/register_license_checker/register_license_checker.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/register_license_checker/register_license_checker.ts deleted file mode 100644 index 8e3b89fa20e33..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/register_license_checker/register_license_checker.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Legacy } from 'kibana'; -// @ts-ignore -import { mirrorPluginStatus } from '../../../../../server/lib/mirror_plugin_status'; -import { PLUGIN } from '../../../common/constants'; -import { checkLicense } from '../check_license'; - -export function registerLicenseChecker(server: Legacy.Server) { - const xpackMainPlugin = server.plugins.xpack_main as any; - const ilmPlugin = (server.plugins as any).index_lifecycle_management; - - mirrorPluginStatus(xpackMainPlugin, ilmPlugin); - xpackMainPlugin.status.once('green', () => { - // Register a function that is called whenever the xpack info changes, - // to re-compute the license check results for this plugin - xpackMainPlugin.info.feature(PLUGIN.ID).registerLicenseCheckResultsGenerator(checkLicense); - }); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/index/index.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/index/index.ts deleted file mode 100644 index 82fb2e3b2a372..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/index/index.ts +++ /dev/null @@ -1,7 +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 { registerIndexRoutes } from './register_index_routes'; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/index/register_add_policy_route.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/index/register_add_policy_route.ts deleted file mode 100644 index c3e235220931c..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/index/register_add_policy_route.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. - */ - -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { isEsError } from '../../../lib/is_es_error'; -import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -async function addLifecyclePolicy( - callWithRequest: any, - indexName: string, - policyName: string, - alias: string -) { - const body = { - lifecycle: { - name: policyName, - rollover_alias: alias, - }, - }; - - const params = { - method: 'PUT', - path: `/${encodeURIComponent(indexName)}/_settings`, - body, - }; - - return callWithRequest('transport.request', params); -} - -export function registerAddPolicyRoute(server: any) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/index_lifecycle_management/index/add', - method: 'POST', - handler: async (request: any) => { - const callWithRequest = callWithRequestFactory(server, request); - const { indexName, policyName, alias } = request.payload as any; - try { - const response = await addLifecyclePolicy(callWithRequest, indexName, policyName, alias); - return response; - } catch (err) { - if (isEsError(err)) { - return wrapEsError(err); - } - - return wrapUnknownError(err); - } - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/index/register_index_routes.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/index/register_index_routes.ts deleted file mode 100644 index 74eb1a86a93ba..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/index/register_index_routes.ts +++ /dev/null @@ -1,15 +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 { registerRetryRoute } from './register_retry_route'; -import { registerRemoveRoute } from './register_remove_route'; -import { registerAddPolicyRoute } from './register_add_policy_route'; - -export function registerIndexRoutes(server: any) { - registerRetryRoute(server); - registerRemoveRoute(server); - registerAddPolicyRoute(server); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/index/register_remove_route.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/index/register_remove_route.ts deleted file mode 100644 index ed3b5a97a3b42..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/index/register_remove_route.ts +++ /dev/null @@ -1,51 +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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { isEsError } from '../../../lib/is_es_error'; -import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -async function removeLifecycle(callWithRequest: any, indexNames: string[]) { - const responses = []; - for (let i = 0; i < indexNames.length; i++) { - const indexName = indexNames[i]; - const params = { - method: 'POST', - path: `/${encodeURIComponent(indexName)}/_ilm/remove`, - ignore: [404], - }; - - responses.push(callWithRequest('transport.request', params)); - } - return Promise.all(responses); -} - -export function registerRemoveRoute(server: any) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/index_lifecycle_management/index/remove', - method: 'POST', - handler: async (request: any) => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const response = await removeLifecycle(callWithRequest, request.payload.indexNames); - return response; - } catch (err) { - if (isEsError(err)) { - return wrapEsError(err); - } - - return wrapUnknownError(err); - } - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/index/register_retry_route.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/index/register_retry_route.ts deleted file mode 100644 index 89278edbecea2..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/index/register_retry_route.ts +++ /dev/null @@ -1,51 +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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { isEsError } from '../../../lib/is_es_error'; -import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -async function retryLifecycle(callWithRequest: any, indexNames: string[]) { - const responses = []; - for (let i = 0; i < indexNames.length; i++) { - const indexName = indexNames[i]; - const params = { - method: 'POST', - path: `/${encodeURIComponent(indexName)}/_ilm/retry`, - ignore: [404], - }; - - responses.push(callWithRequest('transport.request', params)); - } - return Promise.all(responses); -} - -export function registerRetryRoute(server: any) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/index_lifecycle_management/index/retry', - method: 'POST', - handler: async (request: any) => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const response = await retryLifecycle(callWithRequest, request.payload.indexNames); - return response; - } catch (err) { - if (isEsError(err)) { - return wrapEsError(err); - } - - return wrapUnknownError(err); - } - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/nodes/constants.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/nodes/constants.ts deleted file mode 100644 index 4392dacac8fa4..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/nodes/constants.ts +++ /dev/null @@ -1,15 +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 const NODE_ATTRS_KEYS_TO_IGNORE: string[] = [ - 'ml.enabled', - 'ml.machine_memory', - 'ml.max_open_jobs', - // Used by ML to identify nodes that have transform enabled: - // https://github.com/elastic/elasticsearch/pull/52712/files#diff-225cc2c1291b4c60a8c3412a619094e1R147 - 'transform.node', - 'xpack.installed', -]; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/nodes/index.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/nodes/index.ts deleted file mode 100644 index ef0ac271ae60e..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/nodes/index.ts +++ /dev/null @@ -1,7 +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 { registerNodesRoutes } from './register_nodes_routes'; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/nodes/register_details_route.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/nodes/register_details_route.ts deleted file mode 100644 index c2c3f8bf07028..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/nodes/register_details_route.ts +++ /dev/null @@ -1,61 +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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { isEsError } from '../../../lib/is_es_error'; -import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -function findMatchingNodes(stats: any, nodeAttrs: string): any { - return Object.entries(stats.nodes).reduce((accum: any[], [nodeId, nodeStats]: [any, any]) => { - const attributes = nodeStats.attributes || {}; - for (const [key, value] of Object.entries(attributes)) { - if (`${key}:${value}` === nodeAttrs) { - accum.push({ - nodeId, - stats: nodeStats, - }); - break; - } - } - return accum; - }, []); -} - -async function fetchNodeStats(callWithRequest: any): Promise { - const params = { - format: 'json', - }; - - return await callWithRequest('nodes.stats', params); -} - -export function registerDetailsRoute(server: any) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/index_lifecycle_management/nodes/{nodeAttrs}/details', - method: 'GET', - handler: async (request: any) => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const stats = await fetchNodeStats(callWithRequest); - const response = findMatchingNodes(stats, request.params.nodeAttrs); - return response; - } catch (err) { - if (isEsError(err)) { - return wrapEsError(err); - } - - return wrapUnknownError(err); - } - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts deleted file mode 100644 index edbe4289ed83c..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts +++ /dev/null @@ -1,63 +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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { isEsError } from '../../../lib/is_es_error'; -import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; -import { NODE_ATTRS_KEYS_TO_IGNORE } from './constants'; - -function convertStatsIntoList(stats: any, attributesToBeFiltered: string[]): any { - return Object.entries(stats.nodes).reduce((accum: any, [nodeId, nodeStats]: [any, any]) => { - const attributes = nodeStats.attributes || {}; - for (const [key, value] of Object.entries(attributes)) { - if (!attributesToBeFiltered.includes(key)) { - const attributeString = `${key}:${value}`; - accum[attributeString] = accum[attributeString] || []; - accum[attributeString].push(nodeId); - } - } - return accum; - }, {}); -} - -async function fetchNodeStats(callWithRequest: any): Promise { - const params = { - format: 'json', - }; - - return await callWithRequest('nodes.stats', params); -} - -export function registerListRoute(server: any) { - const config = server.config(); - const filteredNodeAttributes = config.get('xpack.ilm.filteredNodeAttributes'); - const attributesToBeFiltered = [...NODE_ATTRS_KEYS_TO_IGNORE, ...filteredNodeAttributes]; - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/index_lifecycle_management/nodes/list', - method: 'GET', - handler: async (request: any) => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const stats = await fetchNodeStats(callWithRequest); - const response = convertStatsIntoList(stats, attributesToBeFiltered); - return response; - } catch (err) { - if (isEsError(err)) { - return wrapEsError(err); - } - - return wrapUnknownError(err); - } - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/nodes/register_nodes_routes.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/nodes/register_nodes_routes.ts deleted file mode 100644 index 4486d97038657..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/nodes/register_nodes_routes.ts +++ /dev/null @@ -1,13 +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 { registerListRoute } from './register_list_route'; -import { registerDetailsRoute } from './register_details_route'; - -export function registerNodesRoutes(server: any) { - registerListRoute(server); - registerDetailsRoute(server); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/policies/index.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/policies/index.ts deleted file mode 100644 index 7c6103a3389ab..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/policies/index.ts +++ /dev/null @@ -1,7 +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 { registerPoliciesRoutes } from './register_policies_routes'; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts deleted file mode 100644 index f6bc96dd498a4..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts +++ /dev/null @@ -1,52 +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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { isEsError } from '../../../lib/is_es_error'; -import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -async function createPolicy(callWithRequest: any, policy: any): Promise { - const body = { - policy: { - phases: policy.phases, - }, - }; - const params = { - method: 'PUT', - path: `/_ilm/policy/${encodeURIComponent(policy.name)}`, - ignore: [404], - body, - }; - - return await callWithRequest('transport.request', params); -} - -export function registerCreateRoute(server: any) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/index_lifecycle_management/policies', - method: 'POST', - handler: async (request: any) => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const response = await createPolicy(callWithRequest, request.payload); - return response; - } catch (err) { - if (isEsError(err)) { - return wrapEsError(err); - } - - return wrapUnknownError(err); - } - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.ts deleted file mode 100644 index c84f2efd92d8f..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.ts +++ /dev/null @@ -1,46 +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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { isEsError } from '../../../lib/is_es_error'; -import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -async function deletePolicies(policyNames: string, callWithRequest: any): Promise { - const params = { - method: 'DELETE', - path: `/_ilm/policy/${encodeURIComponent(policyNames)}`, - // we allow 404 since they may have no policies - ignore: [404], - }; - - return await callWithRequest('transport.request', params); -} - -export function registerDeleteRoute(server: any) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/index_lifecycle_management/policies/{policyNames}', - method: 'DELETE', - handler: async (request: any) => { - const callWithRequest = callWithRequestFactory(server, request); - const { policyNames } = request.params; - try { - await deletePolicies(policyNames, callWithRequest); - return {}; - } catch (err) { - if (isEsError(err)) { - return wrapEsError(err); - } - return wrapUnknownError(err); - } - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.ts deleted file mode 100644 index c65f849a47d87..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.ts +++ /dev/null @@ -1,84 +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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { isEsError } from '../../../lib/is_es_error'; -import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -function formatPolicies(policiesMap: any): any { - if (policiesMap.status === 404) { - return []; - } - - return Object.keys(policiesMap).reduce((accum: any[], lifecycleName: string) => { - const policyEntry = policiesMap[lifecycleName]; - accum.push({ - ...policyEntry, - name: lifecycleName, - }); - return accum; - }, []); -} - -async function fetchPolicies(callWithRequest: any): Promise { - const params = { - method: 'GET', - path: '/_ilm/policy', - // we allow 404 since they may have no policies - ignore: [404], - }; - - return await callWithRequest('transport.request', params); -} -async function addLinkedIndices(policiesMap: any, callWithRequest: any) { - if (policiesMap.status === 404) { - return policiesMap; - } - const params = { - method: 'GET', - path: '/*/_ilm/explain', - // we allow 404 since they may have no policies - ignore: [404], - }; - - const policyExplanation: any = await callWithRequest('transport.request', params); - Object.entries(policyExplanation.indices).forEach(([indexName, { policy }]: [string, any]) => { - if (policy && policiesMap[policy]) { - policiesMap[policy].linkedIndices = policiesMap[policy].linkedIndices || []; - policiesMap[policy].linkedIndices.push(indexName); - } - }); -} - -export function registerFetchRoute(server: any) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/index_lifecycle_management/policies', - method: 'GET', - handler: async (request: any) => { - const callWithRequest = callWithRequestFactory(server, request); - const { withIndices } = request.query; - try { - const policiesMap = await fetchPolicies(callWithRequest); - if (withIndices) { - await addLinkedIndices(policiesMap, callWithRequest); - } - return formatPolicies(policiesMap); - } catch (err) { - if (isEsError(err)) { - return wrapEsError(err); - } - - return wrapUnknownError(err); - } - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/policies/register_policies_routes.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/policies/register_policies_routes.ts deleted file mode 100644 index 279b016da178f..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/policies/register_policies_routes.ts +++ /dev/null @@ -1,15 +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 { registerFetchRoute } from './register_fetch_route'; -import { registerCreateRoute } from './register_create_route'; -import { registerDeleteRoute } from './register_delete_route'; - -export function registerPoliciesRoutes(server: any) { - registerFetchRoute(server); - registerCreateRoute(server); - registerDeleteRoute(server); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/templates/index.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/templates/index.ts deleted file mode 100644 index dc9a0acaaf09b..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/templates/index.ts +++ /dev/null @@ -1,7 +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 { registerTemplatesRoutes } from './register_templates_routes'; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.ts deleted file mode 100644 index 57e5a91f60f5b..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.ts +++ /dev/null @@ -1,67 +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 { merge } from 'lodash'; - -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { isEsError } from '../../../lib/is_es_error'; -import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -async function getIndexTemplate(callWithRequest: any, templateName: string): Promise { - const response = await callWithRequest('indices.getTemplate', { name: templateName }); - return response[templateName]; -} - -async function updateIndexTemplate(callWithRequest: any, indexTemplatePatch: any): Promise { - // Fetch existing template - const template = await getIndexTemplate(callWithRequest, indexTemplatePatch.templateName); - merge(template, { - settings: { - index: { - lifecycle: { - name: indexTemplatePatch.policyName, - rollover_alias: indexTemplatePatch.aliasName, - }, - }, - }, - }); - - const params = { - method: 'PUT', - path: `/_template/${encodeURIComponent(indexTemplatePatch.templateName)}`, - ignore: [404], - body: template, - }; - - return await callWithRequest('transport.request', params); -} - -export function registerAddPolicyRoute(server: any) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/index_lifecycle_management/template', - method: 'POST', - handler: async (request: any) => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const response = await updateIndexTemplate(callWithRequest, request.payload); - return response; - } catch (err) { - if (isEsError(err)) { - return wrapEsError(err); - } - - return wrapUnknownError(err); - } - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts deleted file mode 100644 index fd58f471d69bb..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { isEsError } from '../../../lib/is_es_error'; -import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -/** - * We don't want to output system template (whose name starts with a ".") which don't - * have a time base index pattern (with a wildcard in it) as those templates are already - * assigned to a single index. - * - * @param {String} templateName The index template - * @param {Array} indexPatterns Index patterns - */ -function isReservedSystemTemplate(templateName: string, indexPatterns: string[]): boolean { - return ( - templateName.startsWith('kibana_index_template') || - (templateName.startsWith('.') && - indexPatterns.every(pattern => { - return !pattern.includes('*'); - })) - ); -} - -function filterAndFormatTemplates(templates: any): any { - const formattedTemplates = []; - const templateNames = Object.keys(templates); - for (const templateName of templateNames) { - const { settings, index_patterns } = templates[templateName]; // eslint-disable-line camelcase - if (isReservedSystemTemplate(templateName, index_patterns)) { - continue; - } - const formattedTemplate = { - index_lifecycle_name: - settings.index && settings.index.lifecycle ? settings.index.lifecycle.name : undefined, - index_patterns, - allocation_rules: - settings.index && settings.index.routing ? settings.index.routing : undefined, - settings, - name: templateName, - }; - formattedTemplates.push(formattedTemplate); - } - return formattedTemplates; -} - -async function fetchTemplates(callWithRequest: any): Promise { - const params = { - method: 'GET', - path: '/_template', - // we allow 404 incase the user shutdown security in-between the check and now - ignore: [404], - }; - - return await callWithRequest('transport.request', params); -} -export function registerFetchRoute(server: any) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/index_lifecycle_management/templates', - method: 'GET', - handler: async (request: any) => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const templates = await fetchTemplates(callWithRequest); - return filterAndFormatTemplates(templates); - } catch (err) { - if (isEsError(err)) { - return wrapEsError(err); - } - - return wrapUnknownError(err); - } - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/templates/register_get_route.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/templates/register_get_route.ts deleted file mode 100644 index 3edaea6e15818..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/templates/register_get_route.ts +++ /dev/null @@ -1,48 +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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { isEsError } from '../../../lib/is_es_error'; -import { wrapEsError, wrapUnknownError } from '../../../lib/error_wrappers'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -async function fetchTemplate(callWithRequest: any, templateName: string): Promise { - const params = { - method: 'GET', - path: `/_template/${encodeURIComponent(templateName)}`, - // we allow 404 incase the user shutdown security in-between the check and now - ignore: [404], - }; - - return await callWithRequest('transport.request', params); -} - -export function registerGetRoute(server: any) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/index_lifecycle_management/templates/{templateName}', - method: 'GET', - handler: async (request: any) => { - const callWithRequest = callWithRequestFactory(server, request); - const templateName = request.params.templateName; - - try { - const template = await fetchTemplate(callWithRequest, templateName); - return template[templateName]; - } catch (err) { - if (isEsError(err)) { - return wrapEsError(err); - } - - return wrapUnknownError(err); - } - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/templates/register_templates_routes.ts b/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/templates/register_templates_routes.ts deleted file mode 100644 index 424b2d36b1ba2..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/routes/api/templates/register_templates_routes.ts +++ /dev/null @@ -1,15 +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 { registerFetchRoute } from './register_fetch_route'; -import { registerGetRoute } from './register_get_route'; -import { registerAddPolicyRoute } from './register_add_policy_route'; - -export function registerTemplatesRoutes(server: any) { - registerFetchRoute(server); - registerGetRoute(server); - registerAddPolicyRoute(server); -} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/shim.ts b/x-pack/legacy/plugins/index_lifecycle_management/shim.ts deleted file mode 100644 index 18b3d9ef28b6a..0000000000000 --- a/x-pack/legacy/plugins/index_lifecycle_management/shim.ts +++ /dev/null @@ -1,17 +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 { Legacy } from 'kibana'; - -export interface LegacySetup { - server: Legacy.Server; -} - -export function createShim(server: Legacy.Server): LegacySetup { - return { - server, - }; -} diff --git a/x-pack/legacy/plugins/index_management/index.ts b/x-pack/legacy/plugins/index_management/index.ts index 9eba98a526d2b..afca15203b970 100644 --- a/x-pack/legacy/plugins/index_management/index.ts +++ b/x-pack/legacy/plugins/index_management/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +// TODO: Remove this once CCR is migrated to the plugins directory. export function indexManagement(kibana: any) { return new kibana.Plugin({ id: 'index_management', diff --git a/x-pack/legacy/plugins/lens/index.ts b/x-pack/legacy/plugins/lens/index.ts deleted file mode 100644 index e9a901c58cd90..0000000000000 --- a/x-pack/legacy/plugins/lens/index.ts +++ /dev/null @@ -1,38 +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 * as Joi from 'joi'; -import { resolve } from 'path'; -import { LegacyPluginInitializer } from 'src/legacy/types'; -import { PLUGIN_ID, NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../../../plugins/lens/common'; - -export const lens: LegacyPluginInitializer = kibana => { - return new kibana.Plugin({ - id: PLUGIN_ID, - configPrefix: `xpack.${PLUGIN_ID}`, - // task_manager could be required, but is only used for telemetry - require: ['kibana', 'elasticsearch', 'xpack_main', 'interpreter'], - publicDir: resolve(__dirname, 'public'), - - uiExports: { - app: { - title: NOT_INTERNATIONALIZED_PRODUCT_NAME, - description: 'Explore and visualize data.', - main: `plugins/${PLUGIN_ID}/redirect`, - listed: false, - }, - visualize: [`plugins/${PLUGIN_ID}/legacy`], - embeddableFactories: [`plugins/${PLUGIN_ID}/legacy`], - styleSheetPaths: resolve(__dirname, 'public/index.scss'), - }, - - config: () => { - return Joi.object({ - enabled: Joi.boolean().default(true), - }).default(); - }, - }); -}; diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/_index.scss b/x-pack/legacy/plugins/lens/public/app_plugin/_index.scss deleted file mode 100644 index 2ac86f0e58a61..0000000000000 --- a/x-pack/legacy/plugins/lens/public/app_plugin/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './app'; diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx deleted file mode 100644 index dfea2e39fcbc5..0000000000000 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ /dev/null @@ -1,417 +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 _ from 'lodash'; -import React, { useState, useEffect, useCallback } from 'react'; -import { I18nProvider } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { Query, DataPublicPluginStart } from 'src/plugins/data/public'; -import { AppMountContext, NotificationsStart } from 'src/core/public'; -import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; -import { npStart } from 'ui/new_platform'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; -import { SavedObjectSaveModal } from '../../../../../../src/plugins/saved_objects/public'; -import { Document, SavedObjectStore } from '../persistence'; -import { EditorFrameInstance } from '../types'; -import { NativeRenderer } from '../native_renderer'; -import { trackUiEvent } from '../lens_ui_telemetry'; -import { - esFilters, - Filter, - IndexPattern as IndexPatternInstance, - IndexPatternsContract, - SavedQuery, -} from '../../../../../../src/plugins/data/public'; - -interface State { - isLoading: boolean; - isSaveModalVisible: boolean; - indexPatternsForTopNav: IndexPatternInstance[]; - persistedDoc?: Document; - lastKnownDoc?: Document; - - // Properties needed to interface with TopNav - dateRange: { - fromDate: string; - toDate: string; - }; - query: Query; - filters: Filter[]; - savedQuery?: SavedQuery; -} - -export function App({ - editorFrame, - data, - core, - storage, - docId, - docStorage, - redirectTo, - addToDashboardMode, -}: { - editorFrame: EditorFrameInstance; - data: DataPublicPluginStart; - core: AppMountContext['core']; - storage: IStorageWrapper; - docId?: string; - docStorage: SavedObjectStore; - redirectTo: (id?: string) => void; - addToDashboardMode?: boolean; -}) { - const language = - storage.get('kibana.userQueryLanguage') || core.uiSettings.get('search:queryLanguage'); - - const [state, setState] = useState(() => { - const currentRange = data.query.timefilter.timefilter.getTime(); - return { - isLoading: !!docId, - isSaveModalVisible: false, - indexPatternsForTopNav: [], - query: { query: '', language }, - dateRange: { - fromDate: currentRange.from, - toDate: currentRange.to, - }, - filters: [], - }; - }); - - const { lastKnownDoc } = state; - - useEffect(() => { - // Clear app-specific filters when navigating to Lens. Necessary because Lens - // can be loaded without a full page refresh - data.query.filterManager.setAppFilters([]); - - const filterSubscription = data.query.filterManager.getUpdates$().subscribe({ - next: () => { - setState(s => ({ ...s, filters: data.query.filterManager.getFilters() })); - trackUiEvent('app_filters_updated'); - }, - }); - - const timeSubscription = data.query.timefilter.timefilter.getTimeUpdate$().subscribe({ - next: () => { - const currentRange = data.query.timefilter.timefilter.getTime(); - setState(s => ({ - ...s, - dateRange: { - fromDate: currentRange.from, - toDate: currentRange.to, - }, - })); - }, - }); - - return () => { - filterSubscription.unsubscribe(); - timeSubscription.unsubscribe(); - }; - }, []); - - // Sync Kibana breadcrumbs any time the saved document's title changes - useEffect(() => { - core.chrome.setBreadcrumbs([ - { - href: core.http.basePath.prepend(`/app/kibana#/visualize`), - text: i18n.translate('xpack.lens.breadcrumbsTitle', { - defaultMessage: 'Visualize', - }), - }, - { - text: state.persistedDoc - ? state.persistedDoc.title - : i18n.translate('xpack.lens.breadcrumbsCreate', { defaultMessage: 'Create' }), - }, - ]); - }, [state.persistedDoc && state.persistedDoc.title]); - - useEffect(() => { - if (docId && (!state.persistedDoc || state.persistedDoc.id !== docId)) { - setState(s => ({ ...s, isLoading: true })); - docStorage - .load(docId) - .then(doc => { - getAllIndexPatterns( - doc.state.datasourceMetaData.filterableIndexPatterns, - data.indexPatterns, - core.notifications - ) - .then(indexPatterns => { - // Don't overwrite any pinned filters - data.query.filterManager.setAppFilters(doc.state.filters); - setState(s => ({ - ...s, - isLoading: false, - persistedDoc: doc, - lastKnownDoc: doc, - query: doc.state.query, - indexPatternsForTopNav: indexPatterns, - })); - }) - .catch(() => { - setState(s => ({ ...s, isLoading: false })); - - redirectTo(); - }); - }) - .catch(() => { - setState(s => ({ ...s, isLoading: false })); - - core.notifications.toasts.addDanger( - i18n.translate('xpack.lens.app.docLoadingError', { - defaultMessage: 'Error loading saved document', - }) - ); - - redirectTo(); - }); - } - }, [docId]); - - const isSaveable = - lastKnownDoc && - lastKnownDoc.expression && - lastKnownDoc.expression.length > 0 && - core.application.capabilities.visualize.save; - - const onError = useCallback( - (e: { message: string }) => - core.notifications.toasts.addDanger({ - title: e.message, - }), - [] - ); - - const { TopNavMenu } = npStart.plugins.navigation.ui; - - const confirmButton = addToDashboardMode ? ( - - ) : null; - - return ( - - -
-
- { - if (isSaveable && lastKnownDoc) { - setState(s => ({ ...s, isSaveModalVisible: true })); - } - }, - testId: 'lnsApp_saveButton', - disableButton: !isSaveable, - }, - ]} - data-test-subj="lnsApp_topNav" - screenTitle={'lens'} - onQuerySubmit={payload => { - const { dateRange, query } = payload; - - if ( - dateRange.from !== state.dateRange.fromDate || - dateRange.to !== state.dateRange.toDate - ) { - data.query.timefilter.timefilter.setTime(dateRange); - trackUiEvent('app_date_change'); - } else { - trackUiEvent('app_query_change'); - } - - setState(s => ({ - ...s, - dateRange: { - fromDate: dateRange.from, - toDate: dateRange.to, - }, - query: query || s.query, - })); - }} - appName={'lens'} - indexPatterns={state.indexPatternsForTopNav} - showSearchBar={true} - showDatePicker={true} - showQueryBar={true} - showFilterBar={true} - showSaveQuery={core.application.capabilities.visualize.saveQuery as boolean} - savedQuery={state.savedQuery} - onSaved={savedQuery => { - setState(s => ({ ...s, savedQuery })); - }} - onSavedQueryUpdated={savedQuery => { - const savedQueryFilters = savedQuery.attributes.filters || []; - const globalFilters = data.query.filterManager.getGlobalFilters(); - data.query.filterManager.setFilters([...globalFilters, ...savedQueryFilters]); - setState(s => ({ - ...s, - savedQuery: { ...savedQuery }, // Shallow query for reference issues - dateRange: savedQuery.attributes.timefilter - ? { - fromDate: savedQuery.attributes.timefilter.from, - toDate: savedQuery.attributes.timefilter.to, - } - : s.dateRange, - })); - }} - onClearSavedQuery={() => { - data.query.filterManager.setFilters(data.query.filterManager.getGlobalFilters()); - setState(s => ({ - ...s, - savedQuery: undefined, - filters: data.query.filterManager.getGlobalFilters(), - query: { - query: '', - language: - storage.get('kibana.userQueryLanguage') || - core.uiSettings.get('search:queryLanguage'), - }, - })); - }} - query={state.query} - dateRangeFrom={state.dateRange.fromDate} - dateRangeTo={state.dateRange.toDate} - /> -
- - {(!state.isLoading || state.persistedDoc) && ( - { - if (!_.isEqual(state.persistedDoc, doc)) { - setState(s => ({ ...s, lastKnownDoc: doc })); - } - - // Update the cached index patterns if the user made a change to any of them - if ( - state.indexPatternsForTopNav.length !== filterableIndexPatterns.length || - filterableIndexPatterns.find( - ({ id }) => - !state.indexPatternsForTopNav.find(indexPattern => indexPattern.id === id) - ) - ) { - getAllIndexPatterns( - filterableIndexPatterns, - data.indexPatterns, - core.notifications - ).then(indexPatterns => { - if (indexPatterns) { - setState(s => ({ ...s, indexPatternsForTopNav: indexPatterns })); - } - }); - } - }, - }} - /> - )} -
- {lastKnownDoc && state.isSaveModalVisible && ( - { - const [pinnedFilters, appFilters] = _.partition( - lastKnownDoc.state?.filters, - esFilters.isFilterPinned - ); - const lastDocWithoutPinned = pinnedFilters?.length - ? { - ...lastKnownDoc, - state: { - ...lastKnownDoc.state, - filters: appFilters, - }, - } - : lastKnownDoc; - - const doc = { - ...lastDocWithoutPinned, - id: props.newCopyOnSave ? undefined : lastKnownDoc.id, - title: props.newTitle, - }; - - docStorage - .save(doc) - .then(({ id }) => { - // Prevents unnecessary network request and disables save button - const newDoc = { ...doc, id }; - setState(s => ({ - ...s, - isSaveModalVisible: false, - persistedDoc: newDoc, - lastKnownDoc: newDoc, - })); - if (docId !== id) { - redirectTo(id); - } - }) - .catch(e => { - // eslint-disable-next-line no-console - console.dir(e); - trackUiEvent('save_failed'); - core.notifications.toasts.addDanger( - i18n.translate('xpack.lens.app.docSavingError', { - defaultMessage: 'Error saving document', - }) - ); - setState(s => ({ ...s, isSaveModalVisible: false })); - }); - }} - onClose={() => setState(s => ({ ...s, isSaveModalVisible: false }))} - title={lastKnownDoc.title || ''} - showCopyOnSave={!addToDashboardMode} - objectType={i18n.translate('xpack.lens.app.saveModalType', { - defaultMessage: 'Lens visualization', - })} - showDescription={false} - confirmButtonLabel={confirmButton} - /> - )} -
-
- ); -} - -export async function getAllIndexPatterns( - ids: Array<{ id: string }>, - indexPatternsService: IndexPatternsContract, - notifications: NotificationsStart -): Promise { - try { - return await Promise.all(ids.map(({ id }) => indexPatternsService.get(id))); - } catch (e) { - notifications.toasts.addDanger( - i18n.translate('xpack.lens.app.indexPatternLoadingError', { - defaultMessage: 'Error loading index patterns', - }) - ); - - throw new Error(e); - } -} diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization/_index.scss b/x-pack/legacy/plugins/lens/public/datatable_visualization/_index.scss deleted file mode 100644 index 99c357b53952f..0000000000000 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './visualization'; diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization/index.ts b/x-pack/legacy/plugins/lens/public/datatable_visualization/index.ts deleted file mode 100644 index 7a54a5daa3095..0000000000000 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization/index.ts +++ /dev/null @@ -1,31 +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 { CoreSetup } from 'src/core/public'; -import { datatableVisualization } from './visualization'; -import { ExpressionsSetup } from '../../../../../../src/plugins/expressions/public'; -import { datatable, datatableColumns, getDatatableRenderer } from './expression'; -import { EditorFrameSetup, FormatFactory } from '../types'; - -export interface DatatableVisualizationPluginSetupPlugins { - expressions: ExpressionsSetup; - formatFactory: Promise; - editorFrame: EditorFrameSetup; -} - -export class DatatableVisualization { - constructor() {} - - setup( - _core: CoreSetup | null, - { expressions, formatFactory, editorFrame }: DatatableVisualizationPluginSetupPlugins - ) { - expressions.registerFunction(() => datatableColumns); - expressions.registerFunction(() => datatable); - expressions.registerRenderer(() => getDatatableRenderer(formatFactory)); - editorFrame.registerVisualization(datatableVisualization); - } -} diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/_index.scss b/x-pack/legacy/plugins/lens/public/drag_drop/_index.scss deleted file mode 100644 index 1b3d0cf0a3c2a..0000000000000 --- a/x-pack/legacy/plugins/lens/public/drag_drop/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './drag_drop' diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/_index.scss b/x-pack/legacy/plugins/lens/public/editor_frame_service/_index.scss deleted file mode 100644 index 4d7e054ff03c3..0000000000000 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './editor_frame/index'; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/index.scss b/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/index.scss deleted file mode 100644 index 6c6a63c8c7eb6..0000000000000 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/index.scss +++ /dev/null @@ -1,8 +0,0 @@ -@import './chart_switch'; -@import './config_panel_wrapper'; -@import './data_panel_wrapper'; -@import './expression_renderer'; -@import './frame_layout'; -@import './suggestion_panel'; -@import './workspace_panel_wrapper'; - diff --git a/x-pack/legacy/plugins/lens/public/help_menu_util.tsx b/x-pack/legacy/plugins/lens/public/help_menu_util.tsx deleted file mode 100644 index 9ead31690e854..0000000000000 --- a/x-pack/legacy/plugins/lens/public/help_menu_util.tsx +++ /dev/null @@ -1,25 +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 { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; -import { ChromeStart } from 'kibana/public'; - -export function addHelpMenuToAppChrome(chrome: ChromeStart) { - chrome.setHelpExtension({ - appName: 'Lens', - links: [ - { - linkType: 'documentation', - href: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/lens.html`, - }, - { - linkType: 'github', - title: '[Lens]', - labels: ['Feature:Lens'], - }, - ], - }); -} diff --git a/x-pack/legacy/plugins/lens/public/helpers/url_helper.test.ts b/x-pack/legacy/plugins/lens/public/helpers/url_helper.test.ts deleted file mode 100644 index ef960fb52952b..0000000000000 --- a/x-pack/legacy/plugins/lens/public/helpers/url_helper.test.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. - */ - -jest.mock('../../../../../../src/plugins/dashboard/public', () => ({ - DashboardConstants: { - ADD_EMBEDDABLE_ID: 'addEmbeddableId', - ADD_EMBEDDABLE_TYPE: 'addEmbeddableType', - }, -})); - -import { addEmbeddableToDashboardUrl, getUrlVars } from './url_helper'; - -describe('Dashboard URL Helper', () => { - it('addEmbeddableToDashboardUrl', () => { - const id = '123eb456cd'; - const urlVars = { - x: '1', - y: '2', - z: '3', - }; - const url = - "/pep/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!())"; - expect(addEmbeddableToDashboardUrl(url, id, urlVars)).toEqual( - `/pep/app/kibana#/dashboard?_a=%28description%3A%27%27%2Cfilters%3A%21%28%29%29&_g=%28refreshInterval%3A%28pause%3A%21t%2Cvalue%3A0%29%2Ctime%3A%28from%3Anow-15m%2Cto%3Anow%29%29&addEmbeddableId=${id}&addEmbeddableType=lens&x=1&y=2&z=3` - ); - }); - - it('getUrlVars', () => { - let url = - "http://localhost:5601/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!()"; - expect(getUrlVars(url)).toEqual({ - _g: '(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))', - _a: "(description:'',filters:!()", - }); - url = 'http://mybusiness.mydomain.com/app/kibana#/dashboard?x=y&y=z'; - expect(getUrlVars(url)).toEqual({ - x: 'y', - y: 'z', - }); - url = 'http://localhost:5601/app/kibana#/dashboard/777182'; - expect(getUrlVars(url)).toEqual({}); - url = - 'http://localhost:5601/app/kibana#/dashboard/777182?title=Some%20Dashboard%20With%20Spaces'; - expect(getUrlVars(url)).toEqual({ title: 'Some Dashboard With Spaces' }); - }); -}); diff --git a/x-pack/legacy/plugins/lens/public/helpers/url_helper.ts b/x-pack/legacy/plugins/lens/public/helpers/url_helper.ts deleted file mode 100644 index 3495c15118ce7..0000000000000 --- a/x-pack/legacy/plugins/lens/public/helpers/url_helper.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { parseUrl, stringify } from 'query-string'; -import { DashboardConstants } from '../../../../../../src/plugins/dashboard/public'; - -type UrlVars = Record; - -/** - * Return query params from URL - * @param url given url - */ -export function getUrlVars(url: string): Record { - const vars: UrlVars = {}; - for (const [, key, value] of url.matchAll(/[?&]+([^=&]+)=([^&]*)/gi)) { - vars[key] = decodeURIComponent(value); - } - return vars; -} - -/** * - * Returns dashboard URL with added embeddableType and embeddableId query params - * eg. - * input: url: /lol/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)), embeddableId: 12345 - * output: /lol/app/kibana#/dashboard?addEmbeddableType=lens&addEmbeddableId=12345&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)) - * @param url dasbhoard absolute url - * @param embeddableId id of the saved visualization - * @param urlVars url query params - */ -export function addEmbeddableToDashboardUrl(url: string, embeddableId: string, urlVars: UrlVars) { - const dashboardParsedUrl = parseUrl(url); - const keys = Object.keys(urlVars).sort(); - - keys.forEach(key => { - dashboardParsedUrl.query[key] = urlVars[key]; - }); - dashboardParsedUrl.query[DashboardConstants.ADD_EMBEDDABLE_TYPE] = 'lens'; - dashboardParsedUrl.query[DashboardConstants.ADD_EMBEDDABLE_ID] = embeddableId; - const query = stringify(dashboardParsedUrl.query); - - return `${dashboardParsedUrl.url}?${query}`; -} diff --git a/x-pack/legacy/plugins/lens/public/index.scss b/x-pack/legacy/plugins/lens/public/index.scss deleted file mode 100644 index 2f91d14c397c7..0000000000000 --- a/x-pack/legacy/plugins/lens/public/index.scss +++ /dev/null @@ -1,13 +0,0 @@ -// Import the EUI global scope so we can use EUI constants -@import 'src/legacy/ui/public/styles/_styling_constants'; - -@import './variables'; -@import './mixins'; - -@import './app_plugin/index'; -@import 'datatable_visualization/index'; -@import './drag_drop/index'; -@import 'editor_frame_service/index'; -@import 'indexpattern_datasource/index'; -@import 'xy_visualization/index'; -@import 'metric_visualization/index'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/_index.scss b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/_index.scss deleted file mode 100644 index a283198d6cf73..0000000000000 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/_index.scss +++ /dev/null @@ -1,4 +0,0 @@ -@import './datapanel'; -@import './field_item'; - -@import './dimension_panel/index'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/_index.scss b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/_index.scss deleted file mode 100644 index 26f805fe735f0..0000000000000 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/_index.scss +++ /dev/null @@ -1,2 +0,0 @@ -@import './field_select'; -@import './popover'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/index.ts b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/index.ts deleted file mode 100644 index 8a5c562ebd455..0000000000000 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/index.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 { CoreSetup } from 'src/core/public'; -import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; -import { getIndexPatternDatasource } from './indexpattern'; -import { renameColumns } from './rename_columns'; -import { getAutoDate } from './auto_date'; -import { ExpressionsSetup } from '../../../../../../src/plugins/expressions/public'; -import { - DataPublicPluginSetup, - DataPublicPluginStart, -} from '../../../../../../src/plugins/data/public'; -import { Datasource, EditorFrameSetup } from '../types'; - -export interface IndexPatternDatasourceSetupPlugins { - expressions: ExpressionsSetup; - data: DataPublicPluginSetup; - editorFrame: EditorFrameSetup; -} - -export interface IndexPatternDatasourceStartPlugins { - data: DataPublicPluginStart; -} - -export class IndexPatternDatasource { - constructor() {} - - setup( - core: CoreSetup, - { data: dataSetup, expressions, editorFrame }: IndexPatternDatasourceSetupPlugins - ) { - expressions.registerFunction(renameColumns); - expressions.registerFunction(getAutoDate({ data: dataSetup })); - - editorFrame.registerDatasource( - core.getStartServices().then(([coreStart, { data }]) => - getIndexPatternDatasource({ - core: coreStart, - storage: new Storage(localStorage), - data, - }) - ) as Promise - ); - } -} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts deleted file mode 100644 index 2db5296905000..0000000000000 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ /dev/null @@ -1,198 +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 { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'src/core/public'; -import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; -import { termsOperation } from './terms'; -import { cardinalityOperation } from './cardinality'; -import { minOperation, averageOperation, sumOperation, maxOperation } from './metrics'; -import { dateHistogramOperation } from './date_histogram'; -import { countOperation } from './count'; -import { DimensionPriority, StateSetter, OperationMetadata } from '../../../types'; -import { BaseIndexPatternColumn } from './column_types'; -import { IndexPatternPrivateState, IndexPattern, IndexPatternField } from '../../types'; -import { DateRange } from '../../../../../../../plugins/lens/common'; -import { DataPublicPluginStart } from '../../../../../../../../src/plugins/data/public'; - -// List of all operation definitions registered to this data source. -// If you want to implement a new operation, add it to this array and -// its type will get propagated to everything else -const internalOperationDefinitions = [ - termsOperation, - dateHistogramOperation, - minOperation, - maxOperation, - averageOperation, - cardinalityOperation, - sumOperation, - countOperation, -]; - -export { termsOperation } from './terms'; -export { dateHistogramOperation } from './date_histogram'; -export { minOperation, averageOperation, sumOperation, maxOperation } from './metrics'; -export { countOperation } from './count'; - -/** - * Properties passed to the operation-specific part of the popover editor - */ -export interface ParamEditorProps { - currentColumn: C; - state: IndexPatternPrivateState; - setState: StateSetter; - columnId: string; - layerId: string; - uiSettings: IUiSettingsClient; - storage: IStorageWrapper; - savedObjectsClient: SavedObjectsClientContract; - http: HttpSetup; - dateRange: DateRange; - data: DataPublicPluginStart; -} - -interface BaseOperationDefinitionProps { - type: C['operationType']; - /** - * The priority of the operation. If multiple operations are possible in - * a given scenario (e.g. the user dragged a field into the workspace), - * the operation with the highest priority is picked. - */ - priority?: number; - /** - * The name of the operation shown to the user (e.g. in the popover editor). - * Should be i18n-ified. - */ - displayName: string; - /** - * This function is called if another column in the same layer changed or got removed. - * Can be used to update references to other columns (e.g. for sorting). - * Based on the current column and the other updated columns, this function has to - * return an updated column. If not implemented, the `id` function is used instead. - */ - onOtherColumnChanged?: ( - currentColumn: C, - columns: Partial> - ) => C; - /** - * React component for operation specific settings shown in the popover editor - */ - paramEditor?: React.ComponentType>; - /** - * Function turning a column into an agg config passed to the `esaggs` function - * together with the agg configs returned from other columns. - */ - toEsAggsConfig: (column: C, columnId: string) => unknown; - /** - * Returns true if the `column` can also be used on `newIndexPattern`. - * If this function returns false, the column is removed when switching index pattern - * for a layer - */ - isTransferable: (column: C, newIndexPattern: IndexPattern) => boolean; - /** - * Transfering a column to another index pattern. This can be used to - * adjust operation specific settings such as reacting to aggregation restrictions - * present on the new index pattern. - */ - transfer?: (column: C, newIndexPattern: IndexPattern) => C; -} - -interface BaseBuildColumnArgs { - suggestedPriority: DimensionPriority | undefined; - layerId: string; - columns: Partial>; - indexPattern: IndexPattern; -} - -interface FieldBasedOperationDefinition - extends BaseOperationDefinitionProps { - /** - * Returns the meta data of the operation if applied to the given field. Undefined - * if the field is not applicable to the operation. - */ - getPossibleOperationForField: (field: IndexPatternField) => OperationMetadata | undefined; - /** - * Builds the column object for the given parameters. Should include default p - */ - buildColumn: ( - arg: BaseBuildColumnArgs & { - field: IndexPatternField; - previousColumn?: C; - } - ) => C; - /** - * This method will be called if the user changes the field of an operation. - * You must implement it and return the new column after the field change. - * The most simple implementation will just change the field on the column, and keep - * the rest the same. Some implementations might want to change labels, or their parameters - * when changing the field. - * - * This will only be called for switching the field, not for initially selecting a field. - * - * See {@link OperationDefinition#transfer} for controlling column building when switching an - * index pattern not just a field. - * - * @param oldColumn The column before the user changed the field. - * @param indexPattern The index pattern that field is on. - * @param field The field that the user changed to. - */ - onFieldChange: (oldColumn: C, indexPattern: IndexPattern, field: IndexPatternField) => C; -} - -/** - * Shape of an operation definition. If the type parameter of the definition - * indicates a field based column, `getPossibleOperationForField` has to be - * specified, otherwise `getPossibleOperationForDocument` has to be defined. - */ -export type OperationDefinition = FieldBasedOperationDefinition< - C ->; - -// Helper to to infer the column type out of the operation definition. -// This is done to avoid it to have to list out the column types along with -// the operation definition types -type ColumnFromOperationDefinition = D extends OperationDefinition ? C : never; - -/** - * A union type of all available column types. If a column is of an unknown type somewhere - * withing the indexpattern data source it should be typed as `IndexPatternColumn` to make - * typeguards possible that consider all available column types. - */ -export type IndexPatternColumn = ColumnFromOperationDefinition< - typeof internalOperationDefinitions[number] ->; - -/** - * A union type of all available operation types. The operation type is a unique id of an operation. - * Each column is assigned to exactly one operation type. - */ -export type OperationType = typeof internalOperationDefinitions[number]['type']; - -/** - * This is an operation definition of an unspecified column out of all possible - * column types. - */ -export type GenericOperationDefinition = FieldBasedOperationDefinition; - -/** - * List of all available operation definitions - */ -export const operationDefinitions = internalOperationDefinitions as GenericOperationDefinition[]; - -/** - * Map of all operation visible to consumers (e.g. the dimension panel). - * This simplifies the type of the map and makes it a simple list of unspecified - * operations definitions, because typescript can't infer the type correctly in most - * situations. - * - * If you need a specifically typed version of an operation (e.g. explicitly working with terms), - * you should import the definition directly from this file - * (e.g. `import { termsOperation } from './operations/definitions'`). This map is - * intended to be used in situations where the operation type is not known during compile time. - */ -export const operationDefinitionMap = internalOperationDefinitions.reduce( - (definitionMap, definition) => ({ ...definitionMap, [definition.type]: definition }), - {} -) as Record; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/to_expression.ts deleted file mode 100644 index 3747deaa6059b..0000000000000 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ /dev/null @@ -1,81 +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 _ from 'lodash'; -import { IndexPatternColumn } from './indexpattern'; -import { operationDefinitionMap } from './operations'; -import { IndexPattern, IndexPatternPrivateState } from './types'; -import { OriginalColumn } from './rename_columns'; - -function getExpressionForLayer( - indexPattern: IndexPattern, - columns: Record, - columnOrder: string[] -) { - if (columnOrder.length === 0) { - return null; - } - - function getEsAggsConfig(column: C, columnId: string) { - return operationDefinitionMap[column.operationType].toEsAggsConfig(column, columnId); - } - - const columnEntries = columnOrder.map(colId => [colId, columns[colId]] as const); - - if (columnEntries.length) { - const aggs = columnEntries.map(([colId, col]) => { - return getEsAggsConfig(col, colId); - }); - - const idMap = columnEntries.reduce((currentIdMap, [colId], index) => { - return { - ...currentIdMap, - [`col-${index}-${colId}`]: { - ...columns[colId], - id: colId, - }, - }; - }, {} as Record); - - const formatterOverrides = columnEntries - .map(([id, col]) => { - const format = col.params && 'format' in col.params ? col.params.format : undefined; - if (!format) { - return null; - } - const base = `| lens_format_column format="${format.id}" columnId="${id}"`; - if (typeof format.params?.decimals === 'number') { - return base + ` decimals=${format.params.decimals}`; - } - return base; - }) - .filter(expr => !!expr) - .join(' '); - - return `esaggs - index="${indexPattern.id}" - metricsAtAllLevels=false - partialRows=false - includeFormatHints=true - aggConfigs={lens_auto_date aggConfigs='${JSON.stringify( - aggs - )}'} | lens_rename_columns idMap='${JSON.stringify(idMap)}' ${formatterOverrides}`; - } - - return null; -} - -export function toExpression(state: IndexPatternPrivateState, layerId: string) { - if (state.layers[layerId]) { - return getExpressionForLayer( - state.indexPatterns[state.layers[layerId].indexPatternId], - state.layers[layerId].columns, - state.layers[layerId].columnOrder - ); - } - - return null; -} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/types.ts deleted file mode 100644 index 3820ff3b387bb..0000000000000 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/types.ts +++ /dev/null @@ -1,60 +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 { IndexPatternColumn } from './operations'; -import { IndexPatternAggRestrictions } from '../../../../../../src/plugins/data/public'; - -export interface IndexPattern { - id: string; - fields: IndexPatternField[]; - title: string; - timeFieldName?: string | null; - fieldFormatMap?: Record< - string, - { - id: string; - params: unknown; - } - >; -} - -export interface IndexPatternField { - name: string; - type: string; - esTypes?: string[]; - aggregatable: boolean; - scripted?: boolean; - searchable: boolean; - aggregationRestrictions?: Partial; -} - -export interface IndexPatternLayer { - columnOrder: string[]; - columns: Record; - // Each layer is tied to the index pattern that created it - indexPatternId: string; -} - -export interface IndexPatternPersistedState { - currentIndexPatternId: string; - layers: Record; -} - -export type IndexPatternPrivateState = IndexPatternPersistedState & { - indexPatternRefs: IndexPatternRef[]; - indexPatterns: Record; - - /** - * indexPatternId -> fieldName -> boolean - */ - existingFields: Record>; - showEmptyFields: boolean; -}; - -export interface IndexPatternRef { - id: string; - title: string; -} diff --git a/x-pack/legacy/plugins/lens/public/legacy.ts b/x-pack/legacy/plugins/lens/public/legacy.ts deleted file mode 100644 index 3b7b6a7a1b510..0000000000000 --- a/x-pack/legacy/plugins/lens/public/legacy.ts +++ /dev/null @@ -1,17 +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 { npSetup, npStart } from 'ui/new_platform'; - -export * from './types'; - -import { plugin } from './index'; - -const pluginInstance = plugin(); -pluginInstance.setup(npSetup.core, { - ...npSetup.plugins, -}); -pluginInstance.start(npStart.core, npStart.plugins); diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/index.ts b/x-pack/legacy/plugins/lens/public/metric_visualization/index.ts deleted file mode 100644 index 65f064258a5e2..0000000000000 --- a/x-pack/legacy/plugins/lens/public/metric_visualization/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { CoreSetup } from 'src/core/public'; -import { metricVisualization } from './metric_visualization'; -import { ExpressionsSetup } from '../../../../../../src/plugins/expressions/public'; -import { metricChart, getMetricChartRenderer } from './metric_expression'; -import { EditorFrameSetup, FormatFactory } from '../types'; - -export interface MetricVisualizationPluginSetupPlugins { - expressions: ExpressionsSetup; - formatFactory: Promise; - editorFrame: EditorFrameSetup; -} - -export class MetricVisualization { - constructor() {} - - setup( - _core: CoreSetup | null, - { expressions, formatFactory, editorFrame }: MetricVisualizationPluginSetupPlugins - ) { - expressions.registerFunction(() => metricChart); - - expressions.registerRenderer(() => getMetricChartRenderer(formatFactory)); - - editorFrame.registerVisualization(metricVisualization); - } -} diff --git a/x-pack/legacy/plugins/lens/public/plugin.tsx b/x-pack/legacy/plugins/lens/public/plugin.tsx deleted file mode 100644 index b426a12d07f9b..0000000000000 --- a/x-pack/legacy/plugins/lens/public/plugin.tsx +++ /dev/null @@ -1,203 +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 React from 'react'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; -import { HashRouter, Route, RouteComponentProps, Switch } from 'react-router-dom'; -import { render, unmountComponentAtNode } from 'react-dom'; -import rison, { RisonObject, RisonValue } from 'rison-node'; -import { isObject } from 'lodash'; - -import { AppMountParameters, CoreSetup, CoreStart } from 'src/core/public'; -import { DataPublicPluginSetup, DataPublicPluginStart } from 'src/plugins/data/public'; -import { EmbeddableSetup, EmbeddableStart } from 'src/plugins/embeddable/public'; -import { ExpressionsSetup, ExpressionsStart } from 'src/plugins/expressions/public'; -import { VisualizationsSetup } from 'src/plugins/visualizations/public'; -import { KibanaLegacySetup } from 'src/plugins/kibana_legacy/public'; -import { DashboardConstants } from '../../../../../src/plugins/dashboard/public'; -import { Storage } from '../../../../../src/plugins/kibana_utils/public'; -import { EditorFrameService } from './editor_frame_service'; -import { IndexPatternDatasource } from './indexpattern_datasource'; -import { addHelpMenuToAppChrome } from './help_menu_util'; -import { SavedObjectIndexStore } from './persistence'; -import { XyVisualization } from './xy_visualization'; -import { MetricVisualization } from './metric_visualization'; -import { DatatableVisualization } from './datatable_visualization'; -import { App } from './app_plugin'; -import { - LensReportManager, - setReportManager, - stopReportManager, - trackUiEvent, -} from './lens_ui_telemetry'; - -import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; -import { NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../../../../plugins/lens/common'; -import { addEmbeddableToDashboardUrl, getUrlVars } from './helpers'; -import { EditorFrameStart } from './types'; -import { getLensAliasConfig } from './vis_type_alias'; - -export interface LensPluginSetupDependencies { - kibanaLegacy: KibanaLegacySetup; - expressions: ExpressionsSetup; - data: DataPublicPluginSetup; - embeddable: EmbeddableSetup; - visualizations: VisualizationsSetup; -} - -export interface LensPluginStartDependencies { - data: DataPublicPluginStart; - embeddable: EmbeddableStart; - expressions: ExpressionsStart; - uiActions: UiActionsStart; -} - -export const isRisonObject = (value: RisonValue): value is RisonObject => { - return isObject(value); -}; -export class LensPlugin { - private datatableVisualization: DatatableVisualization; - private editorFrameService: EditorFrameService; - private createEditorFrame: EditorFrameStart['createInstance'] | null = null; - private indexpatternDatasource: IndexPatternDatasource; - private xyVisualization: XyVisualization; - private metricVisualization: MetricVisualization; - - constructor() { - this.datatableVisualization = new DatatableVisualization(); - this.editorFrameService = new EditorFrameService(); - this.indexpatternDatasource = new IndexPatternDatasource(); - this.xyVisualization = new XyVisualization(); - this.metricVisualization = new MetricVisualization(); - } - - setup( - core: CoreSetup, - { kibanaLegacy, expressions, data, embeddable, visualizations }: LensPluginSetupDependencies - ) { - const editorFrameSetupInterface = this.editorFrameService.setup(core, { - data, - embeddable, - expressions, - }); - const dependencies = { - expressions, - data, - editorFrame: editorFrameSetupInterface, - formatFactory: core - .getStartServices() - .then(([_, { data: dataStart }]) => dataStart.fieldFormats.deserialize), - }; - this.indexpatternDatasource.setup(core, dependencies); - this.xyVisualization.setup(core, dependencies); - this.datatableVisualization.setup(core, dependencies); - this.metricVisualization.setup(core, dependencies); - - visualizations.registerAlias(getLensAliasConfig()); - - kibanaLegacy.registerLegacyApp({ - id: 'lens', - title: NOT_INTERNATIONALIZED_PRODUCT_NAME, - mount: async (params: AppMountParameters) => { - const [coreStart, startDependencies] = await core.getStartServices(); - const dataStart = startDependencies.data; - const savedObjectsClient = coreStart.savedObjects.client; - addHelpMenuToAppChrome(coreStart.chrome); - - const instance = await this.createEditorFrame!(); - - setReportManager( - new LensReportManager({ - storage: new Storage(localStorage), - http: core.http, - }) - ); - const updateUrlTime = (urlVars: Record): void => { - const decoded = rison.decode(urlVars._g); - if (!isRisonObject(decoded)) { - return; - } - // @ts-ignore - decoded.time = dataStart.query.timefilter.timefilter.getTime(); - urlVars._g = rison.encode(decoded); - }; - const redirectTo = ( - routeProps: RouteComponentProps<{ id?: string }>, - addToDashboardMode: boolean, - id?: string - ) => { - if (!id) { - routeProps.history.push('/lens'); - } else if (!addToDashboardMode) { - routeProps.history.push(`/lens/edit/${id}`); - } else if (addToDashboardMode && id) { - routeProps.history.push(`/lens/edit/${id}`); - const lastDashboardLink = coreStart.chrome.navLinks.get('kibana:dashboard'); - if (!lastDashboardLink || !lastDashboardLink.url) { - throw new Error('Cannot get last dashboard url'); - } - const urlVars = getUrlVars(lastDashboardLink.url); - updateUrlTime(urlVars); // we need to pass in timerange in query params directly - const dashboardUrl = addEmbeddableToDashboardUrl(lastDashboardLink.url, id, urlVars); - window.history.pushState({}, '', dashboardUrl); - } - }; - - const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => { - trackUiEvent('loaded'); - const addToDashboardMode = - !!routeProps.location.search && - routeProps.location.search.includes( - DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM - ); - return ( - redirectTo(routeProps, addToDashboardMode, id)} - addToDashboardMode={addToDashboardMode} - /> - ); - }; - - function NotFound() { - trackUiEvent('loaded_404'); - return ; - } - - render( - - - - - - - - - , - params.element - ); - return () => { - instance.unmount(); - unmountComponentAtNode(params.element); - }; - }, - }); - } - - start(core: CoreStart, startDependencies: LensPluginStartDependencies) { - this.createEditorFrame = this.editorFrameService.start(core, startDependencies).createInstance; - this.xyVisualization.start(core, startDependencies); - } - - stop() { - stopReportManager(); - } -} diff --git a/x-pack/legacy/plugins/lens/public/redirect.ts b/x-pack/legacy/plugins/lens/public/redirect.ts deleted file mode 100644 index 25b0188214c5e..0000000000000 --- a/x-pack/legacy/plugins/lens/public/redirect.ts +++ /dev/null @@ -1,19 +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. - */ - -// This file redirects lens urls starting with app/lens#... to their counterpart on app/kibana#lens/... to -// make sure it's compatible with the 7.5 release - -import { npSetup } from 'ui/new_platform'; -import chrome from 'ui/chrome'; - -chrome.setRootController('lens', () => { - // prefix the path in the hash with lens/ - const prefixedHashRoute = window.location.hash.replace(/^#\//, '#/lens/'); - - // redirect to the new lens url `app/kibana#/lens/...` - window.location.href = npSetup.core.http.basePath.prepend('/app/kibana' + prefixedHashRoute); -}); diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts deleted file mode 100644 index 3d67b7b2da460..0000000000000 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ /dev/null @@ -1,425 +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 { Ast } from '@kbn/interpreter/common'; -import { IconType } from '@elastic/eui/src/components/icon/icon'; -import { CoreSetup } from 'src/core/public'; -import { - KibanaDatatable, - SerializedFieldFormat, -} from '../../../../../src/plugins/expressions/public'; -import { DragContextState } from './drag_drop'; -import { Document } from './persistence'; -import { DateRange } from '../../../../plugins/lens/common'; -import { Query, Filter, SavedQuery, IFieldFormat } from '../../../../../src/plugins/data/public'; - -export type ErrorCallback = (e: { message: string }) => void; - -export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat; - -export interface PublicAPIProps { - state: T; - layerId: string; - dateRange: DateRange; -} - -export interface EditorFrameProps { - onError: ErrorCallback; - doc?: Document; - dateRange: DateRange; - query: Query; - filters: Filter[]; - savedQuery?: SavedQuery; - - // Frame loader (app or embeddable) is expected to call this when it loads and updates - // This should be replaced with a top-down state - onChange: (newState: { - filterableIndexPatterns: DatasourceMetaData['filterableIndexPatterns']; - doc: Document; - }) => void; -} -export interface EditorFrameInstance { - mount: (element: Element, props: EditorFrameProps) => void; - unmount: () => void; -} - -export interface EditorFrameSetup { - // generic type on the API functions to pull the "unknown vs. specific type" error into the implementation - registerDatasource: (datasource: Datasource | Promise>) => void; - registerVisualization: ( - visualization: Visualization | Promise> - ) => void; -} - -export interface EditorFrameStart { - createInstance: () => Promise; -} - -// Hints the default nesting to the data source. 0 is the highest priority -export type DimensionPriority = 0 | 1 | 2; - -export interface TableSuggestionColumn { - columnId: string; - operation: Operation; -} - -/** - * A possible table a datasource can create. This object is passed to the visualization - * which tries to build a meaningful visualization given the shape of the table. If this - * is possible, the visualization returns a `VisualizationSuggestion` object - */ -export interface TableSuggestion { - /** - * Flag indicating whether the table will include more than one column. - * This is not the case for example for a single metric aggregation - * */ - isMultiRow: boolean; - /** - * The columns of the table. Each column has to be mapped to a dimension in a chart. If a visualization - * can't use all columns of a suggestion, it should not return a `VisualizationSuggestion` based on it - * because there would be unreferenced columns - */ - columns: TableSuggestionColumn[]; - /** - * The layer this table will replace. This is only relevant if the visualization this suggestion is passed - * is currently active and has multiple layers configured. If this suggestion is applied, the table of this - * layer will be replaced by the columns specified in this suggestion - */ - layerId: string; - /** - * A label describing the table. This can be used to provide a title for the `VisualizationSuggestion`, - * but the visualization can also decide to overwrite it. - */ - label?: string; - /** - * The change type indicates what was changed in this table compared to the currently active table of this layer. - */ - changeType: TableChangeType; -} - -/** - * Indicates what was changed in this table compared to the currently active table of this layer. - * * `initial` means the layer associated with this table does not exist in the current configuration - * * `unchanged` means the table is the same in the currently active configuration - * * `reduced` means the table is a reduced version of the currently active table (some columns dropped, but not all of them) - * * `extended` means the table is an extended version of the currently active table (added one or multiple additional columns) - * * `layers` means the change is a change to the layer structure, not to the table - */ -export type TableChangeType = 'initial' | 'unchanged' | 'reduced' | 'extended' | 'layers'; - -export interface DatasourceSuggestion { - state: T; - table: TableSuggestion; - keptLayerIds: string[]; -} - -export interface DatasourceMetaData { - filterableIndexPatterns: Array<{ id: string; title: string }>; -} - -export type StateSetter = (newState: T | ((prevState: T) => T)) => void; - -/** - * Interface for the datasource registry - */ -export interface Datasource { - id: string; - - // For initializing, either from an empty state or from persisted state - // Because this will be called at runtime, state might have a type of `any` and - // datasources should validate their arguments - initialize: (state?: P) => Promise; - - // Given the current state, which parts should be saved? - getPersistableState: (state: T) => P; - - insertLayer: (state: T, newLayerId: string) => T; - removeLayer: (state: T, layerId: string) => T; - clearLayer: (state: T, layerId: string) => T; - getLayers: (state: T) => string[]; - removeColumn: (props: { prevState: T; layerId: string; columnId: string }) => T; - - renderDataPanel: (domElement: Element, props: DatasourceDataPanelProps) => void; - renderDimensionTrigger: (domElement: Element, props: DatasourceDimensionTriggerProps) => void; - renderDimensionEditor: (domElement: Element, props: DatasourceDimensionEditorProps) => void; - renderLayerPanel: (domElement: Element, props: DatasourceLayerPanelProps) => void; - canHandleDrop: (props: DatasourceDimensionDropProps) => boolean; - onDrop: (props: DatasourceDimensionDropHandlerProps) => boolean; - - toExpression: (state: T, layerId: string) => Ast | string | null; - - getMetaData: (state: T) => DatasourceMetaData; - - getDatasourceSuggestionsForField: (state: T, field: unknown) => Array>; - getDatasourceSuggestionsFromCurrentState: (state: T) => Array>; - - getPublicAPI: (props: PublicAPIProps) => DatasourcePublicAPI; -} - -/** - * This is an API provided to visualizations by the frame, which calls the publicAPI on the datasource - */ -export interface DatasourcePublicAPI { - datasourceId: string; - getTableSpec: () => Array<{ columnId: string }>; - getOperationForColumnId: (columnId: string) => Operation | null; -} - -export interface DatasourceDataPanelProps { - state: T; - dragDropContext: DragContextState; - setState: StateSetter; - core: Pick; - query: Query; - dateRange: DateRange; - filters: Filter[]; -} - -interface SharedDimensionProps { - /** Visualizations can restrict operations based on their own rules. - * For example, limiting to only bucketed or only numeric operations. - */ - filterOperations: (operation: OperationMetadata) => boolean; - - /** Visualizations can hint at the role this dimension would play, which - * affects the default ordering of the query - */ - suggestedPriority?: DimensionPriority; - - /** Some dimension editors will allow users to change the operation grouping - * from the panel, and this lets the visualization hint that it doesn't want - * users to have that level of control - */ - hideGrouping?: boolean; -} - -export type DatasourceDimensionProps = SharedDimensionProps & { - layerId: string; - columnId: string; - onRemove?: (accessor: string) => void; - state: T; -}; - -// The only way a visualization has to restrict the query building -export type DatasourceDimensionEditorProps = DatasourceDimensionProps & { - setState: StateSetter; - core: Pick; - dateRange: DateRange; -}; - -export type DatasourceDimensionTriggerProps = DatasourceDimensionProps & { - dragDropContext: DragContextState; - togglePopover: () => void; -}; - -export interface DatasourceLayerPanelProps { - layerId: string; - state: T; - setState: StateSetter; -} - -export type DatasourceDimensionDropProps = SharedDimensionProps & { - layerId: string; - columnId: string; - state: T; - setState: StateSetter; - dragDropContext: DragContextState; -}; - -export type DatasourceDimensionDropHandlerProps = DatasourceDimensionDropProps & { - droppedItem: unknown; -}; - -export type DataType = 'document' | 'string' | 'number' | 'date' | 'boolean' | 'ip'; - -// An operation represents a column in a table, not any information -// about how the column was created such as whether it is a sum or average. -// Visualizations are able to filter based on the output, not based on the -// underlying data -export interface Operation extends OperationMetadata { - // User-facing label for the operation - label: string; -} - -export interface OperationMetadata { - // The output of this operation will have this data type - dataType: DataType; - // A bucketed operation is grouped by duplicate values, otherwise each row is - // treated as unique - isBucketed: boolean; - scale?: 'ordinal' | 'interval' | 'ratio'; - // Extra meta-information like cardinality, color - // TODO currently it's not possible to differentiate between a field from a raw - // document and an aggregated metric which might be handy in some cases. Once we - // introduce a raw document datasource, this should be considered here. -} - -export interface LensMultiTable { - type: 'lens_multitable'; - tables: Record; - dateRange?: { - fromDate: Date; - toDate: Date; - }; -} - -export interface VisualizationConfigProps { - layerId: string; - frame: FramePublicAPI; - state: T; -} - -export type VisualizationLayerWidgetProps = VisualizationConfigProps & { - setState: (newState: T) => void; -}; - -type VisualizationDimensionGroupConfig = SharedDimensionProps & { - groupLabel: string; - - /** ID is passed back to visualization. For example, `x` */ - groupId: string; - accessors: string[]; - supportsMoreColumns: boolean; - /** If required, a warning will appear if accessors are empty */ - required?: boolean; - dataTestSubj?: string; -}; - -interface VisualizationDimensionChangeProps { - layerId: string; - columnId: string; - prevState: T; -} - -/** - * Object passed to `getSuggestions` of a visualization. - * It contains a possible table the current datasource could - * provide and the state of the visualization if it is currently active. - * - * If the current datasource suggests multiple tables, `getSuggestions` - * is called multiple times with separate `SuggestionRequest` objects. - */ -export interface SuggestionRequest { - /** - * A table configuration the datasource could provide. - */ - table: TableSuggestion; - /** - * State is only passed if the visualization is active. - */ - state?: T; - /** - * The visualization needs to know which table is being suggested - */ - keptLayerIds: string[]; -} - -/** - * A possible configuration of a given visualization. It is based on a `TableSuggestion`. - * Suggestion might be shown in the UI to be chosen by the user directly, but they are - * also applied directly under some circumstances (dragging in the first field from the data - * panel or switching to another visualization in the chart switcher). - */ -export interface VisualizationSuggestion { - /** - * The score of a suggestion should indicate how valuable the suggestion is. It is used - * to rank multiple suggestions of multiple visualizations. The number should be between 0 and 1 - */ - score: number; - /** - * Flag indicating whether this suggestion should not be advertised to the user. It is still - * considered in scenarios where the available suggestion with the highest suggestion is applied - * directly. - */ - hide?: boolean; - /** - * Descriptive title of the suggestion. Should be as short as possible. This title is shown if - * the suggestion is advertised to the user and will also show either the `previewExpression` or - * the `previewIcon` - */ - title: string; - /** - * The new state of the visualization if this suggestion is applied. - */ - state: T; - /** - * An EUI icon type shown instead of the preview expression. - */ - previewIcon: IconType; -} - -export interface FramePublicAPI { - datasourceLayers: Record; - - dateRange: DateRange; - query: Query; - filters: Filter[]; - - // Adds a new layer. This has a side effect of updating the datasource state - addNewLayer: () => string; - removeLayers: (layerIds: string[]) => void; -} - -export interface VisualizationType { - id: string; - icon?: IconType; - largeIcon?: IconType; - label: string; -} - -export interface Visualization { - id: string; - - visualizationTypes: VisualizationType[]; - - getLayerIds: (state: T) => string[]; - clearLayer: (state: T, layerId: string) => T; - removeLayer?: (state: T, layerId: string) => T; - appendLayer?: (state: T, layerId: string) => T; - - // Layer context menu is used by visualizations for styling the entire layer - // For example, the XY visualization uses this to have multiple chart types - getLayerContextMenuIcon?: (opts: { state: T; layerId: string }) => IconType | undefined; - renderLayerContextMenu?: (domElement: Element, props: VisualizationLayerWidgetProps) => void; - - getConfiguration: ( - props: VisualizationConfigProps - ) => { groups: VisualizationDimensionGroupConfig[] }; - - getDescription: ( - state: T - ) => { - icon?: IconType; - label: string; - }; - - switchVisualizationType?: (visualizationTypeId: string, state: T) => T; - - // For initializing from saved object - initialize: (frame: FramePublicAPI, state?: P) => T; - - getPersistableState: (state: T) => P; - - // Actions triggered by the frame which tell the datasource that a dimension is being changed - setDimension: ( - props: VisualizationDimensionChangeProps & { - groupId: string; - } - ) => T; - removeDimension: (props: VisualizationDimensionChangeProps) => T; - - toExpression: (state: T, frame: FramePublicAPI) => Ast | string | null; - - /** - * Epression to render a preview version of the chart in very constraint space. - * If there is no expression provided, the preview icon is used. - */ - toPreviewExpression?: (state: T, frame: FramePublicAPI) => Ast | string | null; - - // The frame will call this function on all visualizations when the table changes, or when - // rendering additional ways of using the data - getSuggestions: (context: SuggestionRequest) => Array>; -} diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/_index.scss b/x-pack/legacy/plugins/lens/public/xy_visualization/_index.scss deleted file mode 100644 index 794ed4aed82ec..0000000000000 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './_xy_expression'; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/index.ts b/x-pack/legacy/plugins/lens/public/xy_visualization/index.ts deleted file mode 100644 index 8cc5abb44d6e1..0000000000000 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/index.ts +++ /dev/null @@ -1,64 +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 { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme'; -import { CoreSetup, IUiSettingsClient, CoreStart } from 'src/core/public'; -import moment from 'moment-timezone'; -import { ExpressionsSetup } from '../../../../../../src/plugins/expressions/public'; -import { xyVisualization } from './xy_visualization'; -import { xyChart, getXyChartRenderer } from './xy_expression'; -import { legendConfig, xConfig, layerConfig } from './types'; -import { EditorFrameSetup, FormatFactory } from '../types'; -import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public'; -import { setExecuteTriggerActions } from './services'; - -export interface XyVisualizationPluginSetupPlugins { - expressions: ExpressionsSetup; - formatFactory: Promise; - editorFrame: EditorFrameSetup; -} - -interface XyVisualizationPluginStartPlugins { - uiActions: UiActionsStart; -} - -function getTimeZone(uiSettings: IUiSettingsClient) { - const configuredTimeZone = uiSettings.get('dateFormat:tz'); - if (configuredTimeZone === 'Browser') { - return moment.tz.guess(); - } - - return configuredTimeZone; -} - -export class XyVisualization { - constructor() {} - - setup( - core: CoreSetup, - { expressions, formatFactory, editorFrame }: XyVisualizationPluginSetupPlugins - ) { - expressions.registerFunction(() => legendConfig); - expressions.registerFunction(() => xConfig); - expressions.registerFunction(() => layerConfig); - expressions.registerFunction(() => xyChart); - - expressions.registerRenderer( - getXyChartRenderer({ - formatFactory, - chartTheme: core.uiSettings.get('theme:darkMode') - ? EUI_CHARTS_THEME_DARK.theme - : EUI_CHARTS_THEME_LIGHT.theme, - timeZone: getTimeZone(core.uiSettings), - }) - ); - - editorFrame.registerVisualization(xyVisualization); - } - start(core: CoreStart, { uiActions }: XyVisualizationPluginStartPlugins) { - setExecuteTriggerActions(uiActions.executeTriggerActions); - } -} diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/services.ts b/x-pack/legacy/plugins/lens/public/xy_visualization/services.ts deleted file mode 100644 index af683efb86534..0000000000000 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/services.ts +++ /dev/null @@ -1,12 +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 { createGetterSetter } from '../../../../../../src/plugins/kibana_utils/public'; -import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public'; - -export const [getExecuteTriggerActions, setExecuteTriggerActions] = createGetterSetter< - UiActionsStart['executeTriggerActions'] ->('executeTriggerActions'); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/types.ts b/x-pack/legacy/plugins/lens/public/xy_visualization/types.ts deleted file mode 100644 index f7b4afc76ec4b..0000000000000 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/types.ts +++ /dev/null @@ -1,282 +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 { Position } from '@elastic/charts'; -import { i18n } from '@kbn/i18n'; -import { ArgumentType, ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; -import chartAreaSVG from '../assets/chart_area.svg'; -import chartAreaStackedSVG from '../assets/chart_area_stacked.svg'; -import chartBarSVG from '../assets/chart_bar.svg'; -import chartBarStackedSVG from '../assets/chart_bar_stacked.svg'; -import chartBarHorizontalSVG from '../assets/chart_bar_horizontal.svg'; -import chartBarHorizontalStackedSVG from '../assets/chart_bar_horizontal_stacked.svg'; -import chartLineSVG from '../assets/chart_line.svg'; - -import { VisualizationType } from '..'; - -export interface LegendConfig { - isVisible: boolean; - position: Position; -} - -type LegendConfigResult = LegendConfig & { type: 'lens_xy_legendConfig' }; - -export const legendConfig: ExpressionFunctionDefinition< - 'lens_xy_legendConfig', - null, - LegendConfig, - LegendConfigResult -> = { - name: 'lens_xy_legendConfig', - aliases: [], - type: 'lens_xy_legendConfig', - help: `Configure the xy chart's legend`, - inputTypes: ['null'], - args: { - isVisible: { - types: ['boolean'], - help: i18n.translate('xpack.lens.xyChart.isVisible.help', { - defaultMessage: 'Specifies whether or not the legend is visible.', - }), - }, - position: { - types: ['string'], - options: [Position.Top, Position.Right, Position.Bottom, Position.Left], - help: i18n.translate('xpack.lens.xyChart.position.help', { - defaultMessage: 'Specifies the legend position.', - }), - }, - }, - fn: function fn(input: unknown, args: LegendConfig) { - return { - type: 'lens_xy_legendConfig', - ...args, - }; - }, -}; - -interface AxisConfig { - title: string; - hide?: boolean; -} - -const axisConfig: { [key in keyof AxisConfig]: ArgumentType } = { - title: { - types: ['string'], - help: i18n.translate('xpack.lens.xyChart.title.help', { - defaultMessage: 'The axis title', - }), - }, - hide: { - types: ['boolean'], - default: false, - help: 'Show / hide axis', - }, -}; - -export interface YState extends AxisConfig { - accessors: string[]; -} - -export interface XConfig extends AxisConfig { - accessor: string; -} - -type XConfigResult = XConfig & { type: 'lens_xy_xConfig' }; - -export const xConfig: ExpressionFunctionDefinition< - 'lens_xy_xConfig', - null, - XConfig, - XConfigResult -> = { - name: 'lens_xy_xConfig', - aliases: [], - type: 'lens_xy_xConfig', - help: `Configure the xy chart's x axis`, - inputTypes: ['null'], - args: { - ...axisConfig, - accessor: { - types: ['string'], - help: 'The column to display on the x axis.', - }, - }, - fn: function fn(input: unknown, args: XConfig) { - return { - type: 'lens_xy_xConfig', - ...args, - }; - }, -}; - -type LayerConfigResult = LayerArgs & { type: 'lens_xy_layer' }; - -export const layerConfig: ExpressionFunctionDefinition< - 'lens_xy_layer', - null, - LayerArgs, - LayerConfigResult -> = { - name: 'lens_xy_layer', - aliases: [], - type: 'lens_xy_layer', - help: `Configure a layer in the xy chart`, - inputTypes: ['null'], - args: { - ...axisConfig, - layerId: { - types: ['string'], - help: '', - }, - xAccessor: { - types: ['string'], - help: '', - }, - seriesType: { - types: ['string'], - options: ['bar', 'line', 'area', 'bar_stacked', 'area_stacked'], - help: 'The type of chart to display.', - }, - xScaleType: { - options: ['ordinal', 'linear', 'time'], - help: 'The scale type of the x axis', - default: 'ordinal', - }, - isHistogram: { - types: ['boolean'], - default: false, - help: 'Whether to layout the chart as a histogram', - }, - yScaleType: { - options: ['log', 'sqrt', 'linear', 'time'], - help: 'The scale type of the y axes', - default: 'linear', - }, - splitAccessor: { - types: ['string'], - help: 'The column to split by', - multi: false, - }, - accessors: { - types: ['string'], - help: 'The columns to display on the y axis.', - multi: true, - }, - columnToLabel: { - types: ['string'], - help: 'JSON key-value pairs of column ID to label', - }, - }, - fn: function fn(input: unknown, args: LayerArgs) { - return { - type: 'lens_xy_layer', - ...args, - }; - }, -}; - -export type SeriesType = - | 'bar' - | 'bar_horizontal' - | 'line' - | 'area' - | 'bar_stacked' - | 'bar_horizontal_stacked' - | 'area_stacked'; - -export interface LayerConfig { - hide?: boolean; - layerId: string; - xAccessor?: string; - accessors: string[]; - seriesType: SeriesType; - splitAccessor?: string; -} - -export type LayerArgs = LayerConfig & { - columnToLabel?: string; // Actually a JSON key-value pair - yScaleType: 'time' | 'linear' | 'log' | 'sqrt'; - xScaleType: 'time' | 'linear' | 'ordinal'; - isHistogram: boolean; -}; - -// Arguments to XY chart expression, with computed properties -export interface XYArgs { - xTitle: string; - yTitle: string; - legend: LegendConfig & { type: 'lens_xy_legendConfig' }; - layers: LayerArgs[]; -} - -// Persisted parts of the state -export interface XYState { - preferredSeriesType: SeriesType; - legend: LegendConfig; - layers: LayerConfig[]; -} - -export type State = XYState; -export type PersistableState = XYState; - -export const visualizationTypes: VisualizationType[] = [ - { - id: 'bar', - icon: 'visBarVertical', - largeIcon: chartBarSVG, - label: i18n.translate('xpack.lens.xyVisualization.barLabel', { - defaultMessage: 'Bar', - }), - }, - { - id: 'bar_horizontal', - icon: 'visBarHorizontal', - largeIcon: chartBarHorizontalSVG, - label: i18n.translate('xpack.lens.xyVisualization.barHorizontalLabel', { - defaultMessage: 'Horizontal bar', - }), - }, - { - id: 'bar_stacked', - icon: 'visBarVerticalStacked', - largeIcon: chartBarStackedSVG, - label: i18n.translate('xpack.lens.xyVisualization.stackedBarLabel', { - defaultMessage: 'Stacked bar', - }), - }, - { - id: 'bar_horizontal_stacked', - icon: 'visBarHorizontalStacked', - largeIcon: chartBarHorizontalStackedSVG, - label: i18n.translate('xpack.lens.xyVisualization.stackedBarHorizontalLabel', { - defaultMessage: 'Stacked horizontal bar', - }), - }, - { - id: 'line', - icon: 'visLine', - largeIcon: chartLineSVG, - label: i18n.translate('xpack.lens.xyVisualization.lineLabel', { - defaultMessage: 'Line', - }), - }, - { - id: 'area', - icon: 'visArea', - largeIcon: chartAreaSVG, - label: i18n.translate('xpack.lens.xyVisualization.areaLabel', { - defaultMessage: 'Area', - }), - }, - { - id: 'area_stacked', - icon: 'visAreaStacked', - largeIcon: chartAreaStackedSVG, - label: i18n.translate('xpack.lens.xyVisualization.stackedAreaLabel', { - defaultMessage: 'Stacked area', - }), - }, -]; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx deleted file mode 100644 index 54abc2c2bb667..0000000000000 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx +++ /dev/null @@ -1,815 +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 { - AreaSeries, - Axis, - BarSeries, - Position, - LineSeries, - Settings, - ScaleType, - GeometryValue, - XYChartSeriesIdentifier, - SeriesNameFn, -} from '@elastic/charts'; -import { xyChart, XYChart } from './xy_expression'; -import { LensMultiTable } from '../types'; -import { - KibanaDatatable, - KibanaDatatableRow, -} from '../../../../../../src/plugins/expressions/public'; -import React from 'react'; -import { shallow } from 'enzyme'; -import { XYArgs, LegendConfig, legendConfig, layerConfig, LayerArgs } from './types'; -import { createMockExecutionContext } from '../../../../../../src/plugins/expressions/common/mocks'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; - -const executeTriggerActions = jest.fn(); - -const createSampleDatatableWithRows = (rows: KibanaDatatableRow[]): KibanaDatatable => ({ - type: 'kibana_datatable', - columns: [ - { - id: 'a', - name: 'a', - formatHint: { id: 'number', params: { pattern: '0,0.000' } }, - }, - { id: 'b', name: 'b', formatHint: { id: 'number', params: { pattern: '000,0' } } }, - { - id: 'c', - name: 'c', - formatHint: { id: 'string' }, - meta: { type: 'date-histogram', aggConfigParams: { interval: '10s' } }, - }, - { id: 'd', name: 'ColD', formatHint: { id: 'string' } }, - ], - rows, -}); - -const sampleLayer: LayerArgs = { - layerId: 'first', - seriesType: 'line', - xAccessor: 'c', - accessors: ['a', 'b'], - splitAccessor: 'd', - columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', - xScaleType: 'ordinal', - yScaleType: 'linear', - isHistogram: false, -}; - -const createArgsWithLayers = (layers: LayerArgs[] = [sampleLayer]): XYArgs => ({ - xTitle: '', - yTitle: '', - legend: { - type: 'lens_xy_legendConfig', - isVisible: false, - position: Position.Top, - }, - layers, -}); - -function sampleArgs() { - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - first: createSampleDatatableWithRows([ - { a: 1, b: 2, c: 'I', d: 'Foo' }, - { a: 1, b: 5, c: 'J', d: 'Bar' }, - ]), - }, - }; - - const args: XYArgs = createArgsWithLayers(); - - return { data, args }; -} - -describe('xy_expression', () => { - describe('configs', () => { - test('legendConfig produces the correct arguments', () => { - const args: LegendConfig = { - isVisible: true, - position: Position.Left, - }; - - const result = legendConfig.fn(null, args, createMockExecutionContext()); - - expect(result).toEqual({ - type: 'lens_xy_legendConfig', - ...args, - }); - }); - - test('layerConfig produces the correct arguments', () => { - const args: LayerArgs = { - layerId: 'first', - seriesType: 'line', - xAccessor: 'c', - accessors: ['a', 'b'], - splitAccessor: 'd', - xScaleType: 'linear', - yScaleType: 'linear', - isHistogram: false, - }; - - const result = layerConfig.fn(null, args, createMockExecutionContext()); - - expect(result).toEqual({ - type: 'lens_xy_layer', - ...args, - }); - }); - }); - - describe('xyChart', () => { - test('it renders with the specified data and args', () => { - const { data, args } = sampleArgs(); - const result = xyChart.fn(data, args, createMockExecutionContext()); - - expect(result).toEqual({ - type: 'render', - as: 'lens_xy_chart_renderer', - value: { data, args }, - }); - }); - }); - - describe('XYChart component', () => { - let getFormatSpy: jest.Mock; - let convertSpy: jest.Mock; - - beforeEach(() => { - convertSpy = jest.fn(x => x); - getFormatSpy = jest.fn(); - getFormatSpy.mockReturnValue({ convert: convertSpy }); - }); - - test('it renders line', () => { - const { data, args } = sampleArgs(); - - const component = shallow( - - ); - expect(component).toMatchSnapshot(); - expect(component.find(LineSeries)).toHaveLength(1); - }); - - describe('date range', () => { - const timeSampleLayer: LayerArgs = { - layerId: 'first', - seriesType: 'line', - xAccessor: 'c', - accessors: ['a', 'b'], - splitAccessor: 'd', - columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', - xScaleType: 'time', - yScaleType: 'linear', - isHistogram: false, - }; - const multiLayerArgs = createArgsWithLayers([ - timeSampleLayer, - { - ...timeSampleLayer, - layerId: 'second', - seriesType: 'bar', - xScaleType: 'time', - }, - ]); - test('it uses the full date range', () => { - const { data, args } = sampleArgs(); - - const component = shallow( - - ); - expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(` - Object { - "max": 1546491600000, - "min": 1546405200000, - "minInterval": undefined, - } - `); - }); - - test('it generates correct xDomain for a layer with single value and a layer with no data (1-0) ', () => { - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - first: createSampleDatatableWithRows([{ a: 1, b: 2, c: 'I', d: 'Foo' }]), - second: createSampleDatatableWithRows([]), - }, - }; - - const component = shallow( - - ); - - expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(` - Object { - "max": 1546491600000, - "min": 1546405200000, - "minInterval": 10000, - } - `); - }); - - test('it generates correct xDomain for two layers with single value(1-1)', () => { - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - first: createSampleDatatableWithRows([{ a: 1, b: 2, c: 'I', d: 'Foo' }]), - second: createSampleDatatableWithRows([{ a: 10, b: 5, c: 'J', d: 'Bar' }]), - }, - }; - const component = shallow( - - ); - - expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(` - Object { - "max": 1546491600000, - "min": 1546405200000, - "minInterval": 10000, - } - `); - }); - test('it generates correct xDomain for a layer with single value and layer with multiple value data (1-n)', () => { - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - first: createSampleDatatableWithRows([{ a: 1, b: 2, c: 'I', d: 'Foo' }]), - second: createSampleDatatableWithRows([ - { a: 10, b: 5, c: 'J', d: 'Bar' }, - { a: 8, b: 5, c: 'K', d: 'Buzz' }, - ]), - }, - }; - const component = shallow( - - ); - - expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(` - Object { - "max": 1546491600000, - "min": 1546405200000, - "minInterval": undefined, - } - `); - }); - - test('it generates correct xDomain for 2 layers with multiple value data (n-n)', () => { - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - first: createSampleDatatableWithRows([ - { a: 1, b: 2, c: 'I', d: 'Foo' }, - { a: 8, b: 5, c: 'K', d: 'Buzz' }, - { a: 9, b: 7, c: 'L', d: 'Bar' }, - { a: 10, b: 2, c: 'G', d: 'Bear' }, - ]), - second: createSampleDatatableWithRows([ - { a: 10, b: 5, c: 'J', d: 'Bar' }, - { a: 8, b: 4, c: 'K', d: 'Fi' }, - { a: 1, b: 8, c: 'O', d: 'Pi' }, - ]), - }, - }; - const component = shallow( - - ); - - expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(` - Object { - "max": 1546491600000, - "min": 1546405200000, - "minInterval": undefined, - } - `); - }); - }); - - test('it does not use date range if the x is not a time scale', () => { - const { data, args } = sampleArgs(); - - const component = shallow( - - ); - expect(component.find(Settings).prop('xDomain')).toBeUndefined(); - }); - - test('it renders bar', () => { - const { data, args } = sampleArgs(); - const component = shallow( - - ); - expect(component).toMatchSnapshot(); - expect(component.find(BarSeries)).toHaveLength(1); - }); - - test('it renders area', () => { - const { data, args } = sampleArgs(); - const component = shallow( - - ); - expect(component).toMatchSnapshot(); - expect(component.find(AreaSeries)).toHaveLength(1); - }); - - test('it renders horizontal bar', () => { - const { data, args } = sampleArgs(); - const component = shallow( - - ); - expect(component).toMatchSnapshot(); - expect(component.find(BarSeries)).toHaveLength(1); - expect(component.find(Settings).prop('rotation')).toEqual(90); - }); - - test('onElementClick returns correct context data', () => { - const geometry: GeometryValue = { x: 5, y: 1, accessor: 'y1' }; - const series = { - key: 'spec{d}yAccessor{d}splitAccessors{b-2}', - specId: 'd', - yAccessor: 'd', - splitAccessors: {}, - seriesKeys: [2, 'd'], - }; - - const { args, data } = sampleArgs(); - - const wrapper = mountWithIntl( - - ); - - wrapper - .find(Settings) - .first() - .prop('onElementClick')!([[geometry, series as XYChartSeriesIdentifier]]); - - expect(executeTriggerActions).toHaveBeenCalledWith('VALUE_CLICK_TRIGGER', { - data: { - data: [ - { - column: 1, - row: 1, - table: data.tables.first, - value: 5, - }, - { - column: 1, - row: 0, - table: data.tables.first, - value: 2, - }, - ], - }, - }); - }); - - test('it renders stacked bar', () => { - const { data, args } = sampleArgs(); - const component = shallow( - - ); - expect(component).toMatchSnapshot(); - expect(component.find(BarSeries)).toHaveLength(1); - expect(component.find(BarSeries).prop('stackAccessors')).toHaveLength(1); - }); - - test('it renders stacked area', () => { - const { data, args } = sampleArgs(); - const component = shallow( - - ); - expect(component).toMatchSnapshot(); - expect(component.find(AreaSeries)).toHaveLength(1); - expect(component.find(AreaSeries).prop('stackAccessors')).toHaveLength(1); - }); - - test('it renders stacked horizontal bar', () => { - const { data, args } = sampleArgs(); - const component = shallow( - - ); - expect(component).toMatchSnapshot(); - expect(component.find(BarSeries)).toHaveLength(1); - expect(component.find(BarSeries).prop('stackAccessors')).toHaveLength(1); - expect(component.find(Settings).prop('rotation')).toEqual(90); - }); - - test('it passes time zone to the series', () => { - const { data, args } = sampleArgs(); - const component = shallow( - - ); - expect(component.find(LineSeries).prop('timeZone')).toEqual('CEST'); - }); - - test('it applies histogram mode to the series for single series', () => { - const { data, args } = sampleArgs(); - const firstLayer: LayerArgs = { ...args.layers[0], seriesType: 'bar', isHistogram: true }; - delete firstLayer.splitAccessor; - const component = shallow( - - ); - expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); - }); - - test('it applies histogram mode to the series for stacked series', () => { - const { data, args } = sampleArgs(); - const component = shallow( - - ); - expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); - }); - - test('it does not apply histogram mode for splitted series', () => { - const { data, args } = sampleArgs(); - const component = shallow( - - ); - expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(false); - }); - - test('it names the series for multiple accessors', () => { - const { data, args } = sampleArgs(); - - const component = shallow( - - ); - const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; - - expect( - nameFn( - { - seriesKeys: ['a', 'b', 'c', 'd'], - key: '', - specId: 'a', - yAccessor: '', - splitAccessors: new Map(), - }, - false - ) - ).toEqual('Label A - Label B - c - Label D'); - }); - - test('it names the series for a single accessor', () => { - const { data, args } = sampleArgs(); - - const component = shallow( - - ); - const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; - - expect( - nameFn( - { - seriesKeys: ['a', 'b', 'c', 'd'], - key: '', - specId: 'a', - yAccessor: '', - splitAccessors: new Map(), - }, - false - ) - ).toEqual('Label A'); - }); - - test('it set the scale of the x axis according to the args prop', () => { - const { data, args } = sampleArgs(); - - const component = shallow( - - ); - expect(component.find(LineSeries).prop('xScaleType')).toEqual(ScaleType.Ordinal); - }); - - test('it set the scale of the y axis according to the args prop', () => { - const { data, args } = sampleArgs(); - - const component = shallow( - - ); - expect(component.find(LineSeries).prop('yScaleType')).toEqual(ScaleType.Sqrt); - }); - - test('it gets the formatter for the x axis', () => { - const { data, args } = sampleArgs(); - - shallow( - - ); - - expect(getFormatSpy).toHaveBeenCalledWith({ id: 'string' }); - }); - - test('it gets a default formatter for y if there are multiple y accessors', () => { - const { data, args } = sampleArgs(); - - shallow( - - ); - - expect(getFormatSpy).toHaveBeenCalledWith({ id: 'number' }); - }); - - test('it gets the formatter for the y axis if there is only one accessor', () => { - const { data, args } = sampleArgs(); - - shallow( - - ); - expect(getFormatSpy).toHaveBeenCalledWith({ - id: 'number', - params: { pattern: '0,0.000' }, - }); - }); - - test('it should pass the formatter function to the axis', () => { - const { data, args } = sampleArgs(); - - const instance = shallow( - - ); - - const tickFormatter = instance - .find(Axis) - .first() - .prop('tickFormat'); - - if (!tickFormatter) { - throw new Error('tickFormatter prop not found'); - } - - tickFormatter('I'); - - expect(convertSpy).toHaveBeenCalledWith('I'); - }); - }); -}); diff --git a/x-pack/legacy/plugins/logstash/index.js b/x-pack/legacy/plugins/logstash/index.js index ae8571d1c19c3..29f01032f3413 100755 --- a/x-pack/legacy/plugins/logstash/index.js +++ b/x-pack/legacy/plugins/logstash/index.js @@ -5,12 +5,8 @@ */ import { resolve } from 'path'; -import { registerLogstashPipelinesRoutes } from './server/routes/api/pipelines'; -import { registerLogstashPipelineRoutes } from './server/routes/api/pipeline'; -import { registerLogstashUpgradeRoutes } from './server/routes/api/upgrade'; -import { registerLogstashClusterRoutes } from './server/routes/api/cluster'; import { registerLicenseChecker } from './server/lib/register_license_checker'; -import { PLUGIN } from './common/constants'; +import { PLUGIN } from '../../../plugins/logstash/common/constants'; export const logstash = kibana => new kibana.Plugin({ @@ -32,9 +28,5 @@ export const logstash = kibana => }, init: server => { registerLicenseChecker(server); - registerLogstashPipelinesRoutes(server); - registerLogstashPipelineRoutes(server); - registerLogstashUpgradeRoutes(server); - registerLogstashClusterRoutes(server); }, }); diff --git a/x-pack/legacy/plugins/logstash/public/components/pipeline_editor/pipeline_editor.js b/x-pack/legacy/plugins/logstash/public/components/pipeline_editor/pipeline_editor.js index 43ca656e0827c..5e430ccbd8ceb 100644 --- a/x-pack/legacy/plugins/logstash/public/components/pipeline_editor/pipeline_editor.js +++ b/x-pack/legacy/plugins/logstash/public/components/pipeline_editor/pipeline_editor.js @@ -13,7 +13,7 @@ import 'brace/mode/plain_text'; import 'brace/theme/github'; import { isEmpty } from 'lodash'; -import { TOOLTIPS } from '../../../common/constants/tooltips'; +import { TOOLTIPS } from '../../../../../../plugins/logstash/common/constants/tooltips'; import { EuiButton, EuiButtonEmpty, diff --git a/x-pack/legacy/plugins/logstash/public/lib/register_home_feature.ts b/x-pack/legacy/plugins/logstash/public/lib/register_home_feature.ts index e943656120d5e..2e1ee2afb9ce6 100644 --- a/x-pack/legacy/plugins/logstash/public/lib/register_home_feature.ts +++ b/x-pack/legacy/plugins/logstash/public/lib/register_home_feature.ts @@ -10,7 +10,7 @@ import { npSetup } from 'ui/new_platform'; import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; import { FeatureCatalogueCategory } from '../../../../../../src/plugins/home/public'; // @ts-ignore -import { PLUGIN } from '../../common/constants'; +import { PLUGIN } from '../../../../../plugins/logstash/common/constants'; const { plugins: { home }, diff --git a/x-pack/legacy/plugins/logstash/public/models/pipeline_list_item/pipeline_list_item.js b/x-pack/legacy/plugins/logstash/public/models/pipeline_list_item/pipeline_list_item.js index 14900fdaa7cdc..06d01a05bac27 100755 --- a/x-pack/legacy/plugins/logstash/public/models/pipeline_list_item/pipeline_list_item.js +++ b/x-pack/legacy/plugins/logstash/public/models/pipeline_list_item/pipeline_list_item.js @@ -8,7 +8,7 @@ import { pick, capitalize } from 'lodash'; import { getSearchValue } from 'plugins/logstash/lib/get_search_value'; import { getMoment } from 'plugins/logstash/../common/lib/get_moment'; -import { PIPELINE } from '../../../common/constants'; +import { PIPELINE } from '../../../../../../plugins/logstash/common/constants'; /** * Represents the model for listing pipelines in the UI diff --git a/x-pack/legacy/plugins/logstash/public/services/cluster/cluster_service.js b/x-pack/legacy/plugins/logstash/public/services/cluster/cluster_service.js index 4bad4f48cc61d..e89c2fe7d11bf 100755 --- a/x-pack/legacy/plugins/logstash/public/services/cluster/cluster_service.js +++ b/x-pack/legacy/plugins/logstash/public/services/cluster/cluster_service.js @@ -5,7 +5,7 @@ */ import chrome from 'ui/chrome'; -import { ROUTES } from '../../../common/constants'; +import { ROUTES } from '../../../../../../plugins/logstash/common/constants'; import { Cluster } from 'plugins/logstash/models/cluster'; export class ClusterService { diff --git a/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js b/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js index 97b336ec0728b..69cc8614a6ae2 100755 --- a/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js +++ b/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js @@ -7,7 +7,7 @@ import React from 'react'; import { toastNotifications } from 'ui/notify'; import { MarkdownSimple } from '../../../../../../../src/plugins/kibana_react/public'; -import { PLUGIN } from '../../../common/constants'; +import { PLUGIN } from '../../../../../../plugins/logstash/common/constants'; export class LogstashLicenseService { constructor(xpackInfoService, kbnUrlService, $timeout) { diff --git a/x-pack/legacy/plugins/logstash/public/services/monitoring/monitoring_service.js b/x-pack/legacy/plugins/logstash/public/services/monitoring/monitoring_service.js index 8a267e38db738..6103e730c2171 100755 --- a/x-pack/legacy/plugins/logstash/public/services/monitoring/monitoring_service.js +++ b/x-pack/legacy/plugins/logstash/public/services/monitoring/monitoring_service.js @@ -6,7 +6,7 @@ import moment from 'moment'; import chrome from 'ui/chrome'; -import { ROUTES, MONITORING } from '../../../common/constants'; +import { ROUTES, MONITORING } from '../../../../../../plugins/logstash/common/constants'; import { PipelineListItem } from 'plugins/logstash/models/pipeline_list_item'; export class MonitoringService { diff --git a/x-pack/legacy/plugins/logstash/public/services/pipeline/pipeline_service.js b/x-pack/legacy/plugins/logstash/public/services/pipeline/pipeline_service.js index 0696bf9d83256..b5d0dbeb852d5 100755 --- a/x-pack/legacy/plugins/logstash/public/services/pipeline/pipeline_service.js +++ b/x-pack/legacy/plugins/logstash/public/services/pipeline/pipeline_service.js @@ -5,7 +5,7 @@ */ import chrome from 'ui/chrome'; -import { ROUTES } from '../../../common/constants'; +import { ROUTES } from '../../../../../../plugins/logstash/common/constants'; import { Pipeline } from 'plugins/logstash/models/pipeline'; export class PipelineService { diff --git a/x-pack/legacy/plugins/logstash/public/services/pipelines/pipelines_service.js b/x-pack/legacy/plugins/logstash/public/services/pipelines/pipelines_service.js index 5a43cf07eba41..d70c8be06fde4 100755 --- a/x-pack/legacy/plugins/logstash/public/services/pipelines/pipelines_service.js +++ b/x-pack/legacy/plugins/logstash/public/services/pipelines/pipelines_service.js @@ -5,7 +5,7 @@ */ import chrome from 'ui/chrome'; -import { ROUTES, MONITORING } from '../../../common/constants'; +import { ROUTES, MONITORING } from '../../../../../../plugins/logstash/common/constants'; import { PipelineListItem } from 'plugins/logstash/models/pipeline_list_item'; const RECENTLY_DELETED_PIPELINE_IDS_STORAGE_KEY = 'xpack.logstash.recentlyDeletedPipelines'; diff --git a/x-pack/legacy/plugins/logstash/public/services/upgrade/upgrade_service.js b/x-pack/legacy/plugins/logstash/public/services/upgrade/upgrade_service.js index 7870a495d07a3..2019bdc1bf1aa 100755 --- a/x-pack/legacy/plugins/logstash/public/services/upgrade/upgrade_service.js +++ b/x-pack/legacy/plugins/logstash/public/services/upgrade/upgrade_service.js @@ -5,7 +5,7 @@ */ import chrome from 'ui/chrome'; -import { ROUTES } from '../../../common/constants'; +import { ROUTES } from '../../../../../../plugins/logstash/common/constants'; export class UpgradeService { constructor($http) { diff --git a/x-pack/legacy/plugins/logstash/server/lib/call_with_request_factory/call_with_request_factory.js b/x-pack/legacy/plugins/logstash/server/lib/call_with_request_factory/call_with_request_factory.js deleted file mode 100755 index 8dc09d394e973..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/lib/call_with_request_factory/call_with_request_factory.js +++ /dev/null @@ -1,18 +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 { once } from 'lodash'; - -const callWithRequest = once(server => { - const cluster = server.plugins.elasticsearch.createCluster('logstash'); - return cluster.callWithRequest; -}); - -export const callWithRequestFactory = (server, request) => { - return (...args) => { - return callWithRequest(server)(request, ...args); - }; -}; diff --git a/x-pack/legacy/plugins/logstash/server/lib/call_with_request_factory/index.js b/x-pack/legacy/plugins/logstash/server/lib/call_with_request_factory/index.js deleted file mode 100755 index 787814d87dff9..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/lib/call_with_request_factory/index.js +++ /dev/null @@ -1,7 +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 { callWithRequestFactory } from './call_with_request_factory'; diff --git a/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/__tests__/wrap_custom_error.js b/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/__tests__/wrap_custom_error.js deleted file mode 100755 index f9c102be7a1ff..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/__tests__/wrap_custom_error.js +++ /dev/null @@ -1,21 +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 expect from '@kbn/expect'; -import { wrapCustomError } from '../wrap_custom_error'; - -describe('wrap_custom_error', () => { - describe('#wrapCustomError', () => { - it('should return a Boom object', () => { - const originalError = new Error('I am an error'); - const statusCode = 404; - const wrappedError = wrapCustomError(originalError, statusCode); - - expect(wrappedError.isBoom).to.be(true); - expect(wrappedError.output.statusCode).to.equal(statusCode); - }); - }); -}); diff --git a/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/__tests__/wrap_es_error.js b/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/__tests__/wrap_es_error.js deleted file mode 100755 index cab25cd0b1b10..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/__tests__/wrap_es_error.js +++ /dev/null @@ -1,42 +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 expect from '@kbn/expect'; -import { wrapEsError } from '../wrap_es_error'; - -describe('wrap_es_error', () => { - describe('#wrapEsError', () => { - let originalError; - beforeEach(() => { - originalError = new Error('I am an error'); - originalError.statusCode = 404; - }); - - it('should return a Boom object', () => { - const wrappedError = wrapEsError(originalError); - - expect(wrappedError.isBoom).to.be(true); - }); - - it('should return the correct Boom object', () => { - const wrappedError = wrapEsError(originalError); - - expect(wrappedError.output.statusCode).to.be(originalError.statusCode); - expect(wrappedError.output.payload.message).to.be(originalError.message); - }); - - it('should return invalid permissions message for 403 errors', () => { - const securityError = new Error('I am an error'); - securityError.statusCode = 403; - const wrappedError = wrapEsError(securityError); - - expect(wrappedError.isBoom).to.be(true); - expect(wrappedError.message).to.be( - 'Insufficient user permissions for managing Logstash pipelines' - ); - }); - }); -}); diff --git a/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/__tests__/wrap_unknown_error.js b/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/__tests__/wrap_unknown_error.js deleted file mode 100755 index 85e0b2b3033ad..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/__tests__/wrap_unknown_error.js +++ /dev/null @@ -1,19 +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 expect from '@kbn/expect'; -import { wrapUnknownError } from '../wrap_unknown_error'; - -describe('wrap_unknown_error', () => { - describe('#wrapUnknownError', () => { - it('should return a Boom object', () => { - const originalError = new Error('I am an error'); - const wrappedError = wrapUnknownError(originalError); - - expect(wrappedError.isBoom).to.be(true); - }); - }); -}); diff --git a/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/index.js b/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/index.js deleted file mode 100755 index f275f15637091..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/index.js +++ /dev/null @@ -1,9 +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 { wrapCustomError } from './wrap_custom_error'; -export { wrapEsError } from './wrap_es_error'; -export { wrapUnknownError } from './wrap_unknown_error'; diff --git a/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/wrap_custom_error.js b/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/wrap_custom_error.js deleted file mode 100755 index 3295113d38ee5..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/wrap_custom_error.js +++ /dev/null @@ -1,18 +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 Boom from 'boom'; - -/** - * Wraps a custom error into a Boom error response and returns it - * - * @param err Object error - * @param statusCode Error status code - * @return Object Boom error response - */ -export function wrapCustomError(err, statusCode) { - return Boom.boomify(err, { statusCode }); -} diff --git a/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/wrap_es_error.js b/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/wrap_es_error.js deleted file mode 100755 index 41819179bde55..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/wrap_es_error.js +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Boom from 'boom'; -import { i18n } from '@kbn/i18n'; - -/** - * Wraps ES errors into a Boom error response and returns it - * This also handles the permissions issue gracefully - * - * @param err Object ES error - * @return Object Boom error response - */ -export function wrapEsError(err) { - const statusCode = err.statusCode; - if (statusCode === 403) { - return Boom.forbidden( - i18n.translate('xpack.logstash.insufficientUserPermissionsDescription', { - defaultMessage: 'Insufficient user permissions for managing Logstash pipelines', - }) - ); - } - return Boom.boomify(err, { statusCode: err.statusCode }); -} diff --git a/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/wrap_unknown_error.js b/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/wrap_unknown_error.js deleted file mode 100755 index ffd915c513362..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/lib/error_wrappers/wrap_unknown_error.js +++ /dev/null @@ -1,17 +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 Boom from 'boom'; - -/** - * Wraps an unknown error into a Boom error response and returns it - * - * @param err Object Unknown error - * @return Object Boom error response - */ -export function wrapUnknownError(err) { - return Boom.boomify(err); -} diff --git a/x-pack/legacy/plugins/logstash/server/lib/fetch_all_from_scroll/__tests__/fetch_all_from_scroll.js b/x-pack/legacy/plugins/logstash/server/lib/fetch_all_from_scroll/__tests__/fetch_all_from_scroll.js deleted file mode 100755 index b1593fb1ba355..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/lib/fetch_all_from_scroll/__tests__/fetch_all_from_scroll.js +++ /dev/null @@ -1,88 +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 expect from '@kbn/expect'; -import sinon from 'sinon'; -import { fetchAllFromScroll } from '../fetch_all_from_scroll'; -import { set } from 'lodash'; - -describe('fetch_all_from_scroll', () => { - let mockResponse; - let stubCallWithRequest; - - beforeEach(() => { - mockResponse = {}; - - stubCallWithRequest = sinon.stub(); - stubCallWithRequest.onCall(0).returns( - new Promise(resolve => { - const mockInnerResponse = { - hits: { - hits: ['newhit'], - }, - _scroll_id: 'newScrollId', - }; - return resolve(mockInnerResponse); - }) - ); - - stubCallWithRequest.onCall(1).returns( - new Promise(resolve => { - const mockInnerResponse = { - hits: { - hits: [], - }, - }; - return resolve(mockInnerResponse); - }) - ); - }); - - describe('#fetchAllFromScroll', () => { - describe('when the passed-in response has no hits', () => { - beforeEach(() => { - set(mockResponse, 'hits.hits', []); - }); - - it('should return an empty array of hits', () => { - return fetchAllFromScroll(mockResponse).then(hits => { - expect(hits).to.eql([]); - }); - }); - - it('should not call callWithRequest', () => { - return fetchAllFromScroll(mockResponse, stubCallWithRequest).then(() => { - expect(stubCallWithRequest.called).to.be(false); - }); - }); - }); - - describe('when the passed-in response has some hits', () => { - beforeEach(() => { - set(mockResponse, 'hits.hits', ['foo', 'bar']); - set(mockResponse, '_scroll_id', 'originalScrollId'); - }); - - it('should return the hits from the response', () => { - return fetchAllFromScroll(mockResponse, stubCallWithRequest).then(hits => { - expect(hits).to.eql(['foo', 'bar', 'newhit']); - }); - }); - - it('should call callWithRequest', () => { - return fetchAllFromScroll(mockResponse, stubCallWithRequest).then(() => { - expect(stubCallWithRequest.calledTwice).to.be(true); - - const firstCallWithRequestCallArgs = stubCallWithRequest.args[0]; - expect(firstCallWithRequestCallArgs[1].body.scroll_id).to.eql('originalScrollId'); - - const secondCallWithRequestCallArgs = stubCallWithRequest.args[1]; - expect(secondCallWithRequestCallArgs[1].body.scroll_id).to.eql('newScrollId'); - }); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/logstash/server/lib/fetch_all_from_scroll/fetch_all_from_scroll.js b/x-pack/legacy/plugins/logstash/server/lib/fetch_all_from_scroll/fetch_all_from_scroll.js deleted file mode 100755 index 835ef0090a5d2..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/lib/fetch_all_from_scroll/fetch_all_from_scroll.js +++ /dev/null @@ -1,28 +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 { get } from 'lodash'; -import { ES_SCROLL_SETTINGS } from '../../../common/constants'; - -export function fetchAllFromScroll(response, callWithRequest, hits = []) { - const newHits = get(response, 'hits.hits', []); - const scrollId = get(response, '_scroll_id'); - - if (newHits.length > 0) { - hits.push(...newHits); - - return callWithRequest('scroll', { - body: { - scroll: ES_SCROLL_SETTINGS.KEEPALIVE, - scroll_id: scrollId, - }, - }).then(innerResponse => { - return fetchAllFromScroll(innerResponse, callWithRequest, hits); - }); - } - - return Promise.resolve(hits); -} diff --git a/x-pack/legacy/plugins/logstash/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js b/x-pack/legacy/plugins/logstash/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js deleted file mode 100755 index 1dc1df922acf7..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js +++ /dev/null @@ -1,69 +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 expect from '@kbn/expect'; -import { licensePreRoutingFactory } from '../license_pre_routing_factory'; - -describe('license_pre_routing_factory', () => { - describe('#logstashFeaturePreRoutingFactory', () => { - let mockServer; - let mockLicenseCheckResults; - - beforeEach(() => { - mockServer = { - plugins: { - xpack_main: { - info: { - feature: () => ({ - getLicenseCheckResults: () => mockLicenseCheckResults, - }), - }, - }, - }, - }; - }); - - it('only instantiates one instance per server', () => { - const firstInstance = licensePreRoutingFactory(mockServer); - const secondInstance = licensePreRoutingFactory(mockServer); - - expect(firstInstance).to.be(secondInstance); - }); - - describe('isAvailable is false', () => { - beforeEach(() => { - mockLicenseCheckResults = { - isAvailable: false, - }; - }); - - it('replies with 403', () => { - const licensePreRouting = licensePreRoutingFactory(mockServer); - const stubRequest = {}; - expect(() => licensePreRouting(stubRequest)).to.throwException(response => { - expect(response).to.be.an(Error); - expect(response.isBoom).to.be(true); - expect(response.output.statusCode).to.be(403); - }); - }); - }); - - describe('isAvailable is true', () => { - beforeEach(() => { - mockLicenseCheckResults = { - isAvailable: true, - }; - }); - - it('replies with nothing', () => { - const licensePreRouting = licensePreRoutingFactory(mockServer); - const stubRequest = {}; - const response = licensePreRouting(stubRequest); - expect(response).to.be(null); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/logstash/server/lib/license_pre_routing_factory/index.js b/x-pack/legacy/plugins/logstash/server/lib/license_pre_routing_factory/index.js deleted file mode 100755 index 0743e443955f4..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/lib/license_pre_routing_factory/index.js +++ /dev/null @@ -1,7 +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 { licensePreRoutingFactory } from './license_pre_routing_factory'; diff --git a/x-pack/legacy/plugins/logstash/server/lib/license_pre_routing_factory/license_pre_routing_factory.js b/x-pack/legacy/plugins/logstash/server/lib/license_pre_routing_factory/license_pre_routing_factory.js deleted file mode 100755 index 05402a56a52d8..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/lib/license_pre_routing_factory/license_pre_routing_factory.js +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { once } from 'lodash'; -import { wrapCustomError } from '../error_wrappers'; -import { PLUGIN } from '../../../common/constants'; - -export const licensePreRoutingFactory = once(server => { - const xpackMainPlugin = server.plugins.xpack_main; - - // License checking and enable/disable logic - function licensePreRouting() { - const licenseCheckResults = xpackMainPlugin.info.feature(PLUGIN.ID).getLicenseCheckResults(); - if (!licenseCheckResults.isAvailable) { - const error = new Error(licenseCheckResults.message); - const statusCode = 403; - throw wrapCustomError(error, statusCode); - } - - return null; - } - - return licensePreRouting; -}); diff --git a/x-pack/legacy/plugins/logstash/server/lib/register_license_checker/register_license_checker.js b/x-pack/legacy/plugins/logstash/server/lib/register_license_checker/register_license_checker.js index 8a17fb2eea497..a0d06e77b410d 100755 --- a/x-pack/legacy/plugins/logstash/server/lib/register_license_checker/register_license_checker.js +++ b/x-pack/legacy/plugins/logstash/server/lib/register_license_checker/register_license_checker.js @@ -6,7 +6,7 @@ import { mirrorPluginStatus } from '../../../../../server/lib/mirror_plugin_status'; import { checkLicense } from '../check_license'; -import { PLUGIN } from '../../../common/constants'; +import { PLUGIN } from '../../../../../../plugins/logstash/common/constants'; export function registerLicenseChecker(server) { const xpackMainPlugin = server.plugins.xpack_main; diff --git a/x-pack/legacy/plugins/logstash/server/models/cluster/__tests__/cluster.js b/x-pack/legacy/plugins/logstash/server/models/cluster/__tests__/cluster.js deleted file mode 100755 index 08a447a160a1a..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/models/cluster/__tests__/cluster.js +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { Cluster } from '../cluster'; - -describe('cluster', () => { - describe('Cluster', () => { - describe('fromUpstreamJSON factory method', () => { - const upstreamJSON = { - cluster_uuid: 'S-S4NNZDRV-g9c-JrIhx6A', - }; - - it('returns correct Cluster instance', () => { - const cluster = Cluster.fromUpstreamJSON(upstreamJSON); - expect(cluster.uuid).to.be(upstreamJSON.cluster_uuid); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/logstash/server/models/cluster/cluster.js b/x-pack/legacy/plugins/logstash/server/models/cluster/cluster.js deleted file mode 100755 index b114162fb0986..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/models/cluster/cluster.js +++ /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. - */ - -import { get } from 'lodash'; - -/** - * This model deals with a cluster object from ES and converts it to Kibana downstream - */ -export class Cluster { - constructor(props) { - this.uuid = props.uuid; - } - - get downstreamJSON() { - const json = { - uuid: this.uuid, - }; - - return json; - } - - // generate Pipeline object from elasticsearch response - static fromUpstreamJSON(upstreamCluster) { - const uuid = get(upstreamCluster, 'cluster_uuid'); - return new Cluster({ uuid }); - } -} diff --git a/x-pack/legacy/plugins/logstash/server/models/pipeline/__tests__/pipeline.js b/x-pack/legacy/plugins/logstash/server/models/pipeline/__tests__/pipeline.js deleted file mode 100755 index 41869c22271f0..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/models/pipeline/__tests__/pipeline.js +++ /dev/null @@ -1,76 +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 expect from '@kbn/expect'; -import { Pipeline } from '../pipeline'; - -describe('pipeline', () => { - describe('Pipeline', () => { - describe('fromUpstreamJSON factory method', () => { - const upstreamJSON = { - _id: 'apache', - _source: { - description: 'this is an apache pipeline', - pipeline_metadata: { - version: 1, - type: 'logstash_pipeline', - }, - username: 'elastic', - pipeline: 'input {} filter { grok {} }\n output {}', - }, - }; - - it('returns correct Pipeline instance', () => { - const pipeline = Pipeline.fromUpstreamJSON(upstreamJSON); - expect(pipeline.id).to.be(upstreamJSON._id); - expect(pipeline.description).to.be(upstreamJSON._source.description); - expect(pipeline.username).to.be(upstreamJSON._source.username); - expect(pipeline.pipeline).to.be(upstreamJSON._source.pipeline); - }); - - it('throws if pipeline argument does not contain an id property', () => { - const badJSON = { - // no _id - _source: upstreamJSON._source, - }; - const testFromUpstreamJsonError = () => { - return Pipeline.fromUpstreamJSON(badJSON); - }; - expect(testFromUpstreamJsonError).to.throwError( - /upstreamPipeline argument must contain an id property/i - ); - }); - }); - - describe('upstreamJSON getter method', () => { - it('returns the upstream JSON', () => { - const downstreamJSON = { - id: 'apache', - description: 'this is an apache pipeline', - username: 'elastic', - pipeline: 'input {} filter { grok {} }\n output {}', - }; - const pipeline = new Pipeline(downstreamJSON); - const expectedUpstreamJSON = { - description: 'this is an apache pipeline', - pipeline_metadata: { - type: 'logstash_pipeline', - version: 1, - }, - username: 'elastic', - pipeline: 'input {} filter { grok {} }\n output {}', - }; - // can't do an object level comparison because modified field is always `now` - expect(pipeline.upstreamJSON.last_modified).to.be.a('string'); - expect(pipeline.upstreamJSON.description).to.be(expectedUpstreamJSON.description); - expect(pipeline.upstreamJSON.pipeline_metadata).to.eql( - expectedUpstreamJSON.pipeline_metadata - ); - expect(pipeline.upstreamJSON.pipeline).to.be(expectedUpstreamJSON.pipeline); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/logstash/server/models/pipeline/pipeline.js b/x-pack/legacy/plugins/logstash/server/models/pipeline/pipeline.js deleted file mode 100755 index f02d303cb0380..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/models/pipeline/pipeline.js +++ /dev/null @@ -1,93 +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 moment from 'moment'; -import { badRequest } from 'boom'; -import { get } from 'lodash'; -import { i18n } from '@kbn/i18n'; - -/** - * This model deals with a pipeline object from ES and converts it to Kibana downstream - */ -export class Pipeline { - constructor(props) { - this.id = props.id; - this.description = props.description; - this.username = props.username; - this.pipeline = props.pipeline; - this.settings = props.settings || {}; - } - - get downstreamJSON() { - const json = { - id: this.id, - description: this.description, - username: this.username, - pipeline: this.pipeline, - settings: this.settings, - }; - - return json; - } - - /** - * Returns the JSON schema for the pipeline doc that Elasticsearch expects - * For now, we hard code pipeline_metadata since we don't use it yet - * pipeline_metadata.version is the version of the Logstash config stored in - * pipeline field. - * pipeline_metadata.type is the Logstash config type (future: LIR, json, etc) - * @return {[JSON]} [Elasticsearch JSON] - */ - get upstreamJSON() { - return { - description: this.description, - last_modified: moment().toISOString(), - pipeline_metadata: { - version: 1, - type: 'logstash_pipeline', - }, - username: this.username, - pipeline: this.pipeline, - pipeline_settings: this.settings, - }; - } - - // generate Pipeline object from kibana response - static fromDownstreamJSON(downstreamPipeline, pipelineId, username) { - const opts = { - id: pipelineId, - description: downstreamPipeline.description, - username, - pipeline: downstreamPipeline.pipeline, - settings: downstreamPipeline.settings, - }; - - return new Pipeline(opts); - } - - // generate Pipeline object from elasticsearch response - static fromUpstreamJSON(upstreamPipeline) { - if (!upstreamPipeline._id) { - throw badRequest( - i18n.translate( - 'xpack.logstash.upstreamPipelineArgumentMustContainAnIdPropertyErrorMessage', - { - defaultMessage: 'upstreamPipeline argument must contain an id property', - } - ) - ); - } - const id = get(upstreamPipeline, '_id'); - const description = get(upstreamPipeline, '_source.description'); - const username = get(upstreamPipeline, '_source.username'); - const pipeline = get(upstreamPipeline, '_source.pipeline'); - const settings = get(upstreamPipeline, '_source.pipeline_settings'); - - const opts = { id, description, username, pipeline, settings }; - - return new Pipeline(opts); - } -} diff --git a/x-pack/legacy/plugins/logstash/server/models/pipeline_list_item/__tests__/pipeline_list_item.js b/x-pack/legacy/plugins/logstash/server/models/pipeline_list_item/__tests__/pipeline_list_item.js deleted file mode 100755 index 4f3a447f030c7..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/models/pipeline_list_item/__tests__/pipeline_list_item.js +++ /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 expect from '@kbn/expect'; -import { PipelineListItem } from '../pipeline_list_item'; - -describe('pipeline_list_item', () => { - describe('PipelineListItem', () => { - const upstreamJSON = { - _id: 'apache', - _source: { - description: 'this is an apache pipeline', - last_modified: '2017-05-14T02:50:51.250Z', - pipeline_metadata: { - type: 'logstash_pipeline', - version: 1, - }, - username: 'elastic', - pipeline: 'input {} filter { grok {} }\n output {}', - }, - }; - - describe('fromUpstreamJSON factory method', () => { - it('returns correct PipelineListItem instance', () => { - const pipelineListItem = PipelineListItem.fromUpstreamJSON(upstreamJSON); - expect(pipelineListItem.id).to.be(upstreamJSON._id); - expect(pipelineListItem.description).to.be(upstreamJSON._source.description); - expect(pipelineListItem.username).to.be(upstreamJSON._source.username); - expect(pipelineListItem.last_modified).to.be(upstreamJSON._source.last_modified); - }); - }); - - describe('downstreamJSON getter method', () => { - it('returns the downstreamJSON JSON', () => { - const pipelineListItem = PipelineListItem.fromUpstreamJSON(upstreamJSON); - const expectedDownstreamJSON = { - id: 'apache', - description: 'this is an apache pipeline', - username: 'elastic', - last_modified: '2017-05-14T02:50:51.250Z', - }; - expect(pipelineListItem.downstreamJSON).to.eql(expectedDownstreamJSON); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/logstash/server/models/pipeline_list_item/pipeline_list_item.js b/x-pack/legacy/plugins/logstash/server/models/pipeline_list_item/pipeline_list_item.js deleted file mode 100755 index bbb506766897e..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/models/pipeline_list_item/pipeline_list_item.js +++ /dev/null @@ -1,42 +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 { get } from 'lodash'; - -export class PipelineListItem { - constructor(props) { - this.id = props.id; - this.description = props.description; - this.last_modified = props.last_modified; - this.username = props.username; - } - - get downstreamJSON() { - const json = { - id: this.id, - description: this.description, - last_modified: this.last_modified, - username: this.username, - }; - - return json; - } - - /** - * Takes the json GET response from ES and constructs a pipeline model to be used - * in Kibana downstream - */ - static fromUpstreamJSON(pipeline) { - const opts = { - id: pipeline._id, - description: get(pipeline, '_source.description'), - last_modified: get(pipeline, '_source.last_modified'), - username: get(pipeline, '_source.username'), - }; - - return new PipelineListItem(opts); - } -} diff --git a/x-pack/legacy/plugins/logstash/server/routes/api/cluster/index.js b/x-pack/legacy/plugins/logstash/server/routes/api/cluster/index.js deleted file mode 100755 index b129d8524b573..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/routes/api/cluster/index.js +++ /dev/null @@ -1,7 +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 { registerLogstashClusterRoutes } from './register_cluster_routes'; diff --git a/x-pack/legacy/plugins/logstash/server/routes/api/cluster/register_cluster_routes.js b/x-pack/legacy/plugins/logstash/server/routes/api/cluster/register_cluster_routes.js deleted file mode 100755 index 86e18b02ddce2..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/routes/api/cluster/register_cluster_routes.js +++ /dev/null @@ -1,11 +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 { registerLoadRoute } from './register_load_route'; - -export function registerLogstashClusterRoutes(server) { - registerLoadRoute(server); -} diff --git a/x-pack/legacy/plugins/logstash/server/routes/api/cluster/register_load_route.js b/x-pack/legacy/plugins/logstash/server/routes/api/cluster/register_load_route.js deleted file mode 100755 index 663b60cc8c1d1..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/routes/api/cluster/register_load_route.js +++ /dev/null @@ -1,40 +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 Boom from 'boom'; -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { Cluster } from '../../../models/cluster'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -function fetchCluster(callWithRequest) { - return callWithRequest('info'); -} - -export function registerLoadRoute(server) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/logstash/cluster', - method: 'GET', - handler: (request, h) => { - const callWithRequest = callWithRequestFactory(server, request); - - return fetchCluster(callWithRequest) - .then(responseFromES => ({ - cluster: Cluster.fromUpstreamJSON(responseFromES).downstreamJSON, - })) - .catch(e => { - if (e.status === 403) { - return h.response(); - } - throw Boom.internal(e); - }); - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/logstash/server/routes/api/pipeline/index.js b/x-pack/legacy/plugins/logstash/server/routes/api/pipeline/index.js deleted file mode 100755 index 643a405ced919..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/routes/api/pipeline/index.js +++ /dev/null @@ -1,7 +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 { registerLogstashPipelineRoutes } from './register_pipeline_routes'; diff --git a/x-pack/legacy/plugins/logstash/server/routes/api/pipeline/register_delete_route.js b/x-pack/legacy/plugins/logstash/server/routes/api/pipeline/register_delete_route.js deleted file mode 100755 index 232ee4207541c..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/routes/api/pipeline/register_delete_route.js +++ /dev/null @@ -1,38 +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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { wrapEsError } from '../../../lib/error_wrappers'; -import { INDEX_NAMES } from '../../../../common/constants'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -function deletePipeline(callWithRequest, pipelineId) { - return callWithRequest('delete', { - index: INDEX_NAMES.PIPELINES, - id: pipelineId, - refresh: 'wait_for', - }); -} - -export function registerDeleteRoute(server) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/logstash/pipeline/{id}', - method: 'DELETE', - handler: (request, h) => { - const callWithRequest = callWithRequestFactory(server, request); - const pipelineId = request.params.id; - - return deletePipeline(callWithRequest, pipelineId) - .then(() => h.response().code(204)) - .catch(e => wrapEsError(e)); - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/logstash/server/routes/api/pipeline/register_load_route.js b/x-pack/legacy/plugins/logstash/server/routes/api/pipeline/register_load_route.js deleted file mode 100755 index 796bf939d747f..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/routes/api/pipeline/register_load_route.js +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Boom from 'boom'; -import { INDEX_NAMES } from '../../../../common/constants'; -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { Pipeline } from '../../../models/pipeline'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -function fetchPipeline(callWithRequest, pipelineId) { - return callWithRequest('get', { - index: INDEX_NAMES.PIPELINES, - id: pipelineId, - _source: ['description', 'username', 'pipeline', 'pipeline_settings'], - ignore: [404], - }); -} - -export function registerLoadRoute(server) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/logstash/pipeline/{id}', - method: 'GET', - handler: request => { - const callWithRequest = callWithRequestFactory(server, request); - const pipelineId = request.params.id; - - return fetchPipeline(callWithRequest, pipelineId) - .then(pipelineResponseFromES => { - if (!pipelineResponseFromES.found) { - throw Boom.notFound(); - } - - const pipeline = Pipeline.fromUpstreamJSON(pipelineResponseFromES); - return pipeline.downstreamJSON; - }) - .catch(e => Boom.boomify(e)); - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/logstash/server/routes/api/pipeline/register_pipeline_routes.js b/x-pack/legacy/plugins/logstash/server/routes/api/pipeline/register_pipeline_routes.js deleted file mode 100755 index 9966cd2ca2139..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/routes/api/pipeline/register_pipeline_routes.js +++ /dev/null @@ -1,15 +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 { registerLoadRoute } from './register_load_route'; -import { registerDeleteRoute } from './register_delete_route'; -import { registerSaveRoute } from './register_save_route'; - -export function registerLogstashPipelineRoutes(server) { - registerLoadRoute(server); - registerDeleteRoute(server); - registerSaveRoute(server); -} diff --git a/x-pack/legacy/plugins/logstash/server/routes/api/pipeline/register_save_route.js b/x-pack/legacy/plugins/logstash/server/routes/api/pipeline/register_save_route.js deleted file mode 100755 index 50f62dc0a0ddd..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/routes/api/pipeline/register_save_route.js +++ /dev/null @@ -1,48 +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 { get } from 'lodash'; -import { wrapEsError } from '../../../lib/error_wrappers'; -import { INDEX_NAMES } from '../../../../common/constants'; -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { Pipeline } from '../../../models/pipeline'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -function savePipeline(callWithRequest, pipelineId, pipelineBody) { - return callWithRequest('index', { - index: INDEX_NAMES.PIPELINES, - id: pipelineId, - body: pipelineBody, - refresh: 'wait_for', - }); -} - -export function registerSaveRoute(server) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/logstash/pipeline/{id}', - method: 'PUT', - handler: async (request, h) => { - let username; - if (server.plugins.security) { - const user = await server.plugins.security.getUser(request); - username = get(user, 'username'); - } - - const callWithRequest = callWithRequestFactory(server, request); - const pipelineId = request.params.id; - - const pipeline = Pipeline.fromDownstreamJSON(request.payload, pipelineId, username); - return savePipeline(callWithRequest, pipeline.id, pipeline.upstreamJSON) - .then(() => h.response().code(204)) - .catch(e => wrapEsError(e)); - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/logstash/server/routes/api/pipelines/index.js b/x-pack/legacy/plugins/logstash/server/routes/api/pipelines/index.js deleted file mode 100755 index db275b5a3ea79..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/routes/api/pipelines/index.js +++ /dev/null @@ -1,7 +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 { registerLogstashPipelinesRoutes } from './register_pipelines_routes'; diff --git a/x-pack/legacy/plugins/logstash/server/routes/api/pipelines/register_delete_route.js b/x-pack/legacy/plugins/logstash/server/routes/api/pipelines/register_delete_route.js deleted file mode 100755 index 8ccd792d5a876..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/routes/api/pipelines/register_delete_route.js +++ /dev/null @@ -1,51 +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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { wrapUnknownError } from '../../../lib/error_wrappers'; -import { INDEX_NAMES } from '../../../../common/constants'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -function deletePipelines(callWithRequest, pipelineIds) { - const deletePromises = pipelineIds.map(pipelineId => { - return callWithRequest('delete', { - index: INDEX_NAMES.PIPELINES, - id: pipelineId, - refresh: 'wait_for', - }) - .then(success => ({ success })) - .catch(error => ({ error })); - }); - - return Promise.all(deletePromises).then(results => { - const successes = results.filter(result => Boolean(result.success)); - const errors = results.filter(result => Boolean(result.error)); - - return { - numSuccesses: successes.length, - numErrors: errors.length, - }; - }); -} - -export function registerDeleteRoute(server) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/logstash/pipelines/delete', - method: 'POST', - handler: request => { - const callWithRequest = callWithRequestFactory(server, request); - - return deletePipelines(callWithRequest, request.payload.pipelineIds) - .then(results => ({ results })) - .catch(err => wrapUnknownError(err)); - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/logstash/server/routes/api/pipelines/register_list_route.js b/x-pack/legacy/plugins/logstash/server/routes/api/pipelines/register_list_route.js deleted file mode 100755 index 43ce1c3e8f6f6..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/routes/api/pipelines/register_list_route.js +++ /dev/null @@ -1,52 +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 { wrapEsError } from '../../../lib/error_wrappers'; -import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { fetchAllFromScroll } from '../../../lib/fetch_all_from_scroll'; -import { INDEX_NAMES, ES_SCROLL_SETTINGS } from '../../../../common/constants'; -import { PipelineListItem } from '../../../models/pipeline_list_item'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -function fetchPipelines(callWithRequest) { - const params = { - index: INDEX_NAMES.PIPELINES, - scroll: ES_SCROLL_SETTINGS.KEEPALIVE, - body: { - size: ES_SCROLL_SETTINGS.PAGE_SIZE, - }, - ignore: [404], - }; - - return callWithRequest('search', params).then(response => - fetchAllFromScroll(response, callWithRequest) - ); -} - -export function registerListRoute(server) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/logstash/pipelines', - method: 'GET', - handler: request => { - const callWithRequest = callWithRequestFactory(server, request); - - return fetchPipelines(callWithRequest) - .then((pipelinesHits = []) => { - const pipelines = pipelinesHits.map(pipeline => { - return PipelineListItem.fromUpstreamJSON(pipeline).downstreamJSON; - }); - - return { pipelines }; - }) - .catch(e => wrapEsError(e)); - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/logstash/server/routes/api/pipelines/register_pipelines_routes.js b/x-pack/legacy/plugins/logstash/server/routes/api/pipelines/register_pipelines_routes.js deleted file mode 100755 index 6d25f3acb9bf9..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/routes/api/pipelines/register_pipelines_routes.js +++ /dev/null @@ -1,13 +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 { registerListRoute } from './register_list_route'; -import { registerDeleteRoute } from './register_delete_route'; - -export function registerLogstashPipelinesRoutes(server) { - registerListRoute(server); - registerDeleteRoute(server); -} diff --git a/x-pack/legacy/plugins/logstash/server/routes/api/upgrade/index.js b/x-pack/legacy/plugins/logstash/server/routes/api/upgrade/index.js deleted file mode 100755 index d616349dd6566..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/routes/api/upgrade/index.js +++ /dev/null @@ -1,7 +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 { registerLogstashUpgradeRoutes } from './register_upgrade_routes'; diff --git a/x-pack/legacy/plugins/logstash/server/routes/api/upgrade/register_execute_route.js b/x-pack/legacy/plugins/logstash/server/routes/api/upgrade/register_execute_route.js deleted file mode 100755 index 16f97930ae25e..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/routes/api/upgrade/register_execute_route.js +++ /dev/null @@ -1,56 +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 { callWithRequestFactory } from '../../../lib/call_with_request_factory'; -import { wrapUnknownError } from '../../../lib/error_wrappers'; -import { INDEX_NAMES } from '../../../../common/constants'; -import { licensePreRoutingFactory } from '../../../lib/license_pre_routing_factory'; - -function doesIndexExist(callWithRequest) { - return callWithRequest('indices.exists', { - index: INDEX_NAMES.PIPELINES, - }); -} - -async function executeUpgrade(callWithRequest) { - // If index doesn't exist yet, there is no mapping to upgrade - if (!(await doesIndexExist(callWithRequest))) { - return; - } - - return callWithRequest('indices.putMapping', { - index: INDEX_NAMES.PIPELINES, - body: { - properties: { - pipeline_settings: { - dynamic: false, - type: 'object', - }, - }, - }, - }); -} - -export function registerExecuteRoute(server) { - const licensePreRouting = licensePreRoutingFactory(server); - - server.route({ - path: '/api/logstash/upgrade', - method: 'POST', - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - try { - await executeUpgrade(callWithRequest); - return { is_upgraded: true }; - } catch (err) { - throw wrapUnknownError(err); - } - }, - config: { - pre: [licensePreRouting], - }, - }); -} diff --git a/x-pack/legacy/plugins/logstash/server/routes/api/upgrade/register_upgrade_routes.js b/x-pack/legacy/plugins/logstash/server/routes/api/upgrade/register_upgrade_routes.js deleted file mode 100755 index a198f82613e37..0000000000000 --- a/x-pack/legacy/plugins/logstash/server/routes/api/upgrade/register_upgrade_routes.js +++ /dev/null @@ -1,11 +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 { registerExecuteRoute } from './register_execute_route'; - -export function registerLogstashUpgradeRoutes(server) { - registerExecuteRoute(server); -} diff --git a/x-pack/legacy/plugins/maps/common/migrations/ems_raster_tile_to_ems_vector_tile.js b/x-pack/legacy/plugins/maps/common/migrations/ems_raster_tile_to_ems_vector_tile.js index 94f4018bbdbb7..091cfd8605cb6 100644 --- a/x-pack/legacy/plugins/maps/common/migrations/ems_raster_tile_to_ems_vector_tile.js +++ b/x-pack/legacy/plugins/maps/common/migrations/ems_raster_tile_to_ems_vector_tile.js @@ -5,11 +5,11 @@ */ import _ from 'lodash'; -import { EMS_TMS, LAYER_TYPE } from '../constants'; +import { SOURCE_TYPES, LAYER_TYPE } from '../constants'; function isEmsTileSource(layerDescriptor) { const sourceType = _.get(layerDescriptor, 'sourceDescriptor.type'); - return sourceType === EMS_TMS; + return sourceType === SOURCE_TYPES.EMS_TMS; } function isTileLayer(layerDescriptor) { diff --git a/x-pack/legacy/plugins/maps/common/migrations/move_apply_global_query.js b/x-pack/legacy/plugins/maps/common/migrations/move_apply_global_query.js index 490e760d8c003..0d6b0052d2b0d 100644 --- a/x-pack/legacy/plugins/maps/common/migrations/move_apply_global_query.js +++ b/x-pack/legacy/plugins/maps/common/migrations/move_apply_global_query.js @@ -5,11 +5,13 @@ */ import _ from 'lodash'; -import { ES_GEO_GRID, ES_PEW_PEW, ES_SEARCH } from '../constants'; +import { SOURCE_TYPES } from '../constants'; function isEsSource(layerDescriptor) { const sourceType = _.get(layerDescriptor, 'sourceDescriptor.type'); - return [ES_GEO_GRID, ES_PEW_PEW, ES_SEARCH].includes(sourceType); + return [SOURCE_TYPES.ES_GEO_GRID, SOURCE_TYPES.ES_PEW_PEW, SOURCE_TYPES.ES_SEARCH].includes( + sourceType + ); } // Migration to move applyGlobalQuery from layer to sources. diff --git a/x-pack/legacy/plugins/maps/common/migrations/references.js b/x-pack/legacy/plugins/maps/common/migrations/references.js index a96af700da37c..3980705fd7cfa 100644 --- a/x-pack/legacy/plugins/maps/common/migrations/references.js +++ b/x-pack/legacy/plugins/maps/common/migrations/references.js @@ -7,11 +7,15 @@ // Can not use public Layer classes to extract references since this logic must run in both client and server. import _ from 'lodash'; -import { ES_GEO_GRID, ES_SEARCH, ES_PEW_PEW } from '../constants'; +import { SOURCE_TYPES } from '../constants'; function doesSourceUseIndexPattern(layerDescriptor) { const sourceType = _.get(layerDescriptor, 'sourceDescriptor.type'); - return sourceType === ES_GEO_GRID || sourceType === ES_SEARCH || sourceType === ES_PEW_PEW; + return ( + sourceType === SOURCE_TYPES.ES_GEO_GRID || + sourceType === SOURCE_TYPES.ES_SEARCH || + sourceType === SOURCE_TYPES.ES_PEW_PEW + ); } export function extractReferences({ attributes, references = [] }) { diff --git a/x-pack/legacy/plugins/maps/common/migrations/references.test.js b/x-pack/legacy/plugins/maps/common/migrations/references.test.js index 40f6fd72a48d7..50a45c81339dc 100644 --- a/x-pack/legacy/plugins/maps/common/migrations/references.test.js +++ b/x-pack/legacy/plugins/maps/common/migrations/references.test.js @@ -5,16 +5,16 @@ */ import { extractReferences, injectReferences } from './references'; -import { ES_GEO_GRID, ES_SEARCH, ES_PEW_PEW } from '../constants'; +import { SOURCE_TYPES } from '../constants'; const layerListJSON = { esSearchSource: { - withIndexPatternId: `[{\"sourceDescriptor\":{\"type\":\"${ES_SEARCH}\",\"indexPatternId\":\"c698b940-e149-11e8-a35a-370a8516603a\"}}]`, - withIndexPatternRef: `[{\"sourceDescriptor\":{\"type\":\"${ES_SEARCH}\",\"indexPatternRefName\":\"layer_0_source_index_pattern\"}}]`, + withIndexPatternId: `[{\"sourceDescriptor\":{\"type\":\"${SOURCE_TYPES.ES_SEARCH}\",\"indexPatternId\":\"c698b940-e149-11e8-a35a-370a8516603a\"}}]`, + withIndexPatternRef: `[{\"sourceDescriptor\":{\"type\":\"${SOURCE_TYPES.ES_SEARCH}\",\"indexPatternRefName\":\"layer_0_source_index_pattern\"}}]`, }, esGeoGridSource: { - withIndexPatternId: `[{\"sourceDescriptor\":{\"type\":\"${ES_GEO_GRID}\",\"indexPatternId\":\"c698b940-e149-11e8-a35a-370a8516603a\"}}]`, - withIndexPatternRef: `[{\"sourceDescriptor\":{\"type\":\"${ES_GEO_GRID}\",\"indexPatternRefName\":\"layer_0_source_index_pattern\"}}]`, + withIndexPatternId: `[{\"sourceDescriptor\":{\"type\":\"${SOURCE_TYPES.ES_GEO_GRID}\",\"indexPatternId\":\"c698b940-e149-11e8-a35a-370a8516603a\"}}]`, + withIndexPatternRef: `[{\"sourceDescriptor\":{\"type\":\"${SOURCE_TYPES.ES_GEO_GRID}\",\"indexPatternRefName\":\"layer_0_source_index_pattern\"}}]`, }, join: { withIndexPatternId: @@ -23,8 +23,8 @@ const layerListJSON = { '[{"joins":[{"right":{"indexPatternRefName":"layer_0_join_0_index_pattern"}}]}]', }, pewPewSource: { - withIndexPatternId: `[{\"sourceDescriptor\":{\"type\":\"${ES_PEW_PEW}\",\"indexPatternId\":\"c698b940-e149-11e8-a35a-370a8516603a\"}}]`, - withIndexPatternRef: `[{\"sourceDescriptor\":{\"type\":\"${ES_PEW_PEW}\",\"indexPatternRefName\":\"layer_0_source_index_pattern\"}}]`, + withIndexPatternId: `[{\"sourceDescriptor\":{\"type\":\"${SOURCE_TYPES.ES_PEW_PEW}\",\"indexPatternId\":\"c698b940-e149-11e8-a35a-370a8516603a\"}}]`, + withIndexPatternRef: `[{\"sourceDescriptor\":{\"type\":\"${SOURCE_TYPES.ES_PEW_PEW}\",\"indexPatternRefName\":\"layer_0_source_index_pattern\"}}]`, }, }; diff --git a/x-pack/legacy/plugins/maps/common/migrations/scaling_type.ts b/x-pack/legacy/plugins/maps/common/migrations/scaling_type.ts index 5823ddd6b42e3..551975fbacea5 100644 --- a/x-pack/legacy/plugins/maps/common/migrations/scaling_type.ts +++ b/x-pack/legacy/plugins/maps/common/migrations/scaling_type.ts @@ -5,13 +5,13 @@ */ import _ from 'lodash'; -import { ES_SEARCH, SCALING_TYPES } from '../constants'; +import { SOURCE_TYPES, SCALING_TYPES } from '../constants'; import { LayerDescriptor, ESSearchSourceDescriptor } from '../descriptor_types'; import { MapSavedObjectAttributes } from '../../../../../plugins/maps/common/map_saved_object_type'; function isEsDocumentSource(layerDescriptor: LayerDescriptor) { const sourceType = _.get(layerDescriptor, 'sourceDescriptor.type'); - return sourceType === ES_SEARCH; + return sourceType === SOURCE_TYPES.ES_SEARCH; } export function migrateUseTopHitsToScalingType({ diff --git a/x-pack/legacy/plugins/maps/common/migrations/top_hits_time_to_sort.js b/x-pack/legacy/plugins/maps/common/migrations/top_hits_time_to_sort.js index 7392dfa71bf3a..055c867486f6c 100644 --- a/x-pack/legacy/plugins/maps/common/migrations/top_hits_time_to_sort.js +++ b/x-pack/legacy/plugins/maps/common/migrations/top_hits_time_to_sort.js @@ -5,11 +5,11 @@ */ import _ from 'lodash'; -import { ES_SEARCH, SORT_ORDER } from '../constants'; +import { SOURCE_TYPES, SORT_ORDER } from '../constants'; function isEsDocumentSource(layerDescriptor) { const sourceType = _.get(layerDescriptor, 'sourceDescriptor.type'); - return sourceType === ES_SEARCH; + return sourceType === SOURCE_TYPES.ES_SEARCH; } export function topHitsTimeToSort({ attributes }) { diff --git a/x-pack/legacy/plugins/maps/index.js b/x-pack/legacy/plugins/maps/index.js index 1a7f478d3bbad..f4e01efc05f45 100644 --- a/x-pack/legacy/plugins/maps/index.js +++ b/x-pack/legacy/plugins/maps/index.js @@ -38,6 +38,7 @@ export function maps(kibana) { return { showMapVisualizationTypes: serverConfig.get('xpack.maps.showMapVisualizationTypes'), showMapsInspectorAdapter: serverConfig.get('xpack.maps.showMapsInspectorAdapter'), + enableVectorTiles: serverConfig.get('xpack.maps.enableVectorTiles'), preserveDrawingBuffer: serverConfig.get('xpack.maps.preserveDrawingBuffer'), isEmsEnabled: mapConfig.includeElasticMapsService, emsFontLibraryUrl: mapConfig.emsFontLibraryUrl, @@ -85,6 +86,7 @@ export function maps(kibana) { showMapVisualizationTypes: Joi.boolean().default(false), showMapsInspectorAdapter: Joi.boolean().default(false), // flag used in functional testing preserveDrawingBuffer: Joi.boolean().default(false), // flag used in functional testing + enableVectorTiles: Joi.boolean().default(false), // flag used to enable/disable vector-tiles }).default(); }, diff --git a/x-pack/legacy/plugins/maps/public/actions/map_actions.js b/x-pack/legacy/plugins/maps/public/actions/map_actions.js index aa55cf0808ef2..7bfbf5761c5b8 100644 --- a/x-pack/legacy/plugins/maps/public/actions/map_actions.js +++ b/x-pack/legacy/plugins/maps/public/actions/map_actions.js @@ -125,9 +125,21 @@ async function syncDataForAllLayers(dispatch, getState, dataFilters) { export function cancelAllInFlightRequests() { return (dispatch, getState) => { getLayerList(getState()).forEach(layer => { - layer.getInFlightRequestTokens().forEach(requestToken => { - dispatch(cancelRequest(requestToken)); - }); + dispatch(clearDataRequests(layer)); + }); + }; +} + +function clearDataRequests(layer) { + return dispatch => { + layer.getInFlightRequestTokens().forEach(requestToken => { + dispatch(cancelRequest(requestToken)); + }); + dispatch({ + type: UPDATE_LAYER_PROP, + id: layer.getId(), + propName: '__dataRequests', + newValue: [], }); }; } @@ -663,13 +675,31 @@ export function updateSourceProp(layerId, propName, value, newLayerType) { layerId, propName, value, - newLayerType, }); + if (newLayerType) { + dispatch(updateLayerType(layerId, newLayerType)); + } await dispatch(clearMissingStyleProperties(layerId)); dispatch(syncDataForLayer(layerId)); }; } +function updateLayerType(layerId, newLayerType) { + return (dispatch, getState) => { + const layer = getLayerById(layerId, getState()); + if (!layer || layer.getType() === newLayerType) { + return; + } + dispatch(clearDataRequests(layer)); + dispatch({ + type: UPDATE_LAYER_PROP, + id: layerId, + propName: 'type', + newValue: newLayerType, + }); + }; +} + export function syncDataForLayer(layerId) { return async (dispatch, getState) => { const targetLayer = getLayerById(layerId, getState()); diff --git a/x-pack/legacy/plugins/maps/public/actions/map_actions.test.js b/x-pack/legacy/plugins/maps/public/actions/map_actions.test.js index c280b8af7ab80..7e2a3c827fa88 100644 --- a/x-pack/legacy/plugins/maps/public/actions/map_actions.test.js +++ b/x-pack/legacy/plugins/maps/public/actions/map_actions.test.js @@ -5,7 +5,7 @@ */ jest.mock('../selectors/map_selectors', () => ({})); -jest.mock('../kibana_services', () => ({})); +jest.mock('../../../../../plugins/maps/public/kibana_services', () => ({})); import { mapExtentChanged, setMouseCoordinates } from './map_actions'; diff --git a/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.js b/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.js index 5e497ff0736b2..686259aeaaba4 100644 --- a/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.js +++ b/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.js @@ -4,11 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ import _ from 'lodash'; +// Import each layer type, even those not used, to init in registry +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import '../../../../../plugins/maps/public/layers/sources/wms_source'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import '../../../../../plugins/maps/public/layers/sources/ems_file_source'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import '../../../../../plugins/maps/public/layers/sources/es_search_source'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import '../../../../../plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import '../../../../../plugins/maps/public/layers/sources/kibana_regionmap_source'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import '../../../../../plugins/maps/public/layers/sources/es_geo_grid_source'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import '../../../../../plugins/maps/public/layers/sources/xyz_tms_source'; + // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { KibanaTilemapSource } from '../../../../../plugins/maps/public/layers/sources/kibana_tilemap_source'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { EMSTMSSource } from '../../../../../plugins/maps/public/layers/sources/ems_tms_source'; -import { getInjectedVarFunc } from '../kibana_services'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getInjectedVarFunc } from '../../../../../plugins/maps/public/kibana_services'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { getKibanaTileMap } from '../../../../../plugins/maps/public/meta'; diff --git a/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.test.js b/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.test.js index 5334beaaf714a..8c9185a16ea0e 100644 --- a/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.test.js +++ b/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.test.js @@ -7,7 +7,7 @@ jest.mock('../../../../../plugins/maps/public/meta', () => { return {}; }); -jest.mock('../kibana_services'); +jest.mock('../../../../../plugins/maps/public/kibana_services'); import { getInitialLayers } from './get_initial_layers'; @@ -15,7 +15,8 @@ const layerListNotProvided = undefined; describe('Saved object has layer list', () => { beforeEach(() => { - require('../kibana_services').getInjectedVarFunc = () => jest.fn(); + require('../../../../../plugins/maps/public/kibana_services').getInjectedVarFunc = () => + jest.fn(); }); it('Should get initial layers from saved object', () => { @@ -65,7 +66,7 @@ describe('EMS is enabled', () => { require('../../../../../plugins/maps/public/meta').getKibanaTileMap = () => { return null; }; - require('../kibana_services').getInjectedVarFunc = () => key => { + require('../../../../../plugins/maps/public/kibana_services').getInjectedVarFunc = () => key => { switch (key) { case 'emsTileLayerId': return { @@ -110,7 +111,7 @@ describe('EMS is not enabled', () => { return null; }; - require('../kibana_services').getInjectedVarFunc = () => key => { + require('../../../../../plugins/maps/public/kibana_services').getInjectedVarFunc = () => key => { switch (key) { case 'isEmsEnabled': return false; diff --git a/x-pack/legacy/plugins/maps/public/angular/get_initial_query.js b/x-pack/legacy/plugins/maps/public/angular/get_initial_query.js index fc8305b252cc3..c50ecb2b05dc0 100644 --- a/x-pack/legacy/plugins/maps/public/angular/get_initial_query.js +++ b/x-pack/legacy/plugins/maps/public/angular/get_initial_query.js @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import chrome from 'ui/chrome'; - -const settings = chrome.getUiSettingsClient(); +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getUiSettings } from '../../../../../plugins/maps/public/kibana_services'; export function getInitialQuery({ mapStateJSON, appState = {}, userQueryLanguage }) { + const settings = getUiSettings(); + if (appState.query) { return appState.query; } diff --git a/x-pack/legacy/plugins/maps/public/angular/get_initial_refresh_config.js b/x-pack/legacy/plugins/maps/public/angular/get_initial_refresh_config.js index 10a2580b78e6d..8735d45debfc4 100644 --- a/x-pack/legacy/plugins/maps/public/angular/get_initial_refresh_config.js +++ b/x-pack/legacy/plugins/maps/public/angular/get_initial_refresh_config.js @@ -3,11 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import chrome from 'ui/chrome'; - -const uiSettings = chrome.getUiSettingsClient(); +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getUiSettings } from '../../../../../plugins/maps/public/kibana_services'; export function getInitialRefreshConfig({ mapStateJSON, globalState = {} }) { + const uiSettings = getUiSettings(); + if (mapStateJSON) { const mapState = JSON.parse(mapStateJSON); if (mapState.refreshConfig) { diff --git a/x-pack/legacy/plugins/maps/public/angular/get_initial_time_filters.js b/x-pack/legacy/plugins/maps/public/angular/get_initial_time_filters.js index 82439175841b2..74fbf603e99f5 100644 --- a/x-pack/legacy/plugins/maps/public/angular/get_initial_time_filters.js +++ b/x-pack/legacy/plugins/maps/public/angular/get_initial_time_filters.js @@ -3,9 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import chrome from 'ui/chrome'; - -const uiSettings = chrome.getUiSettingsClient(); +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getUiSettings } from '../../../../../plugins/maps/public/kibana_services'; export function getInitialTimeFilters({ mapStateJSON, globalState = {} }) { if (mapStateJSON) { @@ -15,6 +14,6 @@ export function getInitialTimeFilters({ mapStateJSON, globalState = {} }) { } } - const defaultTime = uiSettings.get('timepicker:timeDefaults'); + const defaultTime = getUiSettings().get('timepicker:timeDefaults'); return { ...defaultTime, ...globalState.time }; } diff --git a/x-pack/legacy/plugins/maps/public/angular/map.html b/x-pack/legacy/plugins/maps/public/angular/map.html index 2f34ffa660d6e..7d7dcf6f9c9a9 100644 --- a/x-pack/legacy/plugins/maps/public/angular/map.html +++ b/x-pack/legacy/plugins/maps/public/angular/map.html @@ -3,11 +3,11 @@
{ - const { filterManager } = npStart.plugins.data.query; + const savedQueryService = getData().query.savedQueries; + const { filterManager } = getData().query; const savedMap = $route.current.locals.map; $scope.screenTitle = savedMap.title; let unsubscribe; @@ -115,6 +129,14 @@ app.controller( return _.get($state, 'filters', []); } + const visibleSubscription = getCoreChrome() + .getIsVisible$() + .subscribe(isVisible => { + $scope.$evalAsync(() => { + $scope.isVisible = isVisible; + }); + }); + $scope.$listen(globalState, 'fetch_with_changes', diff => { if (diff.includes('time') || diff.includes('filters')) { onQueryChange({ @@ -169,10 +191,10 @@ app.controller( }); /* Saved Queries */ - $scope.showSaveQuery = capabilities.get().maps.saveQuery; + $scope.showSaveQuery = getMapsCapabilities().saveQuery; $scope.$watch( - () => capabilities.get().maps.saveQuery, + () => getMapsCapabilities().saveQuery, newCapability => { $scope.showSaveQuery = newCapability; } @@ -342,7 +364,7 @@ app.controller( // clear old UI state store.dispatch(setSelectedLayer(null)); store.dispatch(updateFlyout(FLYOUT_STATE.NONE)); - store.dispatch(setReadOnly(!capabilities.get().maps.save)); + store.dispatch(setReadOnly(!getMapsCapabilities().save)); handleStoreChanges(store); unsubscribe = store.subscribe(() => { @@ -446,6 +468,8 @@ app.controller( $scope.$on('$destroy', () => { window.removeEventListener('beforeunload', beforeUnload); + visibleSubscription.unsubscribe(); + getCoreChrome().setIsVisible(true); if (unsubscribe) { unsubscribe(); @@ -457,7 +481,7 @@ app.controller( }); const updateBreadcrumbs = () => { - chrome.breadcrumbs.set([ + getCoreChrome().setBreadcrumbs([ { text: i18n.translate('xpack.maps.mapController.mapsBreadcrumbLabel', { defaultMessage: 'Maps', @@ -482,7 +506,7 @@ app.controller( }; updateBreadcrumbs(); - addHelpMenuToAppChrome(chrome); + addHelpMenuToAppChrome(); async function doSave(saveOptions) { await store.dispatch(clearTransientLayerStateAndCloseFlyout()); @@ -491,9 +515,9 @@ app.controller( try { id = await savedMap.save(saveOptions); - docTitle.change(savedMap.title); + getCoreChrome().docTitle.change(savedMap.title); } catch (err) { - toastNotifications.addDanger({ + getToasts().addDanger({ title: i18n.translate('xpack.maps.mapController.saveErrorMessage', { defaultMessage: `Error on saving '{title}'`, values: { title: savedMap.title }, @@ -505,7 +529,7 @@ app.controller( } if (id) { - toastNotifications.addSuccess({ + getToasts().addSuccess({ title: i18n.translate('xpack.maps.mapController.saveSuccessMessage', { defaultMessage: `Saved '{title}'`, values: { title: savedMap.title }, @@ -539,6 +563,7 @@ app.controller( }), testId: 'mapsFullScreenMode', run() { + getCoreChrome().setIsVisible(false); store.dispatch(enableFullScreen()); }, }, @@ -556,7 +581,7 @@ app.controller( getInspector().open(inspectorAdapters, {}); }, }, - ...(capabilities.get().maps.save + ...(getMapsCapabilities().save ? [ { id: 'save', @@ -611,7 +636,7 @@ app.controller( showDescription={false} /> ); - showSaveModal(saveModal, npStart.core.i18n.Context); + showSaveModal(saveModal, getCoreI18n().Context); }, }, ] diff --git a/x-pack/legacy/plugins/maps/public/angular/services/gis_map_saved_object_loader.js b/x-pack/legacy/plugins/maps/public/angular/services/gis_map_saved_object_loader.js index bc636c0b200f8..710997a9c0d7f 100644 --- a/x-pack/legacy/plugins/maps/public/angular/services/gis_map_saved_object_loader.js +++ b/x-pack/legacy/plugins/maps/public/angular/services/gis_map_saved_object_loader.js @@ -4,24 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ +import _ from 'lodash'; import { createSavedGisMapClass } from './saved_gis_map'; -import { uiModules } from 'ui/modules'; import { SavedObjectLoader } from '../../../../../../../src/plugins/saved_objects/public'; -import { npStart } from '../../../../../../../src/legacy/ui/public/new_platform'; +import { + getCoreChrome, + getSavedObjectsClient, + getIndexPatternService, + getCoreOverlays, + getData, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../plugins/maps/public/kibana_services'; -const module = uiModules.get('app/maps'); - -// This is the only thing that gets injected into controllers -module.service('gisMapSavedObjectLoader', function() { - const savedObjectsClient = npStart.core.savedObjects.client; +export const getMapsSavedObjectLoader = _.once(function() { const services = { - savedObjectsClient, - indexPatterns: npStart.plugins.data.indexPatterns, - search: npStart.plugins.data.search, - chrome: npStart.core.chrome, - overlays: npStart.core.overlays, + savedObjectsClient: getSavedObjectsClient(), + indexPatterns: getIndexPatternService(), + search: getData().search, + chrome: getCoreChrome(), + overlays: getCoreOverlays(), }; const SavedGisMap = createSavedGisMapClass(services); - return new SavedObjectLoader(SavedGisMap, npStart.core.savedObjects.client, npStart.core.chrome); + return new SavedObjectLoader(SavedGisMap, getSavedObjectsClient(), getCoreChrome()); }); diff --git a/x-pack/legacy/plugins/maps/public/components/map_listing.js b/x-pack/legacy/plugins/maps/public/components/map_listing.js index 6fb5930e81a20..ef1d524cb91dd 100644 --- a/x-pack/legacy/plugins/maps/public/components/map_listing.js +++ b/x-pack/legacy/plugins/maps/public/components/map_listing.js @@ -7,7 +7,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import _ from 'lodash'; -import { toastNotifications } from 'ui/notify'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getToasts } from '../../../../../plugins/maps/public/kibana_services'; import { EuiTitle, EuiFieldSearch, @@ -27,7 +28,6 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { addHelpMenuToAppChrome } from '../help_menu_util'; -import chrome from 'ui/chrome'; export const EMPTY_FILTER = ''; @@ -55,7 +55,7 @@ export class MapListing extends React.Component { componentDidMount() { this.fetchItems(); - addHelpMenuToAppChrome(chrome); + addHelpMenuToAppChrome(); } debouncedFetch = _.debounce(async filter => { @@ -91,7 +91,7 @@ export class MapListing extends React.Component { try { await this.props.delete(this.state.selectedIds); } catch (error) { - toastNotifications.addDanger({ + getToasts().addDanger({ title: i18n.translate('xpack.maps.mapListing.unableToDeleteToastTitle', { defaultMessage: `Unable to delete map(s)`, }), diff --git a/x-pack/legacy/plugins/maps/public/connected_components/gis_map/index.js b/x-pack/legacy/plugins/maps/public/connected_components/gis_map/index.js index 39cb2c469e054..2d8265bae9387 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/gis_map/index.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/gis_map/index.js @@ -18,6 +18,8 @@ import { getQueryableUniqueIndexPatternIds, isToolbarOverlayHidden, } from '../../selectors/map_selectors'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getCoreChrome } from '../../../../../../plugins/maps/public/kibana_services'; function mapStateToProps(state = {}) { const flyoutDisplay = getFlyoutDisplay(state); @@ -37,7 +39,10 @@ function mapStateToProps(state = {}) { function mapDispatchToProps(dispatch) { return { triggerRefreshTimer: () => dispatch(triggerRefreshTimer()), - exitFullScreen: () => dispatch(exitFullScreen()), + exitFullScreen: () => { + dispatch(exitFullScreen()); + getCoreChrome().setIsVisible(true); + }, cancelAllInFlightRequests: () => dispatch(cancelAllInFlightRequests()), }; } diff --git a/x-pack/legacy/plugins/maps/public/connected_components/gis_map/view.js b/x-pack/legacy/plugins/maps/public/connected_components/gis_map/view.js index 358313b8f5b6d..06097ebea1900 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/gis_map/view.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/gis_map/view.js @@ -12,7 +12,8 @@ import { ToolbarOverlay } from '../toolbar_overlay/index'; import { LayerPanel } from '../layer_panel/index'; import { AddLayerPanel } from '../layer_addpanel/index'; import { EuiFlexGroup, EuiFlexItem, EuiCallOut } from '@elastic/eui'; -import { ExitFullScreenButton } from 'ui/exit_full_screen'; +import { ExitFullScreenButton } from '../../../../../../../src/plugins/kibana_react/public'; + // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { getIndexPatternsFromIds } from '../../../../../../plugins/maps/public/index_pattern_util'; import { ES_GEO_FIELD_TYPE } from '../../../common/constants'; diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_addpanel/view.js b/x-pack/legacy/plugins/maps/public/connected_components/layer_addpanel/view.js index a54df69471aa0..92fcf01f3901f 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_addpanel/view.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_addpanel/view.js @@ -63,13 +63,13 @@ export class AddLayerPanel extends Component { return; } - const style = + const styleDescriptor = this.state.layer && this.state.layer.getCurrentStyle() ? this.state.layer.getCurrentStyle().getDescriptor() : null; const layerInitProps = { ...options, - style: style, + style: styleDescriptor, }; const newLayer = source.createDefaultLayer(layerInitProps, this.props.mapColors); if (!this._isMounted) { diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js index f6bcac0dfc339..40fdac38493d4 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js @@ -20,12 +20,14 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { getIndexPatternService } from '../../../kibana_services'; +import { + getIndexPatternService, + getUiSettings, + getData, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../../plugins/maps/public/kibana_services'; import { GlobalFilterCheckbox } from '../../../components/global_filter_checkbox'; -import { npStart } from 'ui/new_platform'; -const { SearchBar } = npStart.plugins.data.ui; - export class FilterEditor extends Component { state = { isPopoverOpen: false, @@ -84,7 +86,8 @@ export class FilterEditor extends Component { _renderQueryPopover() { const layerQuery = this.props.layer.getQuery(); - const { uiSettings } = npStart.core; + const uiSettings = getUiSettings(); + const { SearchBar } = getData().ui; return ( { const label = event.target.value; @@ -22,8 +20,8 @@ export function LayerSettings(props) { }; const onZoomChange = ([min, max]) => { - props.updateMinZoom(props.layerId, Math.max(MIN_ZOOM, parseInt(min, 10))); - props.updateMaxZoom(props.layerId, Math.min(MAX_ZOOM, parseInt(max, 10))); + props.updateMinZoom(props.layerId, Math.max(props.minVisibilityZoom, parseInt(min, 10))); + props.updateMaxZoom(props.layerId, Math.min(props.maxVisibilityZoom, parseInt(max, 10))); }; const onAlphaChange = alpha => { @@ -38,8 +36,8 @@ export function LayerSettings(props) { defaultMessage: 'Visibility', })} formRowDisplay="columnCompressed" - min={MIN_ZOOM} - max={MAX_ZOOM} + min={props.minVisibilityZoom} + max={props.maxVisibilityZoom} value={[props.minZoom, props.maxZoom]} showInput="inputWithPopover" showRange diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/style_settings/_style_settings.scss b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/style_settings/_style_settings.scss index 249b6dfca5c76..f9ad412f7e48a 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/style_settings/_style_settings.scss +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/style_settings/_style_settings.scss @@ -1,3 +1,3 @@ .mapStyleSettings__fixedBox { - width: $euiSize * 7.5; -} \ No newline at end of file + width: $euiSize * 7.5; +} diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.js b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.js index 1b269e388bea0..2521318f0b3c9 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.js @@ -30,12 +30,11 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; import { Storage } from '../../../../../../../src/plugins/kibana_utils/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getData, getCore } from '../../../../../../plugins/maps/public/kibana_services'; const localStorage = new Storage(window.localStorage); -// This import will eventually become a dependency injected by the fully deangularized NP plugin. -import { npStart } from 'ui/new_platform'; - export class LayerPanel extends React.Component { state = { displayName: '', @@ -168,8 +167,8 @@ export class LayerPanel extends React.Component { services={{ appName: 'maps', storage: localStorage, - data: npStart.plugins.data, - ...npStart.core, + data: getData(), + ...getCore(), }} > diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/features_tooltip/feature_geometry_filter_form.js b/x-pack/legacy/plugins/maps/public/connected_components/map/features_tooltip/feature_geometry_filter_form.js index 7063c50edad6a..15824b82965e8 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/map/features_tooltip/feature_geometry_filter_form.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/features_tooltip/feature_geometry_filter_form.js @@ -12,7 +12,8 @@ import { i18n } from '@kbn/i18n'; import { createSpatialFilterWithGeometry } from '../../../../../../../plugins/maps/public/elasticsearch_geo_utils'; import { GEO_JSON_TYPE } from '../../../../common/constants'; import { GeometryFilterForm } from '../../../components/geometry_filter_form'; -import { UrlOverflowService } from 'ui/error_url_overflow'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { UrlOverflowService } from '../../../../../../../../src/plugins/kibana_legacy/public'; import rison from 'rison-node'; // over estimated and imprecise value to ensure filter has additional room for any meta keys added when filter is mapped. diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/draw_control/draw_control.js b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/draw_control/draw_control.js index df2988d399c5b..cc0e665525036 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/draw_control/draw_control.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/draw_control/draw_control.js @@ -12,7 +12,6 @@ import DrawRectangle from 'mapbox-gl-draw-rectangle-mode'; import { DrawCircle } from './draw_circle'; import { createDistanceFilterWithMeta, - createSpatialFilterWithBoundingBox, createSpatialFilterWithGeometry, getBoundingBoxGeometry, roundCoordinates, @@ -84,23 +83,17 @@ export class DrawControl extends React.Component { roundCoordinates(geometry.coordinates); try { - const options = { + const filter = createSpatialFilterWithGeometry({ + geometry: + this.props.drawState.drawType === DRAW_TYPE.BOUNDS + ? getBoundingBoxGeometry(geometry) + : geometry, indexPatternId: this.props.drawState.indexPatternId, geoFieldName: this.props.drawState.geoFieldName, geoFieldType: this.props.drawState.geoFieldType, geometryLabel: this.props.drawState.geometryLabel, relation: this.props.drawState.relation, - }; - const filter = - this.props.drawState.drawType === DRAW_TYPE.BOUNDS - ? createSpatialFilterWithBoundingBox({ - ...options, - geometry: getBoundingBoxGeometry(geometry), - }) - : createSpatialFilterWithGeometry({ - ...options, - geometry, - }); + }); this.props.addFilters([filter]); } catch (error) { // TODO notify user why filter was not created diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/view.js b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/view.js index fedc1902d80a2..a36e1d7048e92 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/view.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/view.js @@ -14,11 +14,15 @@ import { } from './utils'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { getGlyphUrl, isRetina } from '../../../../../../../plugins/maps/public/meta'; -import { DECIMAL_DEGREES_PRECISION, ZOOM_PRECISION } from '../../../../common/constants'; +import { + DECIMAL_DEGREES_PRECISION, + MAX_ZOOM, + MIN_ZOOM, + ZOOM_PRECISION, +} from '../../../../common/constants'; import mapboxgl from 'mapbox-gl/dist/mapbox-gl-csp'; import mbWorkerUrl from '!!file-loader!mapbox-gl/dist/mapbox-gl-csp-worker'; import mbRtlPlugin from '!!file-loader!@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js'; -import chrome from 'ui/chrome'; import { spritesheet } from '@elastic/maki'; import sprites1 from '@elastic/maki/dist/sprite@1.png'; import sprites2 from '@elastic/maki/dist/sprite@2.png'; @@ -29,6 +33,8 @@ import { clampToLonBounds, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../../../../plugins/maps/public/elasticsearch_geo_utils'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getInjectedVarFunc } from '../../../../../../../plugins/maps/public/kibana_services'; mapboxgl.workerUrl = mbWorkerUrl; mapboxgl.setRTLTextPlugin(mbRtlPlugin); @@ -129,8 +135,10 @@ export class MBMapContainer extends React.Component { container: this.refs.mapContainer, style: mbStyle, scrollZoom: this.props.scrollZoom, - preserveDrawingBuffer: chrome.getInjected('preserveDrawingBuffer', false), + preserveDrawingBuffer: getInjectedVarFunc()('preserveDrawingBuffer', false), interactive: !this.props.disableInteractive, + minZoom: MIN_ZOOM, + maxZoom: MAX_ZOOM, }; const initialView = _.get(this.props.goto, 'center'); if (initialView) { diff --git a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.tsx index bdd2d863e6920..b8e4c84ad56a1 100644 --- a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.tsx @@ -11,7 +11,6 @@ import { render, unmountComponentAtNode } from 'react-dom'; import 'mapbox-gl/dist/mapbox-gl.css'; import { I18nContext } from 'ui/i18n'; -import { npStart } from 'ui/new_platform'; import { Subscription } from 'rxjs'; import { Unsubscribe } from 'redux'; import { @@ -59,6 +58,8 @@ import { getMapCenter, getMapZoom, getHiddenLayerIds } from '../selectors/map_se import { MAP_SAVED_OBJECT_TYPE } from '../../common/constants'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { RenderToolTipContent } from '../../../../../plugins/maps/public/layers/tooltips/tooltip_property'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getUiActions } from '../../../../../plugins/maps/public/kibana_services'; interface MapEmbeddableConfig { editUrl?: string; @@ -269,7 +270,7 @@ export class MapEmbeddable extends Embeddable { - npStart.plugins.uiActions.executeTriggerActions(APPLY_FILTER_TRIGGER, { + getUiActions().executeTriggerActions(APPLY_FILTER_TRIGGER, { embeddable: this, filters, }); diff --git a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable_factory.ts b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable_factory.ts index 5deb3057a449e..96c3baf634a83 100644 --- a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable_factory.ts +++ b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable_factory.ts @@ -5,14 +5,18 @@ */ import _ from 'lodash'; -import chrome from 'ui/chrome'; -import { capabilities } from 'ui/capabilities'; import { i18n } from '@kbn/i18n'; import { npSetup, npStart } from 'ui/new_platform'; -import { SavedObjectLoader } from 'src/plugins/saved_objects/public'; import { IIndexPattern } from 'src/plugins/data/public'; +// @ts-ignore +import { getMapsSavedObjectLoader } from '../angular/services/gis_map_saved_object_loader'; import { MapEmbeddable, MapEmbeddableInput } from './map_embeddable'; -import { getIndexPatternService } from '../kibana_services'; +import { + getIndexPatternService, + getHttp, + getMapsCapabilities, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../plugins/maps/public/kibana_services'; import { EmbeddableFactoryDefinition, IContainer, @@ -26,7 +30,6 @@ import { getQueryableUniqueIndexPatternIds } from '../selectors/map_selectors'; import { getInitialLayers } from '../angular/get_initial_layers'; import { mergeInputWithSavedMap } from './merge_input_with_saved_map'; import '../angular/services/gis_map_saved_object_loader'; -import { bindSetupCoreAndPlugins, bindStartCoreAndPlugins } from '../plugin'; // @ts-ignore import { bindSetupCoreAndPlugins as bindNpSetupCoreAndPlugins, @@ -44,14 +47,12 @@ export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { }; constructor() { // Init required services. Necessary while in legacy - bindSetupCoreAndPlugins(npSetup.core, npSetup.plugins); bindNpSetupCoreAndPlugins(npSetup.core, npSetup.plugins); - bindStartCoreAndPlugins(npStart.core, npStart.plugins); bindNpStartCoreAndPlugins(npStart.core, npStart.plugins); } async isEditable() { - return capabilities.get().maps.save as boolean; + return getMapsCapabilities().save as boolean; } // Not supported yet for maps types. @@ -96,8 +97,7 @@ export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { } async _fetchSavedMap(savedObjectId: string) { - const $injector = await chrome.dangerouslyGetActiveInjector(); - const savedObjectLoader = $injector.get('gisMapSavedObjectLoader'); + const savedObjectLoader = getMapsSavedObjectLoader(); return await savedObjectLoader.get(savedObjectId); } @@ -114,7 +114,7 @@ export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { { layerList, title: savedMap.title, - editUrl: chrome.addBasePath(createMapPath(savedObjectId)), + editUrl: getHttp().basePath.prepend(createMapPath(savedObjectId)), indexPatterns, editable: await this.isEditable(), }, diff --git a/x-pack/legacy/plugins/maps/public/help_menu_util.js b/x-pack/legacy/plugins/maps/public/help_menu_util.js index 72d51cc180eb3..70b9340b562cd 100644 --- a/x-pack/legacy/plugins/maps/public/help_menu_util.js +++ b/x-pack/legacy/plugins/maps/public/help_menu_util.js @@ -3,10 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getDocLinks, getCoreChrome } from '../../../../plugins/maps/public/kibana_services'; -export function addHelpMenuToAppChrome(chrome) { - chrome.helpExtension.set({ +export function addHelpMenuToAppChrome() { + const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = getDocLinks(); + + getCoreChrome().setHelpExtension({ appName: 'Maps', links: [ { diff --git a/x-pack/legacy/plugins/maps/public/index.ts b/x-pack/legacy/plugins/maps/public/index.ts index b69485e251be4..8555594e909d3 100644 --- a/x-pack/legacy/plugins/maps/public/index.ts +++ b/x-pack/legacy/plugins/maps/public/index.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import './kibana_services'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import '../../../../plugins/maps/public/kibana_services'; // import the uiExports that we want to "use" import 'uiExports/inspectorViews'; diff --git a/x-pack/legacy/plugins/maps/public/kibana_services.d.ts b/x-pack/legacy/plugins/maps/public/kibana_services.d.ts deleted file mode 100644 index 89b1fee1aa842..0000000000000 --- a/x-pack/legacy/plugins/maps/public/kibana_services.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { IIndexPattern } from 'src/plugins/data/public'; - -export function getIndexPatternService(): { - get: (id: string) => IIndexPattern | undefined; -}; - -export function setLicenseId(args: unknown): void; -export function setInspector(args: unknown): void; -export function setFileUpload(args: unknown): void; -export function setIndexPatternSelect(args: unknown): void; -export function setHttp(args: unknown): void; -export function setTimeFilter(args: unknown): void; -export function setUiSettings(args: unknown): void; -export function setInjectedVarFunc(args: unknown): void; -export function setToasts(args: unknown): void; -export function setIndexPatternService(args: unknown): void; -export function setAutocompleteService(args: unknown): void; diff --git a/x-pack/legacy/plugins/maps/public/kibana_services.js b/x-pack/legacy/plugins/maps/public/kibana_services.js deleted file mode 100644 index a6491fe1aa6d4..0000000000000 --- a/x-pack/legacy/plugins/maps/public/kibana_services.js +++ /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. - */ - -let indexPatternService; -export const setIndexPatternService = dataIndexPatterns => - (indexPatternService = dataIndexPatterns); -export const getIndexPatternService = () => indexPatternService; - -let inspector; -export const setInspector = newInspector => (inspector = newInspector); -export const getInspector = () => { - return inspector; -}; - -let getInjectedVar; -export const setInjectedVarFunc = getInjectedVarFunc => (getInjectedVar = getInjectedVarFunc); -export const getInjectedVarFunc = () => getInjectedVar; - -let indexPatternSelectComponent; -export const setIndexPatternSelect = indexPatternSelect => - (indexPatternSelectComponent = indexPatternSelect); -export const getIndexPatternSelectComponent = () => indexPatternSelectComponent; - -let dataTimeFilter; -export const setTimeFilter = timeFilter => (dataTimeFilter = timeFilter); -export const getTimeFilter = () => dataTimeFilter; diff --git a/x-pack/legacy/plugins/maps/public/plugin.ts b/x-pack/legacy/plugins/maps/public/plugin.ts index 0fa7e1106a6df..71f1a30c1fbef 100644 --- a/x-pack/legacy/plugins/maps/public/plugin.ts +++ b/x-pack/legacy/plugins/maps/public/plugin.ts @@ -4,28 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import '../../../../plugins/maps/public/layers/layer_wizard_registry'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import '../../../../plugins/maps/public/layers/sources/source_registry'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import '../../../../plugins/maps/public/layers/load_layer_wizards'; - import { Plugin, CoreStart, CoreSetup } from 'src/core/public'; // @ts-ignore -import { wrapInI18nContext } from 'ui/i18n'; -// @ts-ignore import { Start as InspectorStartContract } from 'src/plugins/inspector/public'; // @ts-ignore +import { wrapInI18nContext } from 'ui/i18n'; +// @ts-ignore import { MapListing } from './components/map_listing'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { - setInspector, - setIndexPatternSelect, - setTimeFilter, - setInjectedVarFunc, - setIndexPatternService, -} from './kibana_services'; // @ts-ignore import { bindSetupCoreAndPlugins as bindNpSetupCoreAndPlugins, @@ -62,20 +47,6 @@ interface MapsPluginStartDependencies { // file_upload TODO: Export type from file upload and use here } -export const bindSetupCoreAndPlugins = (core: CoreSetup, plugins: any) => { - const { injectedMetadata } = core; - setInjectedVarFunc(injectedMetadata.getInjectedVar); - setInjectedVarFunc(core.injectedMetadata.getInjectedVar); -}; - -export const bindStartCoreAndPlugins = (core: CoreStart, plugins: any) => { - const { data, inspector } = plugins; - setInspector(inspector); - setIndexPatternSelect(data.ui.IndexPatternSelect); - setTimeFilter(data.query.timefilter.timefilter); - setIndexPatternService(data.indexPatterns); -}; - /** @internal */ export class MapsPlugin implements Plugin { public setup(core: CoreSetup, { __LEGACY: { uiModules }, np }: MapsPluginSetupDependencies) { @@ -85,14 +56,12 @@ export class MapsPlugin implements Plugin { return reactDirective(wrapInI18nContext(MapListing)); }); - bindSetupCoreAndPlugins(core, np); bindNpSetupCoreAndPlugins(core, np); np.home.featureCatalogue.register(featureCatalogueEntry); } public start(core: CoreStart, plugins: MapsPluginStartDependencies) { - bindStartCoreAndPlugins(core, plugins); bindNpStartCoreAndPlugins(core, plugins); } } diff --git a/x-pack/legacy/plugins/maps/public/register_vis_type_alias.js b/x-pack/legacy/plugins/maps/public/register_vis_type_alias.js index 64a42173098ee..9dc07bcb5dc0e 100644 --- a/x-pack/legacy/plugins/maps/public/register_vis_type_alias.js +++ b/x-pack/legacy/plugins/maps/public/register_vis_type_alias.js @@ -4,12 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import chrome from 'ui/chrome'; -import { npSetup } from '../../../../../src/legacy/ui/public/new_platform'; import { i18n } from '@kbn/i18n'; import { APP_ID, APP_ICON, MAP_BASE_URL } from '../common/constants'; +import { + getInjectedVarFunc, + getVisualizations, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../plugins/maps/public/kibana_services'; +import { npSetup } from 'ui/new_platform'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { bindSetupCoreAndPlugins } from '../../../../plugins/maps/public/plugin'; -const showMapVisualizationTypes = chrome.getInjected('showMapVisualizationTypes', false); +bindSetupCoreAndPlugins(npSetup.core, npSetup.plugins); + +const showMapVisualizationTypes = getInjectedVarFunc()('showMapVisualizationTypes', false); const description = i18n.translate('xpack.maps.visTypeAlias.description', { defaultMessage: 'Create and style maps with multiple layers and indices.', @@ -23,7 +31,7 @@ The Maps app offers more functionality and is easier to use.`, } ); -npSetup.plugins.visualizations.registerAlias({ +getVisualizations().registerAlias({ aliasUrl: MAP_BASE_URL, name: APP_ID, title: i18n.translate('xpack.maps.visTypeAlias.title', { @@ -37,5 +45,5 @@ npSetup.plugins.visualizations.registerAlias({ }); if (!showMapVisualizationTypes) { - npSetup.plugins.visualizations.hideTypes(['region_map', 'tile_map']); + getVisualizations().hideTypes(['region_map', 'tile_map']); } diff --git a/x-pack/legacy/plugins/maps/public/routes.js b/x-pack/legacy/plugins/maps/public/routes.js index 49705acb417e2..c082e0e1352c0 100644 --- a/x-pack/legacy/plugins/maps/public/routes.js +++ b/x-pack/legacy/plugins/maps/public/routes.js @@ -5,20 +5,23 @@ */ import { i18n } from '@kbn/i18n'; -import { capabilities } from 'ui/capabilities'; -import chrome from 'ui/chrome'; import routes from 'ui/routes'; -import { docTitle } from 'ui/doc_title'; import listingTemplate from './angular/listing_ng_wrapper.html'; import mapTemplate from './angular/map.html'; -import { npStart } from 'ui/new_platform'; +import { + getSavedObjectsClient, + getCoreChrome, + getMapsCapabilities, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../plugins/maps/public/kibana_services'; +import { getMapsSavedObjectLoader } from './angular/services/gis_map_saved_object_loader'; routes.enable(); routes .defaults(/.*/, { - badge: uiCapabilities => { - if (uiCapabilities.maps.save) { + badge: () => { + if (getMapsCapabilities().save) { return undefined; } @@ -35,7 +38,8 @@ routes }) .when('/', { template: listingTemplate, - controller($scope, gisMapSavedObjectLoader, config) { + controller($scope, config) { + const gisMapSavedObjectLoader = getMapsSavedObjectLoader(); $scope.listingLimit = config.get('savedObjects:listingLimit'); $scope.find = search => { return gisMapSavedObjectLoader.find(search, $scope.listingLimit); @@ -43,19 +47,17 @@ routes $scope.delete = ids => { return gisMapSavedObjectLoader.delete(ids); }; - $scope.readOnly = !capabilities.get().maps.save; + $scope.readOnly = !getMapsCapabilities().save; }, resolve: { hasMaps: function(kbnUrl) { - chrome - .getSavedObjectsClient() + getSavedObjectsClient() .find({ type: 'map', perPage: 1 }) .then(resp => { // Do not show empty listing page, just redirect to a new map if (resp.savedObjects.length === 0) { kbnUrl.redirect('/map'); } - return true; }); }, @@ -65,7 +67,8 @@ routes template: mapTemplate, controller: 'GisMapController', resolve: { - map: function(gisMapSavedObjectLoader, redirectWhenMissing) { + map: function(redirectWhenMissing) { + const gisMapSavedObjectLoader = getMapsSavedObjectLoader(); return gisMapSavedObjectLoader.get().catch( redirectWhenMissing({ map: '/', @@ -78,13 +81,14 @@ routes template: mapTemplate, controller: 'GisMapController', resolve: { - map: function(gisMapSavedObjectLoader, redirectWhenMissing, $route) { + map: function(redirectWhenMissing, $route) { + const gisMapSavedObjectLoader = getMapsSavedObjectLoader(); const id = $route.current.params.id; return gisMapSavedObjectLoader .get(id) .then(savedMap => { - npStart.core.chrome.recentlyAccessed.add(savedMap.getFullPath(), savedMap.title, id); - docTitle.change(savedMap.title); + getCoreChrome().recentlyAccessed.add(savedMap.getFullPath(), savedMap.title, id); + getCoreChrome().docTitle.change(savedMap.title); return savedMap; }) .catch( diff --git a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js index 59346e4c6fb98..1e71025935519 100644 --- a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js +++ b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js @@ -16,7 +16,10 @@ import { VectorLayer } from '../../../../../plugins/maps/public/layers/vector_la import { HeatmapLayer } from '../../../../../plugins/maps/public/layers/heatmap_layer'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { BlendedVectorLayer } from '../../../../../plugins/maps/public/layers/blended_vector_layer'; -import { getTimeFilter } from '../kibana_services'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getTimeFilter } from '../../../../../plugins/maps/public/kibana_services'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { TiledVectorLayer } from '../../../../../plugins/maps/public/layers/tiled_vector_layer'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { getInspectorAdapters } from '../../../../../plugins/maps/public/reducers/non_serializable_instances'; import { @@ -50,6 +53,8 @@ function createLayerInstance(layerDescriptor, inspectorAdapters) { return new HeatmapLayer({ layerDescriptor, source }); case BlendedVectorLayer.type: return new BlendedVectorLayer({ layerDescriptor, source }); + case TiledVectorLayer.type: + return new TiledVectorLayer({ layerDescriptor, source }); default: throw new Error(`Unrecognized layerType ${layerDescriptor.type}`); } diff --git a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.test.js b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.test.js index 77bd29259647c..72cc748617540 100644 --- a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.test.js +++ b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.test.js @@ -5,6 +5,7 @@ */ jest.mock('../../../../../plugins/maps/public/layers/vector_layer', () => {}); +jest.mock('../../../../../plugins/maps/public/layers/tiled_vector_layer', () => {}); jest.mock('../../../../../plugins/maps/public/layers/blended_vector_layer', () => {}); jest.mock('../../../../../plugins/maps/public/layers/heatmap_layer', () => {}); jest.mock('../../../../../plugins/maps/public/layers/vector_tile_layer', () => {}); @@ -14,7 +15,7 @@ jest.mock('../../../../../plugins/maps/public/reducers/non_serializable_instance return {}; }, })); -jest.mock('../kibana_services', () => ({ +jest.mock('../../../../../plugins/maps/public/kibana_services', () => ({ getTimeFilter: () => ({ getTime: () => { return { diff --git a/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.ts b/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.ts index 5657e14622e9b..27c0211446e85 100644 --- a/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.ts +++ b/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.ts @@ -12,7 +12,7 @@ import { } from 'src/core/server'; import { IFieldType, IIndexPattern } from 'src/plugins/data/public'; import { - EMS_FILE, + SOURCE_TYPES, ES_GEO_FIELD_TYPE, MAP_SAVED_OBJECT_TYPE, TELEMETRY_TYPE, @@ -87,6 +87,8 @@ export function buildMapsTelemetry({ const mapsCount = layerLists.length; const dataSourcesCount = layerLists.map(lList => { + // todo: not every source-descriptor has an id + // @ts-ignore const sourceIdList = lList.map((layer: LayerDescriptor) => layer.sourceDescriptor.id); return _.uniq(sourceIdList).length; }); @@ -98,7 +100,7 @@ export function buildMapsTelemetry({ const emsLayersCount = layerLists.map(lList => _(lList) .countBy((layer: LayerDescriptor) => { - const isEmsFile = _.get(layer, 'sourceDescriptor.type') === EMS_FILE; + const isEmsFile = _.get(layer, 'sourceDescriptor.type') === SOURCE_TYPES.EMS_FILE; return isEmsFile && _.get(layer, 'sourceDescriptor.id'); }) .pick((val, key) => key !== 'false') diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/flyout/__snapshots__/flyout.test.js.snap b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/flyout/__snapshots__/flyout.test.js.snap index eb1c65c6a696d..4a7537166bd8a 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/flyout/__snapshots__/flyout.test.js.snap +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/flyout/__snapshots__/flyout.test.js.snap @@ -161,7 +161,7 @@ exports[`Flyout apm part two should show instructions to migrate to metricbeat 1 "children":

", - "maxScreenshotDimension": 1950, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, - "concurrency": 4, - "loadDelay": 3000, - "maxAttempts": 1, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "protocol": "http:", - }, - Object { - "allow": true, - "protocol": "https:", - }, - Object { - "allow": true, - "protocol": "ws:", - }, - Object { - "allow": true, - "protocol": "wss:", - }, - Object { - "allow": true, - "protocol": "data:", - }, - Object { - "allow": false, - }, - ], - }, - "settleTime": 1000, - "timeout": 20000, - "timeouts": Object { - "openUrl": 30000, - "renderComplete": 30000, - "waitForElements": 30000, - }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, - "zoom": 2, - }, - "csv": Object { - "checkForFormulas": true, - "enablePanelActionDownload": true, - "maxSizeBytes": 10485760, - "scroll": Object { - "duration": "30s", - "size": 500, - }, - "useByteOrderMarkEncoding": false, - }, - "enabled": true, - "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "index": ".reporting", - "kibanaServer": Object {}, - "poll": Object { - "jobCompletionNotifier": Object { - "interval": 10000, - "intervalErrorMultiplier": 5, - }, - "jobsRefresh": Object { - "interval": 5000, - "intervalErrorMultiplier": 5, - }, - }, - "queue": Object { - "indexInterval": "week", - "pollEnabled": true, - "pollInterval": 3000, - "pollIntervalErrorMultiplier": 10, - "timeout": 120000, - }, - "roles": Object { - "allow": Array [ - "reporting_user", - ], - }, -} -`; - -exports[`config schema with context {"dev":false,"dist":true} produces correct config 1`] = ` -Object { - "capture": Object { - "browser": Object { - "autoDownload": false, - "chromium": Object { - "disableSandbox": "", - "maxScreenshotDimension": 1950, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, - "concurrency": 4, - "loadDelay": 3000, - "maxAttempts": 3, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "protocol": "http:", - }, - Object { - "allow": true, - "protocol": "https:", - }, - Object { - "allow": true, - "protocol": "ws:", - }, - Object { - "allow": true, - "protocol": "wss:", - }, - Object { - "allow": true, - "protocol": "data:", - }, - Object { - "allow": false, - }, - ], - }, - "settleTime": 1000, - "timeout": 20000, - "timeouts": Object { - "openUrl": 30000, - "renderComplete": 30000, - "waitForElements": 30000, - }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, - "zoom": 2, - }, - "csv": Object { - "checkForFormulas": true, - "enablePanelActionDownload": true, - "maxSizeBytes": 10485760, - "scroll": Object { - "duration": "30s", - "size": 500, - }, - "useByteOrderMarkEncoding": false, - }, - "enabled": true, - "index": ".reporting", - "kibanaServer": Object {}, - "poll": Object { - "jobCompletionNotifier": Object { - "interval": 10000, - "intervalErrorMultiplier": 5, - }, - "jobsRefresh": Object { - "interval": 5000, - "intervalErrorMultiplier": 5, - }, - }, - "queue": Object { - "indexInterval": "week", - "pollEnabled": true, - "pollInterval": 3000, - "pollIntervalErrorMultiplier": 10, - "timeout": 120000, - }, - "roles": Object { - "allow": Array [ - "reporting_user", - ], - }, -} -`; - -exports[`config schema with context {"dev":true,"dist":false} produces correct config 1`] = ` -Object { - "capture": Object { - "browser": Object { - "autoDownload": true, - "chromium": Object { - "disableSandbox": "", - "maxScreenshotDimension": 1950, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, - "concurrency": 4, - "loadDelay": 3000, - "maxAttempts": 1, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "protocol": "http:", - }, - Object { - "allow": true, - "protocol": "https:", - }, - Object { - "allow": true, - "protocol": "ws:", - }, - Object { - "allow": true, - "protocol": "wss:", - }, - Object { - "allow": true, - "protocol": "data:", - }, - Object { - "allow": false, - }, - ], - }, - "settleTime": 1000, - "timeout": 20000, - "timeouts": Object { - "openUrl": 30000, - "renderComplete": 30000, - "waitForElements": 30000, - }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, - "zoom": 2, - }, - "csv": Object { - "checkForFormulas": true, - "enablePanelActionDownload": true, - "maxSizeBytes": 10485760, - "scroll": Object { - "duration": "30s", - "size": 500, - }, - "useByteOrderMarkEncoding": false, - }, - "enabled": true, - "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "index": ".reporting", - "kibanaServer": Object {}, - "poll": Object { - "jobCompletionNotifier": Object { - "interval": 10000, - "intervalErrorMultiplier": 5, - }, - "jobsRefresh": Object { - "interval": 5000, - "intervalErrorMultiplier": 5, - }, - }, - "queue": Object { - "indexInterval": "week", - "pollEnabled": true, - "pollInterval": 3000, - "pollIntervalErrorMultiplier": 10, - "timeout": 120000, - }, - "roles": Object { - "allow": Array [ - "reporting_user", - ], - }, -} -`; - -exports[`config schema with context {"dev":true,"dist":true} produces correct config 1`] = ` -Object { - "capture": Object { - "browser": Object { - "autoDownload": false, - "chromium": Object { - "disableSandbox": "", - "maxScreenshotDimension": 1950, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, - "concurrency": 4, - "loadDelay": 3000, - "maxAttempts": 3, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "protocol": "http:", - }, - Object { - "allow": true, - "protocol": "https:", - }, - Object { - "allow": true, - "protocol": "ws:", - }, - Object { - "allow": true, - "protocol": "wss:", - }, - Object { - "allow": true, - "protocol": "data:", - }, - Object { - "allow": false, - }, - ], - }, - "settleTime": 1000, - "timeout": 20000, - "timeouts": Object { - "openUrl": 30000, - "renderComplete": 30000, - "waitForElements": 30000, - }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, - "zoom": 2, - }, - "csv": Object { - "checkForFormulas": true, - "enablePanelActionDownload": true, - "maxSizeBytes": 10485760, - "scroll": Object { - "duration": "30s", - "size": 500, - }, - "useByteOrderMarkEncoding": false, - }, - "enabled": true, - "index": ".reporting", - "kibanaServer": Object {}, - "poll": Object { - "jobCompletionNotifier": Object { - "interval": 10000, - "intervalErrorMultiplier": 5, - }, - "jobsRefresh": Object { - "interval": 5000, - "intervalErrorMultiplier": 5, - }, - }, - "queue": Object { - "indexInterval": "week", - "pollEnabled": true, - "pollInterval": 3000, - "pollIntervalErrorMultiplier": 10, - "timeout": 120000, - }, - "roles": Object { - "allow": Array [ - "reporting_user", - ], - }, -} -`; diff --git a/x-pack/legacy/plugins/reporting/config.ts b/x-pack/legacy/plugins/reporting/config.ts deleted file mode 100644 index 5eceb84c83e43..0000000000000 --- a/x-pack/legacy/plugins/reporting/config.ts +++ /dev/null @@ -1,183 +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 { BROWSER_TYPE } from './common/constants'; -// @ts-ignore untyped module -import { config as appConfig } from './server/config/config'; -import { getDefaultChromiumSandboxDisabled } from './server/browsers'; - -export async function config(Joi: any) { - return Joi.object({ - enabled: Joi.boolean().default(true), - kibanaServer: Joi.object({ - protocol: Joi.string().valid(['http', 'https']), - hostname: Joi.string().invalid('0'), - port: Joi.number().integer(), - }).default(), - queue: Joi.object({ - indexInterval: Joi.string().default('week'), - pollEnabled: Joi.boolean().default(true), - pollInterval: Joi.number() - .integer() - .default(3000), - pollIntervalErrorMultiplier: Joi.number() - .integer() - .default(10), - timeout: Joi.number() - .integer() - .default(120000), - }).default(), - capture: Joi.object({ - timeouts: Joi.object({ - openUrl: Joi.number() - .integer() - .default(30000), - waitForElements: Joi.number() - .integer() - .default(30000), - renderComplete: Joi.number() - .integer() - .default(30000), - }).default(), - networkPolicy: Joi.object({ - enabled: Joi.boolean().default(true), - rules: Joi.array() - .items( - Joi.object({ - allow: Joi.boolean().required(), - protocol: Joi.string(), - host: Joi.string(), - }) - ) - .default([ - { allow: true, protocol: 'http:' }, - { allow: true, protocol: 'https:' }, - { allow: true, protocol: 'ws:' }, - { allow: true, protocol: 'wss:' }, - { allow: true, protocol: 'data:' }, - { allow: false }, // Default action is to deny! - ]), - }).default(), - zoom: Joi.number() - .integer() - .default(2), - viewport: Joi.object({ - width: Joi.number() - .integer() - .default(1950), - height: Joi.number() - .integer() - .default(1200), - }).default(), - timeout: Joi.number() - .integer() - .default(20000), // deprecated - loadDelay: Joi.number() - .integer() - .default(3000), - settleTime: Joi.number() - .integer() - .default(1000), // deprecated - concurrency: Joi.number() - .integer() - .default(appConfig.concurrency), // deprecated - browser: Joi.object({ - type: Joi.any() - .valid(BROWSER_TYPE) - .default(BROWSER_TYPE), - autoDownload: Joi.boolean().when('$dist', { - is: true, - then: Joi.default(false), - otherwise: Joi.default(true), - }), - chromium: Joi.object({ - inspect: Joi.boolean() - .when('$dev', { - is: false, - then: Joi.valid(false), - else: Joi.default(false), - }) - .default(), - disableSandbox: Joi.boolean().default(await getDefaultChromiumSandboxDisabled()), - proxy: Joi.object({ - enabled: Joi.boolean().default(false), - server: Joi.string() - .uri({ scheme: ['http', 'https'] }) - .when('enabled', { - is: Joi.valid(false), - then: Joi.valid(null), - else: Joi.required(), - }), - bypass: Joi.array() - .items(Joi.string().regex(/^[^\s]+$/)) - .when('enabled', { - is: Joi.valid(false), - then: Joi.valid(null), - else: Joi.default([]), - }), - }).default(), - maxScreenshotDimension: Joi.number() - .integer() - .default(1950), - }).default(), - }).default(), - maxAttempts: Joi.number() - .integer() - .greater(0) - .when('$dist', { - is: true, - then: Joi.default(3), - otherwise: Joi.default(1), - }) - .default(), - }).default(), - csv: Joi.object({ - useByteOrderMarkEncoding: Joi.boolean().default(false), - checkForFormulas: Joi.boolean().default(true), - enablePanelActionDownload: Joi.boolean().default(true), - maxSizeBytes: Joi.number() - .integer() - .default(1024 * 1024 * 10), // bytes in a kB * kB in a mB * 10 - scroll: Joi.object({ - duration: Joi.string() - .regex(/^[0-9]+(d|h|m|s|ms|micros|nanos)$/, { name: 'DurationString' }) - .default('30s'), - size: Joi.number() - .integer() - .default(500), - }).default(), - }).default(), - encryptionKey: Joi.when(Joi.ref('$dist'), { - is: true, - then: Joi.string(), - otherwise: Joi.string().default('a'.repeat(32)), - }), - roles: Joi.object({ - allow: Joi.array() - .items(Joi.string()) - .default(['reporting_user']), - }).default(), - index: Joi.string().default('.reporting'), - poll: Joi.object({ - jobCompletionNotifier: Joi.object({ - interval: Joi.number() - .integer() - .default(10000), - intervalErrorMultiplier: Joi.number() - .integer() - .default(5), - }).default(), - jobsRefresh: Joi.object({ - interval: Joi.number() - .integer() - .default(5000), - intervalErrorMultiplier: Joi.number() - .integer() - .default(5), - }).default(), - }).default(), - }).default(); -} diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/layout.ts index 2c43517dbcaa9..5cd2f3e636a93 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/layout.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/layout.ts @@ -54,7 +54,7 @@ export abstract class Layout { public abstract getPdfPageSize(pageSizeParams: PageSizeParams): string | Size; - public abstract getViewport(itemsCount: number): ViewZoomWidthHeight; + public abstract getViewport(itemsCount: number): ViewZoomWidthHeight | null; public abstract getBrowserZoom(): number; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts index 75ac3dca4ffa0..68d660257a56d 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts @@ -22,6 +22,7 @@ import { LevelLogger } from '../../../../server/lib'; import { createMockBrowserDriverFactory, createMockLayoutInstance } from '../../../../test_helpers'; import { ConditionalHeaders, HeadlessChromiumDriver } from '../../../../types'; import { CaptureConfig } from '../../../../server/types'; +import * as contexts from './constants'; import { screenshotsObservableFactory } from './observable'; import { ElementsPositionAndAttribute } from './types'; @@ -57,10 +58,30 @@ describe('Screenshot Observable Pipeline', () => { expect(result).toMatchInlineSnapshot(` Array [ Object { + "elementsPositionAndAttributes": Array [ + Object { + "attributes": Object { + "description": "Default ", + "title": "Default Mock Title", + }, + "position": Object { + "boundingClientRect": Object { + "height": 600, + "left": 0, + "top": 0, + "width": 800, + }, + "scroll": Object { + "x": 0, + "y": 0, + }, + }, + }, + ], "error": undefined, "screenshots": Array [ Object { - "base64EncodedData": "allyourBase64 of boundingClientRect,scroll", + "base64EncodedData": "allyourBase64", "description": "Default ", "title": "Default Mock Title", }, @@ -95,6 +116,26 @@ describe('Screenshot Observable Pipeline', () => { expect(result).toMatchInlineSnapshot(` Array [ Object { + "elementsPositionAndAttributes": Array [ + Object { + "attributes": Object { + "description": "Default ", + "title": "Default Mock Title", + }, + "position": Object { + "boundingClientRect": Object { + "height": 600, + "left": 0, + "top": 0, + "width": 800, + }, + "scroll": Object { + "x": 0, + "y": 0, + }, + }, + }, + ], "error": undefined, "screenshots": Array [ Object { @@ -106,6 +147,26 @@ describe('Screenshot Observable Pipeline', () => { "timeRange": "Default GetTimeRange Result", }, Object { + "elementsPositionAndAttributes": Array [ + Object { + "attributes": Object { + "description": "Default ", + "title": "Default Mock Title", + }, + "position": Object { + "boundingClientRect": Object { + "height": 600, + "left": 0, + "top": 0, + "width": 800, + }, + "scroll": Object { + "x": 0, + "y": 0, + }, + }, + }, + ], "error": undefined, "screenshots": Array [ Object { @@ -150,10 +211,27 @@ describe('Screenshot Observable Pipeline', () => { await expect(getScreenshot()).resolves.toMatchInlineSnapshot(` Array [ Object { + "elementsPositionAndAttributes": Array [ + Object { + "attributes": Object {}, + "position": Object { + "boundingClientRect": Object { + "height": 200, + "left": 0, + "top": 0, + "width": 200, + }, + "scroll": Object { + "x": 0, + "y": 0, + }, + }, + }, + ], "error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!], "screenshots": Array [ Object { - "base64EncodedData": "allyourBase64 of boundingClientRect,scroll", + "base64EncodedData": "allyourBase64", "description": undefined, "title": undefined, }, @@ -161,10 +239,27 @@ describe('Screenshot Observable Pipeline', () => { "timeRange": null, }, Object { + "elementsPositionAndAttributes": Array [ + Object { + "attributes": Object {}, + "position": Object { + "boundingClientRect": Object { + "height": 200, + "left": 0, + "top": 0, + "width": 200, + }, + "scroll": Object { + "x": 0, + "y": 0, + }, + }, + }, + ], "error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!], "screenshots": Array [ Object { - "base64EncodedData": "allyourBase64 of boundingClientRect,scroll", + "base64EncodedData": "allyourBase64", "description": undefined, "title": undefined, }, @@ -208,10 +303,27 @@ describe('Screenshot Observable Pipeline', () => { await expect(getScreenshot()).resolves.toMatchInlineSnapshot(` Array [ Object { + "elementsPositionAndAttributes": Array [ + Object { + "attributes": Object {}, + "position": Object { + "boundingClientRect": Object { + "height": 200, + "left": 0, + "top": 0, + "width": 200, + }, + "scroll": Object { + "x": 0, + "y": 0, + }, + }, + }, + ], "error": "Instant timeout has fired!", "screenshots": Array [ Object { - "base64EncodedData": "allyourBase64 of boundingClientRect,scroll", + "base64EncodedData": "allyourBase64", "description": undefined, "title": undefined, }, @@ -221,5 +333,69 @@ describe('Screenshot Observable Pipeline', () => { ] `); }); + + it(`uses defaults for element positions and size when Kibana page is not ready`, async () => { + // mocks + const mockBrowserEvaluate = jest.fn(); + mockBrowserEvaluate.mockImplementation(() => { + const lastCallIndex = mockBrowserEvaluate.mock.calls.length - 1; + const { context: mockCall } = mockBrowserEvaluate.mock.calls[lastCallIndex][1]; + + if (mockCall === contexts.CONTEXT_ELEMENTATTRIBUTES) { + return Promise.resolve(null); + } else { + return Promise.resolve(); + } + }); + mockBrowserDriverFactory = await createMockBrowserDriverFactory(logger, { + evaluate: mockBrowserEvaluate, + }); + mockLayout.getViewport = () => null; + + // test + const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshot = async () => { + return await getScreenshots$({ + logger, + urls: ['/welcome/home/start/index.php3?page=./home.php3'], + conditionalHeaders: {} as ConditionalHeaders, + layout: mockLayout, + browserTimezone: 'UTC', + }).toPromise(); + }; + + await expect(getScreenshot()).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "elementsPositionAndAttributes": Array [ + Object { + "attributes": Object {}, + "position": Object { + "boundingClientRect": Object { + "height": 1200, + "left": 0, + "top": 0, + "width": 1800, + }, + "scroll": Object { + "x": 0, + "y": 0, + }, + }, + }, + ], + "error": undefined, + "screenshots": Array [ + Object { + "base64EncodedData": "allyourBase64", + "description": undefined, + "title": undefined, + }, + ], + "timeRange": undefined, + }, + ] + `); + }); }); }); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts index 53a11c18abd79..519a3289395b9 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts @@ -18,6 +18,9 @@ import { ScreenSetupData, ScreenshotObservableOpts, ScreenshotResults } from './ import { waitForRenderComplete } from './wait_for_render'; import { waitForVisualizations } from './wait_for_visualizations'; +const DEFAULT_SCREENSHOT_CLIP_HEIGHT = 1200; +const DEFAULT_SCREENSHOT_CLIP_WIDTH = 1800; + export function screenshotsObservableFactory( captureConfig: CaptureConfig, browserDriverFactory: HeadlessChromiumDriverFactory @@ -42,7 +45,7 @@ export function screenshotsObservableFactory( mergeMap(() => openUrl(captureConfig, driver, url, conditionalHeaders, logger)), mergeMap(() => getNumberOfItems(captureConfig, driver, layout, logger)), mergeMap(async itemsCount => { - const viewport = layout.getViewport(itemsCount); + const viewport = layout.getViewport(itemsCount) || getDefaultViewPort(); await Promise.all([ driver.setViewport(viewport, logger), waitForVisualizations(captureConfig, driver, itemsCount, layout, logger), @@ -83,7 +86,12 @@ export function screenshotsObservableFactory( : getDefaultElementPosition(layout.getViewport(1)); const screenshots = await getScreenshots(driver, elements, logger); const { timeRange, error: setupError } = data; - return { timeRange, screenshots, error: setupError }; + return { + timeRange, + screenshots, + error: setupError, + elementsPositionAndAttributes: elements, + }; } ) ); @@ -97,17 +105,30 @@ export function screenshotsObservableFactory( }; } +/* + * If Kibana is showing a non-HTML error message, the viewport might not be + * provided by the browser. + */ +const getDefaultViewPort = () => ({ + height: DEFAULT_SCREENSHOT_CLIP_HEIGHT, + width: DEFAULT_SCREENSHOT_CLIP_WIDTH, + zoom: 1, +}); /* * If an error happens setting up the page, we don't know if there actually * are any visualizations showing. These defaults should help capture the page * enough for the user to see the error themselves */ -const getDefaultElementPosition = ({ height, width }: { height: number; width: number }) => [ - { +const getDefaultElementPosition = (dimensions: { height?: number; width?: number } | null) => { + const height = dimensions?.height || DEFAULT_SCREENSHOT_CLIP_HEIGHT; + const width = dimensions?.width || DEFAULT_SCREENSHOT_CLIP_WIDTH; + + const defaultObject = { position: { boundingClientRect: { top: 0, left: 0, height, width }, scroll: { x: 0, y: 0 }, }, attributes: {}, - }, -]; + }; + return [defaultObject]; +}; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts index 76613c2d631d6..e113a5d228cd7 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts @@ -45,4 +45,5 @@ export interface ScreenshotResults { timeRange: TimeRange | null; screenshots: Screenshot[]; error?: Error; + elementsPositionAndAttributes?: ElementsPositionAndAttribute[]; // NOTE: for testing } diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js deleted file mode 100644 index 4870e1e35cdaf..0000000000000 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js +++ /dev/null @@ -1,993 +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 Puid from 'puid'; -import sinon from 'sinon'; -import nodeCrypto from '@elastic/node-crypto'; -import { CancellationToken } from '../../../common/cancellation_token'; -import { fieldFormats } from '../../../../../../../src/plugins/data/server'; -import { createMockReportingCore } from '../../../test_helpers'; -import { LevelLogger } from '../../../server/lib/level_logger'; -import { setFieldFormats } from '../../../server/services'; -import { executeJobFactory } from './execute_job'; -import { CSV_BOM_CHARS } from '../../../common/constants'; - -const delay = ms => new Promise(resolve => setTimeout(() => resolve(), ms)); - -const puid = new Puid(); -const getRandomScrollId = () => { - return puid.generate(); -}; - -describe('CSV Execute Job', function() { - const encryptionKey = 'testEncryptionKey'; - const headers = { - sid: 'test', - }; - const mockLogger = new LevelLogger({ - get: () => ({ - debug: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }), - }); - let defaultElasticsearchResponse; - let encryptedHeaders; - - let clusterStub; - let configGetStub; - let mockReportingConfig; - let mockReportingPlugin; - let callAsCurrentUserStub; - let cancellationToken; - - const mockElasticsearch = { - dataClient: { - asScoped: () => clusterStub, - }, - }; - const mockUiSettingsClient = { - get: sinon.stub(), - }; - - beforeAll(async function() { - const crypto = nodeCrypto({ encryptionKey }); - encryptedHeaders = await crypto.encrypt(headers); - }); - - beforeEach(async function() { - configGetStub = sinon.stub(); - configGetStub.withArgs('encryptionKey').returns(encryptionKey); - configGetStub.withArgs('csv', 'maxSizeBytes').returns(1024 * 1000); // 1mB - configGetStub.withArgs('csv', 'scroll').returns({}); - mockReportingConfig = { get: configGetStub, kbnConfig: { get: configGetStub } }; - - mockReportingPlugin = await createMockReportingCore(mockReportingConfig); - mockReportingPlugin.getUiSettingsServiceFactory = () => Promise.resolve(mockUiSettingsClient); - mockReportingPlugin.getElasticsearchService = () => Promise.resolve(mockElasticsearch); - - cancellationToken = new CancellationToken(); - - defaultElasticsearchResponse = { - hits: { - hits: [], - }, - _scroll_id: 'defaultScrollId', - }; - clusterStub = { - callAsCurrentUser: function() {}, - }; - - callAsCurrentUserStub = sinon - .stub(clusterStub, 'callAsCurrentUser') - .resolves(defaultElasticsearchResponse); - - mockUiSettingsClient.get.withArgs('csv:separator').returns(','); - mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(true); - - setFieldFormats({ - fieldFormatServiceFactory: function() { - const uiConfigMock = {}; - uiConfigMock['format:defaultTypeMap'] = { - _default_: { id: 'string', params: {} }, - }; - - const fieldFormatsRegistry = new fieldFormats.FieldFormatsRegistry(); - - fieldFormatsRegistry.init(key => uiConfigMock[key], {}, [fieldFormats.StringFormat]); - - return fieldFormatsRegistry; - }, - }); - }); - - describe('basic Elasticsearch call behavior', function() { - it('should decrypt encrypted headers and pass to callAsCurrentUser', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - await executeJob( - 'job456', - { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, - cancellationToken - ); - expect(callAsCurrentUserStub.called).toBe(true); - expect(callAsCurrentUserStub.firstCall.args[0]).toEqual('search'); - }); - - it('should pass the index and body to execute the initial search', async function() { - const index = 'index'; - const body = { - testBody: true, - }; - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const job = { - headers: encryptedHeaders, - fields: [], - searchRequest: { - index, - body, - }, - }; - - await executeJob('job777', job, cancellationToken); - - const searchCall = callAsCurrentUserStub.firstCall; - expect(searchCall.args[0]).toBe('search'); - expect(searchCall.args[1].index).toBe(index); - expect(searchCall.args[1].body).toBe(body); - }); - - it('should pass the scrollId from the initial search to the subsequent scroll', async function() { - const scrollId = getRandomScrollId(); - callAsCurrentUserStub.onFirstCall().resolves({ - hits: { - hits: [{}], - }, - _scroll_id: scrollId, - }); - callAsCurrentUserStub.onSecondCall().resolves(defaultElasticsearchResponse); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - await executeJob( - 'job456', - { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, - cancellationToken - ); - - const scrollCall = callAsCurrentUserStub.secondCall; - - expect(scrollCall.args[0]).toBe('scroll'); - expect(scrollCall.args[1].scrollId).toBe(scrollId); - }); - - it('should not execute scroll if there are no hits from the search', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - await executeJob( - 'job456', - { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, - cancellationToken - ); - - expect(callAsCurrentUserStub.callCount).toBe(2); - - const searchCall = callAsCurrentUserStub.firstCall; - expect(searchCall.args[0]).toBe('search'); - - const clearScrollCall = callAsCurrentUserStub.secondCall; - expect(clearScrollCall.args[0]).toBe('clearScroll'); - }); - - it('should stop executing scroll if there are no hits', async function() { - callAsCurrentUserStub.onFirstCall().resolves({ - hits: { - hits: [{}], - }, - _scroll_id: 'scrollId', - }); - callAsCurrentUserStub.onSecondCall().resolves({ - hits: { - hits: [], - }, - _scroll_id: 'scrollId', - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - await executeJob( - 'job456', - { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, - cancellationToken - ); - - expect(callAsCurrentUserStub.callCount).toBe(3); - - const searchCall = callAsCurrentUserStub.firstCall; - expect(searchCall.args[0]).toBe('search'); - - const scrollCall = callAsCurrentUserStub.secondCall; - expect(scrollCall.args[0]).toBe('scroll'); - - const clearScroll = callAsCurrentUserStub.thirdCall; - expect(clearScroll.args[0]).toBe('clearScroll'); - }); - - it('should call clearScroll with scrollId when there are no more hits', async function() { - const lastScrollId = getRandomScrollId(); - callAsCurrentUserStub.onFirstCall().resolves({ - hits: { - hits: [{}], - }, - _scroll_id: 'scrollId', - }); - - callAsCurrentUserStub.onSecondCall().resolves({ - hits: { - hits: [], - }, - _scroll_id: lastScrollId, - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - await executeJob( - 'job456', - { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, - cancellationToken - ); - - const lastCall = callAsCurrentUserStub.getCall(callAsCurrentUserStub.callCount - 1); - expect(lastCall.args[0]).toBe('clearScroll'); - expect(lastCall.args[1].scrollId).toEqual([lastScrollId]); - }); - - it('calls clearScroll when there is an error iterating the hits', async function() { - const lastScrollId = getRandomScrollId(); - callAsCurrentUserStub.onFirstCall().resolves({ - hits: { - hits: [ - { - _source: { - one: 'foo', - two: 'bar', - }, - }, - ], - }, - _scroll_id: lastScrollId, - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - conflictedTypesFields: undefined, - searchRequest: { index: null, body: null }, - }; - await expect( - executeJob('job123', jobParams, cancellationToken) - ).rejects.toMatchInlineSnapshot(`[TypeError: Cannot read property 'indexOf' of undefined]`); - - const lastCall = callAsCurrentUserStub.getCall(callAsCurrentUserStub.callCount - 1); - expect(lastCall.args[0]).toBe('clearScroll'); - expect(lastCall.args[1].scrollId).toEqual([lastScrollId]); - }); - }); - - describe('Cells with formula values', () => { - it('returns `csv_contains_formulas` when cells contain formulas', async function() { - configGetStub.withArgs('csv', 'checkForFormulas').returns(true); - callAsCurrentUserStub.onFirstCall().returns({ - hits: { - hits: [{ _source: { one: '=SUM(A1:A2)', two: 'bar' } }], - }, - _scroll_id: 'scrollId', - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - conflictedTypesFields: [], - searchRequest: { index: null, body: null }, - }; - const { csv_contains_formulas: csvContainsFormulas } = await executeJob( - 'job123', - jobParams, - cancellationToken - ); - - expect(csvContainsFormulas).toEqual(true); - }); - - it('returns warnings when headings contain formulas', async function() { - configGetStub.withArgs('csv', 'checkForFormulas').returns(true); - callAsCurrentUserStub.onFirstCall().returns({ - hits: { - hits: [{ _source: { '=SUM(A1:A2)': 'foo', two: 'bar' } }], - }, - _scroll_id: 'scrollId', - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['=SUM(A1:A2)', 'two'], - conflictedTypesFields: [], - searchRequest: { index: null, body: null }, - }; - const { csv_contains_formulas: csvContainsFormulas } = await executeJob( - 'job123', - jobParams, - cancellationToken - ); - - expect(csvContainsFormulas).toEqual(true); - }); - - it('returns no warnings when cells have no formulas', async function() { - configGetStub.withArgs('csv', 'checkForFormulas').returns(true); - callAsCurrentUserStub.onFirstCall().returns({ - hits: { - hits: [{ _source: { one: 'foo', two: 'bar' } }], - }, - _scroll_id: 'scrollId', - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - conflictedTypesFields: [], - searchRequest: { index: null, body: null }, - }; - const { csv_contains_formulas: csvContainsFormulas } = await executeJob( - 'job123', - jobParams, - cancellationToken - ); - - expect(csvContainsFormulas).toEqual(false); - }); - - it('returns no warnings when configured not to', async () => { - configGetStub.withArgs('csv', 'checkForFormulas').returns(false); - callAsCurrentUserStub.onFirstCall().returns({ - hits: { - hits: [{ _source: { one: '=SUM(A1:A2)', two: 'bar' } }], - }, - _scroll_id: 'scrollId', - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - conflictedTypesFields: [], - searchRequest: { index: null, body: null }, - }; - const { csv_contains_formulas: csvContainsFormulas } = await executeJob( - 'job123', - jobParams, - cancellationToken - ); - - expect(csvContainsFormulas).toEqual(false); - }); - }); - - describe('Byte order mark encoding', () => { - it('encodes CSVs with BOM', async () => { - configGetStub.withArgs('csv', 'useByteOrderMarkEncoding').returns(true); - callAsCurrentUserStub.onFirstCall().returns({ - hits: { - hits: [{ _source: { one: 'one', two: 'bar' } }], - }, - _scroll_id: 'scrollId', - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - conflictedTypesFields: [], - searchRequest: { index: null, body: null }, - }; - const { content } = await executeJob('job123', jobParams, cancellationToken); - - expect(content).toEqual(`${CSV_BOM_CHARS}one,two\none,bar\n`); - }); - - it('encodes CSVs without BOM', async () => { - configGetStub.withArgs('csv', 'useByteOrderMarkEncoding').returns(false); - callAsCurrentUserStub.onFirstCall().returns({ - hits: { - hits: [{ _source: { one: 'one', two: 'bar' } }], - }, - _scroll_id: 'scrollId', - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - conflictedTypesFields: [], - searchRequest: { index: null, body: null }, - }; - const { content } = await executeJob('job123', jobParams, cancellationToken); - - expect(content).toEqual('one,two\none,bar\n'); - }); - }); - - describe('Elasticsearch call errors', function() { - it('should reject Promise if search call errors out', async function() { - callAsCurrentUserStub.rejects(new Error()); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: [], - searchRequest: { index: null, body: null }, - }; - await expect( - executeJob('job123', jobParams, cancellationToken) - ).rejects.toMatchInlineSnapshot(`[Error]`); - }); - - it('should reject Promise if scroll call errors out', async function() { - callAsCurrentUserStub.onFirstCall().resolves({ - hits: { - hits: [{}], - }, - _scroll_id: 'scrollId', - }); - callAsCurrentUserStub.onSecondCall().rejects(new Error()); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: [], - searchRequest: { index: null, body: null }, - }; - await expect( - executeJob('job123', jobParams, cancellationToken) - ).rejects.toMatchInlineSnapshot(`[Error]`); - }); - }); - - describe('invalid responses', function() { - it('should reject Promise if search returns hits but no _scroll_id', async function() { - callAsCurrentUserStub.resolves({ - hits: { - hits: [{}], - }, - _scroll_id: undefined, - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: [], - searchRequest: { index: null, body: null }, - }; - await expect( - executeJob('job123', jobParams, cancellationToken) - ).rejects.toMatchInlineSnapshot( - `[Error: Expected _scroll_id in the following Elasticsearch response: {"hits":{"hits":[{}]}}]` - ); - }); - - it('should reject Promise if search returns no hits and no _scroll_id', async function() { - callAsCurrentUserStub.resolves({ - hits: { - hits: [], - }, - _scroll_id: undefined, - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: [], - searchRequest: { index: null, body: null }, - }; - await expect( - executeJob('job123', jobParams, cancellationToken) - ).rejects.toMatchInlineSnapshot( - `[Error: Expected _scroll_id in the following Elasticsearch response: {"hits":{"hits":[]}}]` - ); - }); - - it('should reject Promise if scroll returns hits but no _scroll_id', async function() { - callAsCurrentUserStub.onFirstCall().resolves({ - hits: { - hits: [{}], - }, - _scroll_id: 'scrollId', - }); - - callAsCurrentUserStub.onSecondCall().resolves({ - hits: { - hits: [{}], - }, - _scroll_id: undefined, - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: [], - searchRequest: { index: null, body: null }, - }; - await expect( - executeJob('job123', jobParams, cancellationToken) - ).rejects.toMatchInlineSnapshot( - `[Error: Expected _scroll_id in the following Elasticsearch response: {"hits":{"hits":[{}]}}]` - ); - }); - - it('should reject Promise if scroll returns no hits and no _scroll_id', async function() { - callAsCurrentUserStub.onFirstCall().resolves({ - hits: { - hits: [{}], - }, - _scroll_id: 'scrollId', - }); - - callAsCurrentUserStub.onSecondCall().resolves({ - hits: { - hits: [], - }, - _scroll_id: undefined, - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: [], - searchRequest: { index: null, body: null }, - }; - await expect( - executeJob('job123', jobParams, cancellationToken) - ).rejects.toMatchInlineSnapshot( - `[Error: Expected _scroll_id in the following Elasticsearch response: {"hits":{"hits":[]}}]` - ); - }); - }); - - describe('cancellation', function() { - const scrollId = getRandomScrollId(); - - beforeEach(function() { - // We have to "re-stub" the callAsCurrentUser stub here so that we can use the fakeFunction - // that delays the Promise resolution so we have a chance to call cancellationToken.cancel(). - // Otherwise, we get into an endless loop, and don't have a chance to call cancel - callAsCurrentUserStub.restore(); - callAsCurrentUserStub = sinon - .stub(clusterStub, 'callAsCurrentUser') - .callsFake(async function() { - await delay(1); - return { - hits: { - hits: [{}], - }, - _scroll_id: scrollId, - }; - }); - }); - - it('should stop calling Elasticsearch when cancellationToken.cancel is called', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - executeJob( - 'job345', - { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, - cancellationToken - ); - - await delay(250); - const callCount = callAsCurrentUserStub.callCount; - cancellationToken.cancel(); - await delay(250); - expect(callAsCurrentUserStub.callCount).toBe(callCount + 1); // last call is to clear the scroll - }); - - it(`shouldn't call clearScroll if it never got a scrollId`, async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - executeJob( - 'job345', - { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, - cancellationToken - ); - cancellationToken.cancel(); - - for (let i = 0; i < callAsCurrentUserStub.callCount; ++i) { - expect(callAsCurrentUserStub.getCall(i).args[1]).to.not.be('clearScroll'); - } - }); - - it('should call clearScroll if it got a scrollId', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - executeJob( - 'job345', - { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, - cancellationToken - ); - await delay(100); - cancellationToken.cancel(); - await delay(100); - - const lastCall = callAsCurrentUserStub.getCall(callAsCurrentUserStub.callCount - 1); - expect(lastCall.args[0]).toBe('clearScroll'); - expect(lastCall.args[1].scrollId).toEqual([scrollId]); - }); - }); - - describe('csv content', function() { - it('should write column headers to output, even if there are no results', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - searchRequest: { index: null, body: null }, - }; - const { content } = await executeJob('job123', jobParams, cancellationToken); - expect(content).toBe(`one,two\n`); - }); - - it('should use custom uiSettings csv:separator for header', async function() { - mockUiSettingsClient.get.withArgs('csv:separator').returns(';'); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - searchRequest: { index: null, body: null }, - }; - const { content } = await executeJob('job123', jobParams, cancellationToken); - expect(content).toBe(`one;two\n`); - }); - - it('should escape column headers if uiSettings csv:quoteValues is true', async function() { - mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(true); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one and a half', 'two', 'three-and-four', 'five & six'], - searchRequest: { index: null, body: null }, - }; - const { content } = await executeJob('job123', jobParams, cancellationToken); - expect(content).toBe(`"one and a half",two,"three-and-four","five & six"\n`); - }); - - it(`shouldn't escape column headers if uiSettings csv:quoteValues is false`, async function() { - mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(false); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one and a half', 'two', 'three-and-four', 'five & six'], - searchRequest: { index: null, body: null }, - }; - const { content } = await executeJob('job123', jobParams, cancellationToken); - expect(content).toBe(`one and a half,two,three-and-four,five & six\n`); - }); - - it('should write column headers to output, when there are results', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - callAsCurrentUserStub.onFirstCall().resolves({ - hits: { - hits: [{ one: '1', two: '2' }], - }, - _scroll_id: 'scrollId', - }); - - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - searchRequest: { index: null, body: null }, - }; - const { content } = await executeJob('job123', jobParams, cancellationToken); - const lines = content.split('\n'); - const headerLine = lines[0]; - expect(headerLine).toBe('one,two'); - }); - - it('should use comma separated values of non-nested fields from _source', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - callAsCurrentUserStub.onFirstCall().resolves({ - hits: { - hits: [{ _source: { one: 'foo', two: 'bar' } }], - }, - _scroll_id: 'scrollId', - }); - - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - conflictedTypesFields: [], - searchRequest: { index: null, body: null }, - }; - const { content } = await executeJob('job123', jobParams, cancellationToken); - const lines = content.split('\n'); - const valuesLine = lines[1]; - expect(valuesLine).toBe('foo,bar'); - }); - - it('should concatenate the hits from multiple responses', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - callAsCurrentUserStub.onFirstCall().resolves({ - hits: { - hits: [{ _source: { one: 'foo', two: 'bar' } }], - }, - _scroll_id: 'scrollId', - }); - callAsCurrentUserStub.onSecondCall().resolves({ - hits: { - hits: [{ _source: { one: 'baz', two: 'qux' } }], - }, - _scroll_id: 'scrollId', - }); - - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - conflictedTypesFields: [], - searchRequest: { index: null, body: null }, - }; - const { content } = await executeJob('job123', jobParams, cancellationToken); - const lines = content.split('\n'); - - expect(lines[1]).toBe('foo,bar'); - expect(lines[2]).toBe('baz,qux'); - }); - - it('should use field formatters to format fields', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - callAsCurrentUserStub.onFirstCall().resolves({ - hits: { - hits: [{ _source: { one: 'foo', two: 'bar' } }], - }, - _scroll_id: 'scrollId', - }); - - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - conflictedTypesFields: [], - searchRequest: { index: null, body: null }, - indexPatternSavedObject: { - id: 'logstash-*', - type: 'index-pattern', - attributes: { - title: 'logstash-*', - fields: '[{"name":"one","type":"string"}, {"name":"two","type":"string"}]', - fieldFormatMap: '{"one":{"id":"string","params":{"transform": "upper"}}}', - }, - }, - }; - const { content } = await executeJob('job123', jobParams, cancellationToken); - const lines = content.split('\n'); - - expect(lines[1]).toBe('FOO,bar'); - }); - }); - - describe('maxSizeBytes', function() { - // The following tests use explicitly specified lengths. UTF-8 uses between one and four 8-bit bytes for each - // code-point. However, any character that can be represented by ASCII requires one-byte, so a majority of the - // tests use these 'simple' characters to make the math easier - - describe('when only the headers exceed the maxSizeBytes', function() { - let content; - let maxSizeReached; - - beforeEach(async function() { - configGetStub.withArgs('csv', 'maxSizeBytes').returns(1); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - searchRequest: { index: null, body: null }, - }; - - ({ content, max_size_reached: maxSizeReached } = await executeJob( - 'job123', - jobParams, - cancellationToken - )); - }); - - it('should return max_size_reached', function() { - expect(maxSizeReached).toBe(true); - }); - - it('should return empty content', function() { - expect(content).toBe(''); - }); - }); - - describe('when headers are equal to maxSizeBytes', function() { - let content; - let maxSizeReached; - - beforeEach(async function() { - configGetStub.withArgs('csv', 'maxSizeBytes').returns(9); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - searchRequest: { index: null, body: null }, - }; - - ({ content, max_size_reached: maxSizeReached } = await executeJob( - 'job123', - jobParams, - cancellationToken - )); - }); - - it(`shouldn't return max_size_reached`, function() { - expect(maxSizeReached).toBe(false); - }); - - it(`should return content`, function() { - expect(content).toBe('one,two\n'); - }); - }); - - describe('when the data exceeds the maxSizeBytes', function() { - let content; - let maxSizeReached; - - beforeEach(async function() { - configGetStub.withArgs('csv', 'maxSizeBytes').returns(9); - - callAsCurrentUserStub.onFirstCall().returns({ - hits: { - hits: [{ _source: { one: 'foo', two: 'bar' } }], - }, - _scroll_id: 'scrollId', - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - conflictedTypesFields: [], - searchRequest: { index: null, body: null }, - }; - - ({ content, max_size_reached: maxSizeReached } = await executeJob( - 'job123', - jobParams, - cancellationToken - )); - }); - - it(`should return max_size_reached`, function() { - expect(maxSizeReached).toBe(true); - }); - - it(`should return the headers in the content`, function() { - expect(content).toBe('one,two\n'); - }); - }); - - describe('when headers and data equal the maxSizeBytes', function() { - let content; - let maxSizeReached; - - beforeEach(async function() { - mockReportingPlugin.getUiSettingsServiceFactory = () => mockUiSettingsClient; - configGetStub.withArgs('csv', 'maxSizeBytes').returns(18); - - callAsCurrentUserStub.onFirstCall().returns({ - hits: { - hits: [{ _source: { one: 'foo', two: 'bar' } }], - }, - _scroll_id: 'scrollId', - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - conflictedTypesFields: [], - searchRequest: { index: null, body: null }, - }; - - ({ content, max_size_reached: maxSizeReached } = await executeJob( - 'job123', - jobParams, - cancellationToken - )); - }); - - it(`shouldn't return max_size_reached`, async function() { - expect(maxSizeReached).toBe(false); - }); - - it('should return headers and data in content', function() { - expect(content).toBe('one,two\nfoo,bar\n'); - }); - }); - }); - - describe('scroll settings', function() { - it('passes scroll duration to initial search call', async function() { - const scrollDuration = 'test'; - configGetStub.withArgs('csv', 'scroll').returns({ duration: scrollDuration }); - - callAsCurrentUserStub.onFirstCall().returns({ - hits: { - hits: [{}], - }, - _scroll_id: 'scrollId', - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - conflictedTypesFields: [], - searchRequest: { index: null, body: null }, - }; - - await executeJob('job123', jobParams, cancellationToken); - - const searchCall = callAsCurrentUserStub.firstCall; - expect(searchCall.args[0]).toBe('search'); - expect(searchCall.args[1].scroll).toBe(scrollDuration); - }); - - it('passes scroll size to initial search call', async function() { - const scrollSize = 100; - configGetStub.withArgs('csv', 'scroll').returns({ size: scrollSize }); - - callAsCurrentUserStub.onFirstCall().resolves({ - hits: { - hits: [{}], - }, - _scroll_id: 'scrollId', - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - conflictedTypesFields: [], - searchRequest: { index: null, body: null }, - }; - - await executeJob('job123', jobParams, cancellationToken); - - const searchCall = callAsCurrentUserStub.firstCall; - expect(searchCall.args[0]).toBe('search'); - expect(searchCall.args[1].size).toBe(scrollSize); - }); - - it('passes scroll duration to subsequent scroll call', async function() { - const scrollDuration = 'test'; - configGetStub.withArgs('csv', 'scroll').returns({ duration: scrollDuration }); - - callAsCurrentUserStub.onFirstCall().resolves({ - hits: { - hits: [{}], - }, - _scroll_id: 'scrollId', - }); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); - const jobParams = { - headers: encryptedHeaders, - fields: ['one', 'two'], - conflictedTypesFields: [], - searchRequest: { index: null, body: null }, - }; - - await executeJob('job123', jobParams, cancellationToken); - - const scrollCall = callAsCurrentUserStub.secondCall; - expect(scrollCall.args[0]).toBe('scroll'); - expect(scrollCall.args[1].scroll).toBe(scrollDuration); - }); - }); -}); diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.ts b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.ts new file mode 100644 index 0000000000000..f0afade8629ab --- /dev/null +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.ts @@ -0,0 +1,1032 @@ +/* + * 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. + */ + +// @ts-ignore +import Puid from 'puid'; +import sinon from 'sinon'; +import nodeCrypto from '@elastic/node-crypto'; +import { CancellationToken } from '../../../common/cancellation_token'; +import { fieldFormats } from '../../../../../../../src/plugins/data/server'; +import { createMockReportingCore } from '../../../test_helpers'; +import { LevelLogger } from '../../../server/lib/level_logger'; +import { setFieldFormats } from '../../../server/services'; +import { executeJobFactory } from './execute_job'; +import { JobDocPayloadDiscoverCsv } from '../types'; +import { CSV_BOM_CHARS } from '../../../common/constants'; + +const delay = (ms: number) => new Promise(resolve => setTimeout(() => resolve(), ms)); + +const puid = new Puid(); +const getRandomScrollId = () => { + return puid.generate(); +}; + +const getJobDocPayload = (baseObj: any) => baseObj as JobDocPayloadDiscoverCsv; + +describe('CSV Execute Job', function() { + const encryptionKey = 'testEncryptionKey'; + const headers = { + sid: 'test', + }; + const mockLogger = new LevelLogger({ + get: () => + ({ + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as any), + }); + let defaultElasticsearchResponse: any; + let encryptedHeaders: any; + + let clusterStub: any; + let configGetStub: any; + let mockReportingConfig: any; + let mockReportingPlugin: any; + let callAsCurrentUserStub: any; + let cancellationToken: any; + + const mockElasticsearch = { + dataClient: { + asScoped: () => clusterStub, + }, + }; + const mockUiSettingsClient = { + get: sinon.stub(), + }; + + beforeAll(async function() { + const crypto = nodeCrypto({ encryptionKey }); + encryptedHeaders = await crypto.encrypt(headers); + }); + + beforeEach(async function() { + configGetStub = sinon.stub(); + configGetStub.withArgs('encryptionKey').returns(encryptionKey); + configGetStub.withArgs('csv', 'maxSizeBytes').returns(1024 * 1000); // 1mB + configGetStub.withArgs('csv', 'scroll').returns({}); + mockReportingConfig = { get: configGetStub, kbnConfig: { get: configGetStub } }; + + mockReportingPlugin = await createMockReportingCore(mockReportingConfig); + mockReportingPlugin.getUiSettingsServiceFactory = () => Promise.resolve(mockUiSettingsClient); + mockReportingPlugin.getElasticsearchService = () => Promise.resolve(mockElasticsearch); + + cancellationToken = new CancellationToken(); + + defaultElasticsearchResponse = { + hits: { + hits: [], + }, + _scroll_id: 'defaultScrollId', + }; + clusterStub = { + callAsCurrentUser() {}, + }; + + callAsCurrentUserStub = sinon + .stub(clusterStub, 'callAsCurrentUser') + .resolves(defaultElasticsearchResponse); + + mockUiSettingsClient.get.withArgs('csv:separator').returns(','); + mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(true); + + setFieldFormats({ + fieldFormatServiceFactory() { + const uiConfigMock = {}; + (uiConfigMock as any)['format:defaultTypeMap'] = { + _default_: { id: 'string', params: {} }, + }; + + const fieldFormatsRegistry = new fieldFormats.FieldFormatsRegistry(); + + fieldFormatsRegistry.init(key => (uiConfigMock as any)[key], {}, [ + fieldFormats.StringFormat, + ]); + + return Promise.resolve(fieldFormatsRegistry); + }, + }); + }); + + describe('basic Elasticsearch call behavior', function() { + it('should decrypt encrypted headers and pass to callAsCurrentUser', async function() { + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + await executeJob( + 'job456', + getJobDocPayload({ + headers: encryptedHeaders, + fields: [], + searchRequest: { index: null, body: null }, + }), + cancellationToken + ); + expect(callAsCurrentUserStub.called).toBe(true); + expect(callAsCurrentUserStub.firstCall.args[0]).toEqual('search'); + }); + + it('should pass the index and body to execute the initial search', async function() { + const index = 'index'; + const body = { + testBody: true, + }; + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const job = getJobDocPayload({ + headers: encryptedHeaders, + fields: [], + searchRequest: { + index, + body, + }, + }); + + await executeJob('job777', job, cancellationToken); + + const searchCall = callAsCurrentUserStub.firstCall; + expect(searchCall.args[0]).toBe('search'); + expect(searchCall.args[1].index).toBe(index); + expect(searchCall.args[1].body).toBe(body); + }); + + it('should pass the scrollId from the initial search to the subsequent scroll', async function() { + const scrollId = getRandomScrollId(); + callAsCurrentUserStub.onFirstCall().resolves({ + hits: { + hits: [{}], + }, + _scroll_id: scrollId, + }); + callAsCurrentUserStub.onSecondCall().resolves(defaultElasticsearchResponse); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + await executeJob( + 'job456', + getJobDocPayload({ + headers: encryptedHeaders, + fields: [], + searchRequest: { index: null, body: null }, + }), + cancellationToken + ); + + const scrollCall = callAsCurrentUserStub.secondCall; + + expect(scrollCall.args[0]).toBe('scroll'); + expect(scrollCall.args[1].scrollId).toBe(scrollId); + }); + + it('should not execute scroll if there are no hits from the search', async function() { + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + await executeJob( + 'job456', + getJobDocPayload({ + headers: encryptedHeaders, + fields: [], + searchRequest: { index: null, body: null }, + }), + cancellationToken + ); + + expect(callAsCurrentUserStub.callCount).toBe(2); + + const searchCall = callAsCurrentUserStub.firstCall; + expect(searchCall.args[0]).toBe('search'); + + const clearScrollCall = callAsCurrentUserStub.secondCall; + expect(clearScrollCall.args[0]).toBe('clearScroll'); + }); + + it('should stop executing scroll if there are no hits', async function() { + callAsCurrentUserStub.onFirstCall().resolves({ + hits: { + hits: [{}], + }, + _scroll_id: 'scrollId', + }); + callAsCurrentUserStub.onSecondCall().resolves({ + hits: { + hits: [], + }, + _scroll_id: 'scrollId', + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + await executeJob( + 'job456', + getJobDocPayload({ + headers: encryptedHeaders, + fields: [], + searchRequest: { index: null, body: null }, + }), + cancellationToken + ); + + expect(callAsCurrentUserStub.callCount).toBe(3); + + const searchCall = callAsCurrentUserStub.firstCall; + expect(searchCall.args[0]).toBe('search'); + + const scrollCall = callAsCurrentUserStub.secondCall; + expect(scrollCall.args[0]).toBe('scroll'); + + const clearScroll = callAsCurrentUserStub.thirdCall; + expect(clearScroll.args[0]).toBe('clearScroll'); + }); + + it('should call clearScroll with scrollId when there are no more hits', async function() { + const lastScrollId = getRandomScrollId(); + callAsCurrentUserStub.onFirstCall().resolves({ + hits: { + hits: [{}], + }, + _scroll_id: 'scrollId', + }); + + callAsCurrentUserStub.onSecondCall().resolves({ + hits: { + hits: [], + }, + _scroll_id: lastScrollId, + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + await executeJob( + 'job456', + getJobDocPayload({ + headers: encryptedHeaders, + fields: [], + searchRequest: { index: null, body: null }, + }), + cancellationToken + ); + + const lastCall = callAsCurrentUserStub.getCall(callAsCurrentUserStub.callCount - 1); + expect(lastCall.args[0]).toBe('clearScroll'); + expect(lastCall.args[1].scrollId).toEqual([lastScrollId]); + }); + + it('calls clearScroll when there is an error iterating the hits', async function() { + const lastScrollId = getRandomScrollId(); + callAsCurrentUserStub.onFirstCall().resolves({ + hits: { + hits: [ + { + _source: { + one: 'foo', + two: 'bar', + }, + }, + ], + }, + _scroll_id: lastScrollId, + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + conflictedTypesFields: undefined, + searchRequest: { index: null, body: null }, + }); + await expect( + executeJob('job123', jobParams, cancellationToken) + ).rejects.toMatchInlineSnapshot(`[TypeError: Cannot read property 'indexOf' of undefined]`); + + const lastCall = callAsCurrentUserStub.getCall(callAsCurrentUserStub.callCount - 1); + expect(lastCall.args[0]).toBe('clearScroll'); + expect(lastCall.args[1].scrollId).toEqual([lastScrollId]); + }); + }); + + describe('Cells with formula values', () => { + it('returns `csv_contains_formulas` when cells contain formulas', async function() { + configGetStub.withArgs('csv', 'checkForFormulas').returns(true); + callAsCurrentUserStub.onFirstCall().returns({ + hits: { + hits: [{ _source: { one: '=SUM(A1:A2)', two: 'bar' } }], + }, + _scroll_id: 'scrollId', + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + conflictedTypesFields: [], + searchRequest: { index: null, body: null }, + }); + const { csv_contains_formulas: csvContainsFormulas } = await executeJob( + 'job123', + jobParams, + cancellationToken + ); + + expect(csvContainsFormulas).toEqual(true); + }); + + it('returns warnings when headings contain formulas', async function() { + configGetStub.withArgs('csv', 'checkForFormulas').returns(true); + callAsCurrentUserStub.onFirstCall().returns({ + hits: { + hits: [{ _source: { '=SUM(A1:A2)': 'foo', two: 'bar' } }], + }, + _scroll_id: 'scrollId', + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['=SUM(A1:A2)', 'two'], + conflictedTypesFields: [], + searchRequest: { index: null, body: null }, + }); + const { csv_contains_formulas: csvContainsFormulas } = await executeJob( + 'job123', + jobParams, + cancellationToken + ); + + expect(csvContainsFormulas).toEqual(true); + }); + + it('returns no warnings when cells have no formulas', async function() { + configGetStub.withArgs('csv', 'checkForFormulas').returns(true); + callAsCurrentUserStub.onFirstCall().returns({ + hits: { + hits: [{ _source: { one: 'foo', two: 'bar' } }], + }, + _scroll_id: 'scrollId', + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + conflictedTypesFields: [], + searchRequest: { index: null, body: null }, + }); + const { csv_contains_formulas: csvContainsFormulas } = await executeJob( + 'job123', + jobParams, + cancellationToken + ); + + expect(csvContainsFormulas).toEqual(false); + }); + + it('returns no warnings when configured not to', async () => { + configGetStub.withArgs('csv', 'checkForFormulas').returns(false); + callAsCurrentUserStub.onFirstCall().returns({ + hits: { + hits: [{ _source: { one: '=SUM(A1:A2)', two: 'bar' } }], + }, + _scroll_id: 'scrollId', + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + conflictedTypesFields: [], + searchRequest: { index: null, body: null }, + }); + const { csv_contains_formulas: csvContainsFormulas } = await executeJob( + 'job123', + jobParams, + cancellationToken + ); + + expect(csvContainsFormulas).toEqual(false); + }); + }); + + describe('Byte order mark encoding', () => { + it('encodes CSVs with BOM', async () => { + configGetStub.withArgs('csv', 'useByteOrderMarkEncoding').returns(true); + callAsCurrentUserStub.onFirstCall().returns({ + hits: { + hits: [{ _source: { one: 'one', two: 'bar' } }], + }, + _scroll_id: 'scrollId', + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + conflictedTypesFields: [], + searchRequest: { index: null, body: null }, + }); + const { content } = await executeJob('job123', jobParams, cancellationToken); + + expect(content).toEqual(`${CSV_BOM_CHARS}one,two\none,bar\n`); + }); + + it('encodes CSVs without BOM', async () => { + configGetStub.withArgs('csv', 'useByteOrderMarkEncoding').returns(false); + callAsCurrentUserStub.onFirstCall().returns({ + hits: { + hits: [{ _source: { one: 'one', two: 'bar' } }], + }, + _scroll_id: 'scrollId', + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + conflictedTypesFields: [], + searchRequest: { index: null, body: null }, + }); + const { content } = await executeJob('job123', jobParams, cancellationToken); + + expect(content).toEqual('one,two\none,bar\n'); + }); + }); + + describe('Elasticsearch call errors', function() { + it('should reject Promise if search call errors out', async function() { + callAsCurrentUserStub.rejects(new Error()); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: [], + searchRequest: { index: null, body: null }, + }); + await expect( + executeJob('job123', jobParams, cancellationToken) + ).rejects.toMatchInlineSnapshot(`[Error]`); + }); + + it('should reject Promise if scroll call errors out', async function() { + callAsCurrentUserStub.onFirstCall().resolves({ + hits: { + hits: [{}], + }, + _scroll_id: 'scrollId', + }); + callAsCurrentUserStub.onSecondCall().rejects(new Error()); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: [], + searchRequest: { index: null, body: null }, + }); + await expect( + executeJob('job123', jobParams, cancellationToken) + ).rejects.toMatchInlineSnapshot(`[Error]`); + }); + }); + + describe('invalid responses', function() { + it('should reject Promise if search returns hits but no _scroll_id', async function() { + callAsCurrentUserStub.resolves({ + hits: { + hits: [{}], + }, + _scroll_id: undefined, + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: [], + searchRequest: { index: null, body: null }, + }); + await expect( + executeJob('job123', jobParams, cancellationToken) + ).rejects.toMatchInlineSnapshot( + `[Error: Expected _scroll_id in the following Elasticsearch response: {"hits":{"hits":[{}]}}]` + ); + }); + + it('should reject Promise if search returns no hits and no _scroll_id', async function() { + callAsCurrentUserStub.resolves({ + hits: { + hits: [], + }, + _scroll_id: undefined, + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: [], + searchRequest: { index: null, body: null }, + }); + await expect( + executeJob('job123', jobParams, cancellationToken) + ).rejects.toMatchInlineSnapshot( + `[Error: Expected _scroll_id in the following Elasticsearch response: {"hits":{"hits":[]}}]` + ); + }); + + it('should reject Promise if scroll returns hits but no _scroll_id', async function() { + callAsCurrentUserStub.onFirstCall().resolves({ + hits: { + hits: [{}], + }, + _scroll_id: 'scrollId', + }); + + callAsCurrentUserStub.onSecondCall().resolves({ + hits: { + hits: [{}], + }, + _scroll_id: undefined, + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: [], + searchRequest: { index: null, body: null }, + }); + await expect( + executeJob('job123', jobParams, cancellationToken) + ).rejects.toMatchInlineSnapshot( + `[Error: Expected _scroll_id in the following Elasticsearch response: {"hits":{"hits":[{}]}}]` + ); + }); + + it('should reject Promise if scroll returns no hits and no _scroll_id', async function() { + callAsCurrentUserStub.onFirstCall().resolves({ + hits: { + hits: [{}], + }, + _scroll_id: 'scrollId', + }); + + callAsCurrentUserStub.onSecondCall().resolves({ + hits: { + hits: [], + }, + _scroll_id: undefined, + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: [], + searchRequest: { index: null, body: null }, + }); + await expect( + executeJob('job123', jobParams, cancellationToken) + ).rejects.toMatchInlineSnapshot( + `[Error: Expected _scroll_id in the following Elasticsearch response: {"hits":{"hits":[]}}]` + ); + }); + }); + + describe('cancellation', function() { + const scrollId = getRandomScrollId(); + + beforeEach(function() { + // We have to "re-stub" the callAsCurrentUser stub here so that we can use the fakeFunction + // that delays the Promise resolution so we have a chance to call cancellationToken.cancel(). + // Otherwise, we get into an endless loop, and don't have a chance to call cancel + callAsCurrentUserStub.restore(); + callAsCurrentUserStub = sinon + .stub(clusterStub, 'callAsCurrentUser') + .callsFake(async function() { + await delay(1); + return { + hits: { + hits: [{}], + }, + _scroll_id: scrollId, + }; + }); + }); + + it('should stop calling Elasticsearch when cancellationToken.cancel is called', async function() { + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + executeJob( + 'job345', + getJobDocPayload({ + headers: encryptedHeaders, + fields: [], + searchRequest: { index: null, body: null }, + }), + cancellationToken + ); + + await delay(250); + const callCount = callAsCurrentUserStub.callCount; + cancellationToken.cancel(); + await delay(250); + expect(callAsCurrentUserStub.callCount).toBe(callCount + 1); // last call is to clear the scroll + }); + + it(`shouldn't call clearScroll if it never got a scrollId`, async function() { + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + executeJob( + 'job345', + getJobDocPayload({ + headers: encryptedHeaders, + fields: [], + searchRequest: { index: null, body: null }, + }), + cancellationToken + ); + cancellationToken.cancel(); + + for (let i = 0; i < callAsCurrentUserStub.callCount; ++i) { + expect(callAsCurrentUserStub.getCall(i).args[1]).not.toBe('clearScroll'); // dead code? + } + }); + + it('should call clearScroll if it got a scrollId', async function() { + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + executeJob( + 'job345', + getJobDocPayload({ + headers: encryptedHeaders, + fields: [], + searchRequest: { index: null, body: null }, + }), + cancellationToken + ); + await delay(100); + cancellationToken.cancel(); + await delay(100); + + const lastCall = callAsCurrentUserStub.getCall(callAsCurrentUserStub.callCount - 1); + expect(lastCall.args[0]).toBe('clearScroll'); + expect(lastCall.args[1].scrollId).toEqual([scrollId]); + }); + }); + + describe('csv content', function() { + it('should write column headers to output, even if there are no results', async function() { + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + searchRequest: { index: null, body: null }, + }); + const { content } = await executeJob('job123', jobParams, cancellationToken); + expect(content).toBe(`one,two\n`); + }); + + it('should use custom uiSettings csv:separator for header', async function() { + mockUiSettingsClient.get.withArgs('csv:separator').returns(';'); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + searchRequest: { index: null, body: null }, + }); + const { content } = await executeJob('job123', jobParams, cancellationToken); + expect(content).toBe(`one;two\n`); + }); + + it('should escape column headers if uiSettings csv:quoteValues is true', async function() { + mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(true); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one and a half', 'two', 'three-and-four', 'five & six'], + searchRequest: { index: null, body: null }, + }); + const { content } = await executeJob('job123', jobParams, cancellationToken); + expect(content).toBe(`"one and a half",two,"three-and-four","five & six"\n`); + }); + + it(`shouldn't escape column headers if uiSettings csv:quoteValues is false`, async function() { + mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(false); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one and a half', 'two', 'three-and-four', 'five & six'], + searchRequest: { index: null, body: null }, + }); + const { content } = await executeJob('job123', jobParams, cancellationToken); + expect(content).toBe(`one and a half,two,three-and-four,five & six\n`); + }); + + it('should write column headers to output, when there are results', async function() { + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + callAsCurrentUserStub.onFirstCall().resolves({ + hits: { + hits: [{ one: '1', two: '2' }], + }, + _scroll_id: 'scrollId', + }); + + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + searchRequest: { index: null, body: null }, + }); + const { content } = await executeJob('job123', jobParams, cancellationToken); + const lines = content.split('\n'); + const headerLine = lines[0]; + expect(headerLine).toBe('one,two'); + }); + + it('should use comma separated values of non-nested fields from _source', async function() { + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + callAsCurrentUserStub.onFirstCall().resolves({ + hits: { + hits: [{ _source: { one: 'foo', two: 'bar' } }], + }, + _scroll_id: 'scrollId', + }); + + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + conflictedTypesFields: [], + searchRequest: { index: null, body: null }, + }); + const { content } = await executeJob('job123', jobParams, cancellationToken); + const lines = content.split('\n'); + const valuesLine = lines[1]; + expect(valuesLine).toBe('foo,bar'); + }); + + it('should concatenate the hits from multiple responses', async function() { + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + callAsCurrentUserStub.onFirstCall().resolves({ + hits: { + hits: [{ _source: { one: 'foo', two: 'bar' } }], + }, + _scroll_id: 'scrollId', + }); + callAsCurrentUserStub.onSecondCall().resolves({ + hits: { + hits: [{ _source: { one: 'baz', two: 'qux' } }], + }, + _scroll_id: 'scrollId', + }); + + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + conflictedTypesFields: [], + searchRequest: { index: null, body: null }, + }); + const { content } = await executeJob('job123', jobParams, cancellationToken); + const lines = content.split('\n'); + + expect(lines[1]).toBe('foo,bar'); + expect(lines[2]).toBe('baz,qux'); + }); + + it('should use field formatters to format fields', async function() { + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + callAsCurrentUserStub.onFirstCall().resolves({ + hits: { + hits: [{ _source: { one: 'foo', two: 'bar' } }], + }, + _scroll_id: 'scrollId', + }); + + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + conflictedTypesFields: [], + searchRequest: { index: null, body: null }, + indexPatternSavedObject: { + id: 'logstash-*', + type: 'index-pattern', + attributes: { + title: 'logstash-*', + fields: '[{"name":"one","type":"string"}, {"name":"two","type":"string"}]', + fieldFormatMap: '{"one":{"id":"string","params":{"transform": "upper"}}}', + }, + }, + }); + const { content } = await executeJob('job123', jobParams, cancellationToken); + const lines = content.split('\n'); + + expect(lines[1]).toBe('FOO,bar'); + }); + }); + + describe('maxSizeBytes', function() { + // The following tests use explicitly specified lengths. UTF-8 uses between one and four 8-bit bytes for each + // code-point. However, any character that can be represented by ASCII requires one-byte, so a majority of the + // tests use these 'simple' characters to make the math easier + + describe('when only the headers exceed the maxSizeBytes', function() { + let content: string; + let maxSizeReached: boolean; + + beforeEach(async function() { + configGetStub.withArgs('csv', 'maxSizeBytes').returns(1); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + searchRequest: { index: null, body: null }, + }); + + ({ content, max_size_reached: maxSizeReached } = await executeJob( + 'job123', + jobParams, + cancellationToken + )); + }); + + it('should return max_size_reached', function() { + expect(maxSizeReached).toBe(true); + }); + + it('should return empty content', function() { + expect(content).toBe(''); + }); + }); + + describe('when headers are equal to maxSizeBytes', function() { + let content: string; + let maxSizeReached: boolean; + + beforeEach(async function() { + configGetStub.withArgs('csv', 'maxSizeBytes').returns(9); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + searchRequest: { index: null, body: null }, + }); + + ({ content, max_size_reached: maxSizeReached } = await executeJob( + 'job123', + jobParams, + cancellationToken + )); + }); + + it(`shouldn't return max_size_reached`, function() { + expect(maxSizeReached).toBe(false); + }); + + it(`should return content`, function() { + expect(content).toBe('one,two\n'); + }); + }); + + describe('when the data exceeds the maxSizeBytes', function() { + let content: string; + let maxSizeReached: boolean; + + beforeEach(async function() { + configGetStub.withArgs('csv', 'maxSizeBytes').returns(9); + + callAsCurrentUserStub.onFirstCall().returns({ + hits: { + hits: [{ _source: { one: 'foo', two: 'bar' } }], + }, + _scroll_id: 'scrollId', + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + conflictedTypesFields: [], + searchRequest: { index: null, body: null }, + }); + + ({ content, max_size_reached: maxSizeReached } = await executeJob( + 'job123', + jobParams, + cancellationToken + )); + }); + + it(`should return max_size_reached`, function() { + expect(maxSizeReached).toBe(true); + }); + + it(`should return the headers in the content`, function() { + expect(content).toBe('one,two\n'); + }); + }); + + describe('when headers and data equal the maxSizeBytes', function() { + let content: string; + let maxSizeReached: boolean; + + beforeEach(async function() { + mockReportingPlugin.getUiSettingsServiceFactory = () => mockUiSettingsClient; + configGetStub.withArgs('csv', 'maxSizeBytes').returns(18); + + callAsCurrentUserStub.onFirstCall().returns({ + hits: { + hits: [{ _source: { one: 'foo', two: 'bar' } }], + }, + _scroll_id: 'scrollId', + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + conflictedTypesFields: [], + searchRequest: { index: null, body: null }, + }); + + ({ content, max_size_reached: maxSizeReached } = await executeJob( + 'job123', + jobParams, + cancellationToken + )); + }); + + it(`shouldn't return max_size_reached`, async function() { + expect(maxSizeReached).toBe(false); + }); + + it('should return headers and data in content', function() { + expect(content).toBe('one,two\nfoo,bar\n'); + }); + }); + }); + + describe('scroll settings', function() { + it('passes scroll duration to initial search call', async function() { + const scrollDuration = 'test'; + configGetStub.withArgs('csv', 'scroll').returns({ duration: scrollDuration }); + + callAsCurrentUserStub.onFirstCall().returns({ + hits: { + hits: [{}], + }, + _scroll_id: 'scrollId', + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + conflictedTypesFields: [], + searchRequest: { index: null, body: null }, + }); + + await executeJob('job123', jobParams, cancellationToken); + + const searchCall = callAsCurrentUserStub.firstCall; + expect(searchCall.args[0]).toBe('search'); + expect(searchCall.args[1].scroll).toBe(scrollDuration); + }); + + it('passes scroll size to initial search call', async function() { + const scrollSize = 100; + configGetStub.withArgs('csv', 'scroll').returns({ size: scrollSize }); + + callAsCurrentUserStub.onFirstCall().resolves({ + hits: { + hits: [{}], + }, + _scroll_id: 'scrollId', + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + conflictedTypesFields: [], + searchRequest: { index: null, body: null }, + }); + + await executeJob('job123', jobParams, cancellationToken); + + const searchCall = callAsCurrentUserStub.firstCall; + expect(searchCall.args[0]).toBe('search'); + expect(searchCall.args[1].size).toBe(scrollSize); + }); + + it('passes scroll duration to subsequent scroll call', async function() { + const scrollDuration = 'test'; + configGetStub.withArgs('csv', 'scroll').returns({ duration: scrollDuration }); + + callAsCurrentUserStub.onFirstCall().resolves({ + hits: { + hits: [{}], + }, + _scroll_id: 'scrollId', + }); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const jobParams = getJobDocPayload({ + headers: encryptedHeaders, + fields: ['one', 'two'], + conflictedTypesFields: [], + searchRequest: { index: null, body: null }, + }); + + await executeJob('job123', jobParams, cancellationToken); + + const scrollCall = callAsCurrentUserStub.secondCall; + expect(scrollCall.args[0]).toBe('scroll'); + expect(scrollCall.args[1].scroll).toBe(scrollDuration); + }); + }); +}); diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js deleted file mode 100644 index cb63e7dad2fdf..0000000000000 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js +++ /dev/null @@ -1,121 +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 * as Rx from 'rxjs'; -import { createMockReportingCore } from '../../../../test_helpers'; -import { cryptoFactory } from '../../../../server/lib/crypto'; -import { executeJobFactory } from './index'; -import { generatePngObservableFactory } from '../lib/generate_png'; -import { LevelLogger } from '../../../../server/lib'; - -jest.mock('../lib/generate_png', () => ({ generatePngObservableFactory: jest.fn() })); - -let mockReporting; - -const cancellationToken = { - on: jest.fn(), -}; - -const mockLoggerFactory = { - get: jest.fn().mockImplementation(() => ({ - error: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - })), -}; -const getMockLogger = () => new LevelLogger(mockLoggerFactory); - -const mockEncryptionKey = 'abcabcsecuresecret'; -const encryptHeaders = async headers => { - const crypto = cryptoFactory(mockEncryptionKey); - return await crypto.encrypt(headers); -}; - -beforeEach(async () => { - const kbnConfig = { - 'server.basePath': '/sbp', - }; - const reportingConfig = { - encryptionKey: mockEncryptionKey, - 'kibanaServer.hostname': 'localhost', - 'kibanaServer.port': 5601, - 'kibanaServer.protocol': 'http', - }; - const mockReportingConfig = { - get: (...keys) => reportingConfig[keys.join('.')], - kbnConfig: { get: (...keys) => kbnConfig[keys.join('.')] }, - }; - - mockReporting = await createMockReportingCore(mockReportingConfig); - - const mockElasticsearch = { - dataClient: { - asScoped: () => ({ callAsCurrentUser: jest.fn() }), - }, - }; - const mockGetElasticsearch = jest.fn(); - mockGetElasticsearch.mockImplementation(() => Promise.resolve(mockElasticsearch)); - mockReporting.getElasticsearchService = mockGetElasticsearch; - - generatePngObservableFactory.mockReturnValue(jest.fn()); -}); - -afterEach(() => generatePngObservableFactory.mockReset()); - -test(`passes browserTimezone to generatePng`, async () => { - const encryptedHeaders = await encryptHeaders({}); - - const generatePngObservable = generatePngObservableFactory(); - generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); - - const executeJob = await executeJobFactory(mockReporting, getMockLogger()); - const browserTimezone = 'UTC'; - await executeJob( - 'pngJobId', - { relativeUrl: '/app/kibana#/something', browserTimezone, headers: encryptedHeaders }, - cancellationToken - ); - - expect(generatePngObservable).toBeCalledWith( - expect.any(LevelLogger), - 'http://localhost:5601/sbp/app/kibana#/something', - browserTimezone, - expect.anything(), - undefined - ); -}); - -test(`returns content_type of application/png`, async () => { - const executeJob = await executeJobFactory(mockReporting, getMockLogger()); - const encryptedHeaders = await encryptHeaders({}); - - const generatePngObservable = generatePngObservableFactory(); - generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); - - const { content_type: contentType } = await executeJob( - 'pngJobId', - { relativeUrl: '/app/kibana#/something', timeRange: {}, headers: encryptedHeaders }, - cancellationToken - ); - expect(contentType).toBe('image/png'); -}); - -test(`returns content of generatePng getBuffer base64 encoded`, async () => { - const testContent = 'test content'; - - const generatePngObservable = generatePngObservableFactory(); - generatePngObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); - - const executeJob = await executeJobFactory(mockReporting, getMockLogger()); - const encryptedHeaders = await encryptHeaders({}); - const { content } = await executeJob( - 'pngJobId', - { relativeUrl: '/app/kibana#/something', timeRange: {}, headers: encryptedHeaders }, - cancellationToken - ); - - expect(content).toEqual(Buffer.from(testContent).toString('base64')); -}); diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.ts new file mode 100644 index 0000000000000..c9cba64a732b6 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.ts @@ -0,0 +1,146 @@ +/* + * 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 * as Rx from 'rxjs'; +import { createMockReportingCore, createMockBrowserDriverFactory } from '../../../../test_helpers'; +import { cryptoFactory } from '../../../../server/lib/crypto'; +import { executeJobFactory } from './index'; +import { generatePngObservableFactory } from '../lib/generate_png'; +import { CancellationToken } from '../../../../common/cancellation_token'; +import { LevelLogger } from '../../../../server/lib'; +import { ReportingCore, CaptureConfig } from '../../../../server/types'; +import { JobDocPayloadPNG } from '../../types'; + +jest.mock('../lib/generate_png', () => ({ generatePngObservableFactory: jest.fn() })); + +let mockReporting: ReportingCore; + +const cancellationToken = ({ + on: jest.fn(), +} as unknown) as CancellationToken; + +const mockLoggerFactory = { + get: jest.fn().mockImplementation(() => ({ + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + })), +}; +const getMockLogger = () => new LevelLogger(mockLoggerFactory); + +const captureConfig = {} as CaptureConfig; + +const mockEncryptionKey = 'abcabcsecuresecret'; +const encryptHeaders = async (headers: Record) => { + const crypto = cryptoFactory(mockEncryptionKey); + return await crypto.encrypt(headers); +}; + +const getJobDocPayload = (baseObj: any) => baseObj as JobDocPayloadPNG; + +beforeEach(async () => { + const kbnConfig = { + 'server.basePath': '/sbp', + }; + const reportingConfig = { + encryptionKey: mockEncryptionKey, + 'kibanaServer.hostname': 'localhost', + 'kibanaServer.port': 5601, + 'kibanaServer.protocol': 'http', + }; + const mockReportingConfig = { + get: (...keys: string[]) => (reportingConfig as any)[keys.join('.')], + kbnConfig: { get: (...keys: string[]) => (kbnConfig as any)[keys.join('.')] }, + }; + + mockReporting = await createMockReportingCore(mockReportingConfig); + + const mockElasticsearch = { + dataClient: { + asScoped: () => ({ callAsCurrentUser: jest.fn() }), + }, + }; + const mockGetElasticsearch = jest.fn(); + mockGetElasticsearch.mockImplementation(() => Promise.resolve(mockElasticsearch)); + mockReporting.getElasticsearchService = mockGetElasticsearch; + + (generatePngObservableFactory as jest.Mock).mockReturnValue(jest.fn()); +}); + +afterEach(() => (generatePngObservableFactory as jest.Mock).mockReset()); + +test(`passes browserTimezone to generatePng`, async () => { + const encryptedHeaders = await encryptHeaders({}); + const mockBrowserDriverFactory = await createMockBrowserDriverFactory(getMockLogger()); + + const generatePngObservable = generatePngObservableFactory( + captureConfig, + mockBrowserDriverFactory + ); + (generatePngObservable as jest.Mock).mockReturnValue(Rx.of(Buffer.from(''))); + + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); + const browserTimezone = 'UTC'; + await executeJob( + 'pngJobId', + getJobDocPayload({ + relativeUrl: '/app/kibana#/something', + browserTimezone, + headers: encryptedHeaders, + }), + cancellationToken + ); + + expect(generatePngObservable).toBeCalledWith( + expect.any(LevelLogger), + 'http://localhost:5601/sbp/app/kibana#/something', + browserTimezone, + expect.anything(), + undefined + ); +}); + +test(`returns content_type of application/png`, async () => { + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); + const encryptedHeaders = await encryptHeaders({}); + + const mockBrowserDriverFactory = await createMockBrowserDriverFactory(getMockLogger()); + + const generatePngObservable = generatePngObservableFactory( + captureConfig, + mockBrowserDriverFactory + ); + (generatePngObservable as jest.Mock).mockReturnValue(Rx.of(Buffer.from(''))); + + const { content_type: contentType } = await executeJob( + 'pngJobId', + getJobDocPayload({ relativeUrl: '/app/kibana#/something', headers: encryptedHeaders }), + cancellationToken + ); + expect(contentType).toBe('image/png'); +}); + +test(`returns content of generatePng getBuffer base64 encoded`, async () => { + const testContent = 'test content'; + + const mockBrowserDriverFactory = await createMockBrowserDriverFactory(getMockLogger()); + + const generatePngObservable = generatePngObservableFactory( + captureConfig, + mockBrowserDriverFactory + ); + (generatePngObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); + + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); + const encryptedHeaders = await encryptHeaders({}); + const { content } = await executeJob( + 'pngJobId', + getJobDocPayload({ relativeUrl: '/app/kibana#/something', headers: encryptedHeaders }), + cancellationToken + ); + + expect(content).toEqual(Buffer.from(testContent).toString('base64')); +}); diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js deleted file mode 100644 index c6f07f8ad2d34..0000000000000 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js +++ /dev/null @@ -1,98 +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 * as Rx from 'rxjs'; -import { createMockReportingCore } from '../../../../test_helpers'; -import { cryptoFactory } from '../../../../server/lib/crypto'; -import { executeJobFactory } from './index'; -import { generatePdfObservableFactory } from '../lib/generate_pdf'; -import { LevelLogger } from '../../../../server/lib'; - -jest.mock('../lib/generate_pdf', () => ({ generatePdfObservableFactory: jest.fn() })); - -let mockReporting; - -const cancellationToken = { - on: jest.fn(), -}; - -const mockLoggerFactory = { - get: jest.fn().mockImplementation(() => ({ - error: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - })), -}; -const getMockLogger = () => new LevelLogger(mockLoggerFactory); - -const mockEncryptionKey = 'testencryptionkey'; -const encryptHeaders = async headers => { - const crypto = cryptoFactory(mockEncryptionKey); - return await crypto.encrypt(headers); -}; - -beforeEach(async () => { - const kbnConfig = { - 'server.basePath': '/sbp', - }; - const reportingConfig = { - encryptionKey: mockEncryptionKey, - 'kibanaServer.hostname': 'localhost', - 'kibanaServer.port': 5601, - 'kibanaServer.protocol': 'http', - }; - const mockReportingConfig = { - get: (...keys) => reportingConfig[keys.join('.')], - kbnConfig: { get: (...keys) => kbnConfig[keys.join('.')] }, - }; - - mockReporting = await createMockReportingCore(mockReportingConfig); - - const mockElasticsearch = { - dataClient: { - asScoped: () => ({ callAsCurrentUser: jest.fn() }), - }, - }; - const mockGetElasticsearch = jest.fn(); - mockGetElasticsearch.mockImplementation(() => Promise.resolve(mockElasticsearch)); - mockReporting.getElasticsearchService = mockGetElasticsearch; - - generatePdfObservableFactory.mockReturnValue(jest.fn()); -}); - -afterEach(() => generatePdfObservableFactory.mockReset()); - -test(`returns content_type of application/pdf`, async () => { - const executeJob = await executeJobFactory(mockReporting, getMockLogger()); - const encryptedHeaders = await encryptHeaders({}); - - const generatePdfObservable = generatePdfObservableFactory(); - generatePdfObservable.mockReturnValue(Rx.of(Buffer.from(''))); - - const { content_type: contentType } = await executeJob( - 'pdfJobId', - { relativeUrls: [], timeRange: {}, headers: encryptedHeaders }, - cancellationToken - ); - expect(contentType).toBe('application/pdf'); -}); - -test(`returns content of generatePdf getBuffer base64 encoded`, async () => { - const testContent = 'test content'; - - const generatePdfObservable = generatePdfObservableFactory(); - generatePdfObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); - - const executeJob = await executeJobFactory(mockReporting, getMockLogger()); - const encryptedHeaders = await encryptHeaders({}); - const { content } = await executeJob( - 'pdfJobId', - { relativeUrls: [], timeRange: {}, headers: encryptedHeaders }, - cancellationToken - ); - - expect(content).toEqual(Buffer.from(testContent).toString('base64')); -}); diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.ts new file mode 100644 index 0000000000000..c3c0d38584bc1 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.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 * as Rx from 'rxjs'; +import { createMockReportingCore, createMockBrowserDriverFactory } from '../../../../test_helpers'; +import { cryptoFactory } from '../../../../server/lib/crypto'; +import { LevelLogger } from '../../../../server/lib'; +import { CancellationToken } from '../../../../types'; +import { ReportingCore, CaptureConfig } from '../../../../server/types'; +import { generatePdfObservableFactory } from '../lib/generate_pdf'; +import { JobDocPayloadPDF } from '../../types'; +import { executeJobFactory } from './index'; + +jest.mock('../lib/generate_pdf', () => ({ generatePdfObservableFactory: jest.fn() })); + +let mockReporting: ReportingCore; + +const cancellationToken = ({ + on: jest.fn(), +} as unknown) as CancellationToken; + +const captureConfig = {} as CaptureConfig; + +const mockLoggerFactory = { + get: jest.fn().mockImplementation(() => ({ + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + })), +}; +const getMockLogger = () => new LevelLogger(mockLoggerFactory); + +const mockEncryptionKey = 'testencryptionkey'; +const encryptHeaders = async (headers: Record) => { + const crypto = cryptoFactory(mockEncryptionKey); + return await crypto.encrypt(headers); +}; + +const getJobDocPayload = (baseObj: any) => baseObj as JobDocPayloadPDF; + +beforeEach(async () => { + const kbnConfig = { + 'server.basePath': '/sbp', + }; + const reportingConfig = { + encryptionKey: mockEncryptionKey, + 'kibanaServer.hostname': 'localhost', + 'kibanaServer.port': 5601, + 'kibanaServer.protocol': 'http', + }; + const mockReportingConfig = { + get: (...keys: string[]) => (reportingConfig as any)[keys.join('.')], + kbnConfig: { get: (...keys: string[]) => (kbnConfig as any)[keys.join('.')] }, + }; + + mockReporting = await createMockReportingCore(mockReportingConfig); + + const mockElasticsearch = { + dataClient: { + asScoped: () => ({ callAsCurrentUser: jest.fn() }), + }, + }; + const mockGetElasticsearch = jest.fn(); + mockGetElasticsearch.mockImplementation(() => Promise.resolve(mockElasticsearch)); + mockReporting.getElasticsearchService = mockGetElasticsearch; + + (generatePdfObservableFactory as jest.Mock).mockReturnValue(jest.fn()); +}); + +afterEach(() => (generatePdfObservableFactory as jest.Mock).mockReset()); + +test(`returns content_type of application/pdf`, async () => { + const logger = getMockLogger(); + const executeJob = await executeJobFactory(mockReporting, logger); + const mockBrowserDriverFactory = await createMockBrowserDriverFactory(logger); + const encryptedHeaders = await encryptHeaders({}); + + const generatePdfObservable = generatePdfObservableFactory( + captureConfig, + mockBrowserDriverFactory + ); + (generatePdfObservable as jest.Mock).mockReturnValue(Rx.of(Buffer.from(''))); + + const { content_type: contentType } = await executeJob( + 'pdfJobId', + getJobDocPayload({ relativeUrls: [], headers: encryptedHeaders }), + cancellationToken + ); + expect(contentType).toBe('application/pdf'); +}); + +test(`returns content of generatePdf getBuffer base64 encoded`, async () => { + const testContent = 'test content'; + const mockBrowserDriverFactory = await createMockBrowserDriverFactory(getMockLogger()); + + const generatePdfObservable = generatePdfObservableFactory( + captureConfig, + mockBrowserDriverFactory + ); + (generatePdfObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); + + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); + const encryptedHeaders = await encryptHeaders({}); + const { content } = await executeJob( + 'pdfJobId', + getJobDocPayload({ relativeUrls: [], headers: encryptedHeaders }), + cancellationToken + ); + + expect(content).toEqual(Buffer.from(testContent).toString('base64')); +}); diff --git a/x-pack/legacy/plugins/reporting/index.test.js b/x-pack/legacy/plugins/reporting/index.test.js deleted file mode 100644 index 0d9a717bd7d81..0000000000000 --- a/x-pack/legacy/plugins/reporting/index.test.js +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { reporting } from './index'; -import { getConfigSchema } from '../../../test_utils'; - -// The snapshot records the number of cpus available -// to make the snapshot deterministic `os.cpus` needs to be mocked -// but the other members on `os` must remain untouched -jest.mock('os', () => { - const os = jest.requireActual('os'); - os.cpus = () => [{}, {}, {}, {}]; - return os; -}); - -// eslint-disable-next-line jest/valid-describe -const describeWithContext = describe.each([ - [{ dev: false, dist: false }], - [{ dev: true, dist: false }], - [{ dev: false, dist: true }], - [{ dev: true, dist: true }], -]); - -describeWithContext('config schema with context %j', context => { - it('produces correct config', async () => { - const schema = await getConfigSchema(reporting); - const value = await schema.validate({}, { context }); - value.capture.browser.chromium.disableSandbox = ''; - await expect(value).toMatchSnapshot(); - }); -}); diff --git a/x-pack/legacy/plugins/reporting/index.ts b/x-pack/legacy/plugins/reporting/index.ts index a5d27d0545da1..fb95e2c2edc24 100644 --- a/x-pack/legacy/plugins/reporting/index.ts +++ b/x-pack/legacy/plugins/reporting/index.ts @@ -8,7 +8,6 @@ import { i18n } from '@kbn/i18n'; import { Legacy } from 'kibana'; import { resolve } from 'path'; import { PLUGIN_ID, UI_SETTINGS_CUSTOM_PDF_LOGO } from './common/constants'; -import { config as reportingConfig } from './config'; import { legacyInit } from './server/legacy'; import { ReportingPluginSpecOptions } from './types'; @@ -17,10 +16,8 @@ const kbToBase64Length = (kb: number) => Math.floor((kb * 1024 * 8) / 6); export const reporting = (kibana: any) => { return new kibana.Plugin({ id: PLUGIN_ID, - configPrefix: 'xpack.reporting', publicDir: resolve(__dirname, 'public'), require: ['kibana', 'elasticsearch', 'xpack_main'], - config: reportingConfig, uiExports: { uiSettingDefaults: { @@ -47,14 +44,5 @@ export const reporting = (kibana: any) => { async init(server: Legacy.Server) { return legacyInit(server, this); }, - - deprecations({ unused }: any) { - return [ - unused('capture.concurrency'), - unused('capture.timeout'), - unused('capture.settleTime'), - unused('kibanaApp'), - ]; - }, } as ReportingPluginSpecOptions); }; diff --git a/x-pack/legacy/plugins/reporting/log_configuration.ts b/x-pack/legacy/plugins/reporting/log_configuration.ts deleted file mode 100644 index 7aaed2038bd52..0000000000000 --- a/x-pack/legacy/plugins/reporting/log_configuration.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import getosSync, { LinuxOs } from 'getos'; -import { promisify } from 'util'; -import { BROWSER_TYPE } from './common/constants'; -import { CaptureConfig } from './server/types'; -import { Logger } from './types'; - -const getos = promisify(getosSync); - -export async function logConfiguration(captureConfig: CaptureConfig, logger: Logger) { - const { - browser: { - type: browserType, - chromium: { disableSandbox }, - }, - } = captureConfig; - - logger.debug(`Browser type: ${browserType}`); - if (browserType === BROWSER_TYPE) { - logger.debug(`Chromium sandbox disabled: ${disableSandbox}`); - } - - const os = await getos(); - const { os: osName, dist, release } = os as LinuxOs; - if (dist) { - logger.debug(`Running on os "${osName}", distribution "${dist}", release "${release}"`); - } else { - logger.debug(`Running on os "${osName}"`); - } -} diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts index 4b80e129c04da..dfaa87021c31c 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts @@ -190,19 +190,14 @@ export class HeadlessChromiumDriver { } public async screenshot(elementPosition: ElementPosition): Promise { - let clip; - if (elementPosition) { - const { boundingClientRect, scroll = { x: 0, y: 0 } } = elementPosition; - clip = { + const { boundingClientRect, scroll } = elementPosition; + const screenshot = await this.page.screenshot({ + clip: { x: boundingClientRect.left + scroll.x, y: boundingClientRect.top + scroll.y, height: boundingClientRect.height, width: boundingClientRect.width, - }; - } - - const screenshot = await this.page.screenshot({ - clip, + }, }); return screenshot.toString('base64'); diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts index a2f7a1f3ad0da..928f3b8377809 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts @@ -10,7 +10,7 @@ type ViewportConfig = CaptureConfig['viewport']; type BrowserConfig = CaptureConfig['browser']['chromium']; interface LaunchArgs { - userDataDir: BrowserConfig['userDataDir']; + userDataDir: string; viewport: ViewportConfig; disableSandbox: BrowserConfig['disableSandbox']; proxy: BrowserConfig['proxy']; diff --git a/x-pack/legacy/plugins/reporting/server/browsers/default_chromium_sandbox_disabled.test.js b/x-pack/legacy/plugins/reporting/server/browsers/default_chromium_sandbox_disabled.test.js deleted file mode 100644 index a022506f9e2da..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/browsers/default_chromium_sandbox_disabled.test.js +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -jest.mock('getos', () => { - return jest.fn(); -}); - -import { getDefaultChromiumSandboxDisabled } from './default_chromium_sandbox_disabled'; -import getos from 'getos'; - -function defaultTest(os, expectedDefault) { - test(`${expectedDefault ? 'disabled' : 'enabled'} on ${JSON.stringify(os)}`, async () => { - getos.mockImplementation(cb => cb(null, os)); - const actualDefault = await getDefaultChromiumSandboxDisabled(); - expect(actualDefault).toBe(expectedDefault); - }); -} - -defaultTest({ os: 'win32' }, false); -defaultTest({ os: 'darwin' }, false); -defaultTest({ os: 'linux', dist: 'Centos', release: '7.0' }, true); -defaultTest({ os: 'linux', dist: 'Red Hat Linux', release: '7.0' }, true); -defaultTest({ os: 'linux', dist: 'Ubuntu Linux', release: '14.04' }, false); -defaultTest({ os: 'linux', dist: 'Ubuntu Linux', release: '16.04' }, false); -defaultTest({ os: 'linux', dist: 'SUSE Linux', release: '11' }, false); -defaultTest({ os: 'linux', dist: 'SUSE Linux', release: '12' }, false); -defaultTest({ os: 'linux', dist: 'SUSE Linux', release: '42.0' }, false); -defaultTest({ os: 'linux', dist: 'Debian', release: '8' }, true); -defaultTest({ os: 'linux', dist: 'Debian', release: '9' }, true); diff --git a/x-pack/legacy/plugins/reporting/server/browsers/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/index.ts index 1e42e2736962e..7f902c84308f6 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/index.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/index.ts @@ -8,7 +8,6 @@ import * as chromiumDefinition from './chromium'; export { ensureAllBrowsersDownloaded } from './download'; export { createBrowserDriverFactory } from './create_browser_driver_factory'; -export { getDefaultChromiumSandboxDisabled } from './default_chromium_sandbox_disabled'; export { HeadlessChromiumDriver } from './chromium/driver'; export { HeadlessChromiumDriverFactory } from './chromium/driver_factory'; diff --git a/x-pack/legacy/plugins/reporting/server/config/config.js b/x-pack/legacy/plugins/reporting/server/config/config.js deleted file mode 100644 index 08e4db464b003..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/config/config.js +++ /dev/null @@ -1,21 +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 { cpus } from 'os'; - -const defaultCPUCount = 2; - -function cpuCount() { - try { - return cpus().length; - } catch (e) { - return defaultCPUCount; - } -} - -export const config = { - concurrency: cpuCount(), -}; diff --git a/x-pack/legacy/plugins/reporting/server/config/index.ts b/x-pack/legacy/plugins/reporting/server/config/index.ts index b7b67b57932eb..c6b915be3a94a 100644 --- a/x-pack/legacy/plugins/reporting/server/config/index.ts +++ b/x-pack/legacy/plugins/reporting/server/config/index.ts @@ -6,10 +6,9 @@ import { Legacy } from 'kibana'; import { CoreSetup } from 'src/core/server'; -import { i18n } from '@kbn/i18n'; -import crypto from 'crypto'; import { get } from 'lodash'; -import { NetworkPolicy } from '../../types'; +import { ConfigType as ReportingConfigType } from '../../../../../plugins/reporting/server'; +export { ReportingConfigType }; // make config.get() aware of the value type it returns interface Config { @@ -56,131 +55,6 @@ export interface ReportingConfig extends Config { kbnConfig: Config; } -type BrowserType = 'chromium'; - -interface BrowserConfig { - inspect: boolean; - userDataDir: string; - viewport: { width: number; height: number }; - disableSandbox: boolean; - proxy: { - enabled: boolean; - server?: string; - bypass?: string[]; - }; -} - -interface CaptureConfig { - browser: { - type: BrowserType; - autoDownload: boolean; - chromium: BrowserConfig; - }; - maxAttempts: number; - networkPolicy: NetworkPolicy; - loadDelay: number; - timeouts: { - openUrl: number; - waitForElements: number; - renderComplete: number; - }; - viewport: any; - zoom: any; -} - -interface QueueConfig { - indexInterval: string; - pollEnabled: boolean; - pollInterval: number; - pollIntervalErrorMultiplier: number; - timeout: number; -} - -interface ScrollConfig { - duration: string; - size: number; -} - -export interface ReportingConfigType { - capture: CaptureConfig; - csv: { - scroll: ScrollConfig; - enablePanelActionDownload: boolean; - checkForFormulas: boolean; - maxSizeBytes: number; - useByteOrderMarkEncoding: boolean; - }; - encryptionKey: string; - kibanaServer: any; - index: string; - queue: QueueConfig; - roles: any; -} - -const addConfigDefaults = ( - server: Legacy.Server, - core: CoreSetup, - baseConfig: ReportingConfigType -) => { - // encryption key - let encryptionKey = baseConfig.encryptionKey; - if (encryptionKey === undefined) { - server.log( - ['reporting', 'config', 'warning'], - i18n.translate('xpack.reporting.selfCheckEncryptionKey.warning', { - defaultMessage: - `Generating a random key for {setting}. To prevent pending reports ` + - `from failing on restart, please set {setting} in kibana.yml`, - values: { - setting: 'xpack.reporting.encryptionKey', - }, - }) - ); - encryptionKey = crypto.randomBytes(16).toString('hex'); - } - - const { kibanaServer: reportingServer } = baseConfig; - const serverInfo = core.http.getServerInfo(); - - // kibanaServer.hostname, default to server.host, don't allow "0" - let kibanaServerHostname = reportingServer.hostname ? reportingServer.hostname : serverInfo.host; - if (kibanaServerHostname === '0') { - server.log( - ['reporting', 'config', 'warning'], - i18n.translate('xpack.reporting.selfCheckHostname.warning', { - defaultMessage: - `Found 'server.host: "0"' in settings. This is incompatible with Reporting. ` + - `To enable Reporting to work, '{setting}: 0.0.0.0' is being automatically to the configuration. ` + - `You can change to 'server.host: 0.0.0.0' or add '{setting}: 0.0.0.0' in kibana.yml to prevent this message.`, - values: { - setting: 'xpack.reporting.kibanaServer.hostname', - }, - }) - ); - kibanaServerHostname = '0.0.0.0'; - } - - // kibanaServer.port, default to server.port - const kibanaServerPort = reportingServer.port - ? reportingServer.port - : serverInfo.port; // prettier-ignore - - // kibanaServer.protocol, default to server.protocol - const kibanaServerProtocol = reportingServer.protocol - ? reportingServer.protocol - : serverInfo.protocol; - - return { - ...baseConfig, - encryptionKey, - kibanaServer: { - hostname: kibanaServerHostname, - port: kibanaServerPort, - protocol: kibanaServerProtocol, - }, - }; -}; - export const buildConfig = ( core: CoreSetup, server: Legacy.Server, @@ -204,10 +78,8 @@ export const buildConfig = ( }, }; - // spreading arguments as an array allows the return type to be known by the compiler - reportingConfig = addConfigDefaults(server, core, reportingConfig); return { - get: (...keys: string[]) => get(reportingConfig, keys.join('.'), null), + get: (...keys: string[]) => get(reportingConfig, keys.join('.'), null), // spreading arguments as an array allows the return type to be known by the compiler kbnConfig: { get: (...keys: string[]) => get(kbnConfig, keys.join('.'), null), }, diff --git a/x-pack/legacy/plugins/reporting/server/legacy.ts b/x-pack/legacy/plugins/reporting/server/legacy.ts index 679b42aca6de5..d044dc866ed0e 100644 --- a/x-pack/legacy/plugins/reporting/server/legacy.ts +++ b/x-pack/legacy/plugins/reporting/server/legacy.ts @@ -5,7 +5,9 @@ */ import { Legacy } from 'kibana'; +import { take } from 'rxjs/operators'; import { PluginInitializerContext } from 'src/core/server'; +import { PluginsSetup } from '../../../../plugins/reporting/server'; import { SecurityPluginSetup } from '../../../../plugins/security/server'; import { ReportingPluginSpecOptions } from '../types'; import { buildConfig } from './config'; @@ -17,7 +19,6 @@ const buildLegacyDependencies = ( reportingPlugin: ReportingPluginSpecOptions ): LegacySetup => ({ route: server.route.bind(server), - config: server.config, plugins: { xpack_main: server.plugins.xpack_main, reporting: reportingPlugin, @@ -32,14 +33,13 @@ export const legacyInit = async ( reportingLegacyPlugin: ReportingPluginSpecOptions ) => { const { core: coreSetup } = server.newPlatform.setup; - const legacyConfig = server.config(); - const reportingConfig = buildConfig(coreSetup, server, legacyConfig.get('xpack.reporting')); - + const { config$ } = (server.newPlatform.setup.plugins.reporting as PluginsSetup).__legacy; + const reportingConfig = await config$.pipe(take(1)).toPromise(); const __LEGACY = buildLegacyDependencies(server, reportingLegacyPlugin); const pluginInstance = plugin( server.newPlatform.coreContext as PluginInitializerContext, - reportingConfig + buildConfig(coreSetup, server, reportingConfig) ); await pluginInstance.setup(coreSetup, { elasticsearch: coreSetup.elasticsearch, diff --git a/x-pack/legacy/plugins/reporting/server/lib/esqueue/__tests__/worker.js b/x-pack/legacy/plugins/reporting/server/lib/esqueue/__tests__/worker.js index ad93a1882746d..ea80a652bb506 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/esqueue/__tests__/worker.js +++ b/x-pack/legacy/plugins/reporting/server/lib/esqueue/__tests__/worker.js @@ -760,6 +760,27 @@ describe('Worker class', function() { }); }); + it('handle warnings in the output by reflecting a warning status', () => { + const workerFn = () => { + return Promise.resolve({ + ...payload, + warnings: [`Don't run with scissors!`], + }); + }; + worker = new Worker(mockQueue, 'test', workerFn, defaultWorkerOptions); + + return worker + ._performJob({ + test: true, + ...job, + }) + .then(() => { + sinon.assert.calledOnce(updateSpy); + const doc = updateSpy.firstCall.args[1].body.doc; + expect(doc).to.have.property('status', constants.JOB_STATUS_WARNINGS); + }); + }); + it('should emit completion event', function(done) { worker = new Worker(mockQueue, 'test', noop, defaultWorkerOptions); diff --git a/x-pack/legacy/plugins/reporting/server/lib/esqueue/constants/statuses.js b/x-pack/legacy/plugins/reporting/server/lib/esqueue/constants/statuses.js deleted file mode 100644 index 620a567e18fe7..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/esqueue/constants/statuses.js +++ /dev/null @@ -1,13 +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 const statuses = { - JOB_STATUS_PENDING: 'pending', - JOB_STATUS_PROCESSING: 'processing', - JOB_STATUS_COMPLETED: 'completed', - JOB_STATUS_FAILED: 'failed', - JOB_STATUS_CANCELLED: 'cancelled', -}; diff --git a/x-pack/legacy/plugins/reporting/server/lib/esqueue/constants/statuses.ts b/x-pack/legacy/plugins/reporting/server/lib/esqueue/constants/statuses.ts new file mode 100644 index 0000000000000..7c7f1431adf23 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/esqueue/constants/statuses.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 const statuses = { + JOB_STATUS_PENDING: 'pending', + JOB_STATUS_PROCESSING: 'processing', + JOB_STATUS_COMPLETED: 'completed', + JOB_STATUS_WARNINGS: 'completed_with_warnings', + JOB_STATUS_FAILED: 'failed', + JOB_STATUS_CANCELLED: 'cancelled', +}; diff --git a/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js b/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js index 6cdbe8f968f75..ceb4ef43b2d9d 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js +++ b/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js @@ -8,6 +8,7 @@ import moment from 'moment'; export const intervals = ['year', 'month', 'week', 'day', 'hour', 'minute']; +// TODO: This helper function can be removed by using `schema.duration` objects in the reporting config schema export function indexTimestamp(intervalStr, separator = '-') { if (separator.match(/[a-z]/i)) throw new Error('Interval separator can not be a letter'); diff --git a/x-pack/legacy/plugins/reporting/server/lib/esqueue/worker.js b/x-pack/legacy/plugins/reporting/server/lib/esqueue/worker.js index 113059fa2fa47..ab0bb6740f078 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/esqueue/worker.js +++ b/x-pack/legacy/plugins/reporting/server/lib/esqueue/worker.js @@ -285,8 +285,12 @@ export class Worker extends events.EventEmitter { const completedTime = moment().toISOString(); const docOutput = this._formatOutput(output); + const status = + output && output.warnings && output.warnings.length > 0 + ? constants.JOB_STATUS_WARNINGS + : constants.JOB_STATUS_COMPLETED; const doc = { - status: constants.JOB_STATUS_COMPLETED, + status, completed_at: completedTime, output: docOutput, }; diff --git a/x-pack/legacy/plugins/reporting/server/plugin.ts b/x-pack/legacy/plugins/reporting/server/plugin.ts index c9ed2e81c6792..e0fa99106a93e 100644 --- a/x-pack/legacy/plugins/reporting/server/plugin.ts +++ b/x-pack/legacy/plugins/reporting/server/plugin.ts @@ -5,15 +5,12 @@ */ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/server'; -import { logConfiguration } from '../log_configuration'; import { createBrowserDriverFactory } from './browsers'; import { ReportingCore, ReportingConfig } from './core'; import { createQueueFactory, enqueueJobFactory, LevelLogger, runValidations } from './lib'; import { setFieldFormats } from './services'; import { ReportingSetup, ReportingSetupDeps, ReportingStart, ReportingStartDeps } from './types'; import { registerReportingUsageCollector } from './usage'; -// @ts-ignore no module definition -import { mirrorPluginStatus } from '../../../server/lib/mirror_plugin_status'; export class ReportingPlugin implements Plugin { @@ -61,8 +58,6 @@ export class ReportingPlugin setFieldFormats(plugins.data.fieldFormats); - logConfiguration(this.config.get('capture'), this.logger); - return {}; } diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js b/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js deleted file mode 100644 index 9f0de844df369..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js +++ /dev/null @@ -1,323 +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 Hapi from 'hapi'; -import { createMockReportingCore } from '../../test_helpers'; -import { ExportTypesRegistry } from '../lib/export_types_registry'; - -jest.mock('./lib/authorized_user_pre_routing', () => ({ - authorizedUserPreRoutingFactory: () => () => ({}), -})); -jest.mock('./lib/reporting_feature_pre_routing', () => ({ - reportingFeaturePreRoutingFactory: () => () => () => ({ - jobTypes: ['unencodedJobType', 'base64EncodedJobType'], - }), -})); - -import { registerJobInfoRoutes } from './jobs'; - -let mockServer; -let exportTypesRegistry; -let mockReportingPlugin; -let mockReportingConfig; -const mockLogger = { - error: jest.fn(), - debug: jest.fn(), -}; - -beforeEach(async () => { - mockServer = new Hapi.Server({ debug: false, port: 8080, routes: { log: { collect: true } } }); - exportTypesRegistry = new ExportTypesRegistry(); - exportTypesRegistry.register({ - id: 'unencoded', - jobType: 'unencodedJobType', - jobContentExtension: 'csv', - }); - exportTypesRegistry.register({ - id: 'base64Encoded', - jobType: 'base64EncodedJobType', - jobContentEncoding: 'base64', - jobContentExtension: 'pdf', - }); - - mockReportingConfig = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; - mockReportingPlugin = await createMockReportingCore(mockReportingConfig); - mockReportingPlugin.getExportTypesRegistry = () => exportTypesRegistry; -}); - -const mockPlugins = { - elasticsearch: { - adminClient: { callAsInternalUser: jest.fn() }, - }, - security: null, -}; - -const getHits = (...sources) => { - return { - hits: { - hits: sources.map(source => ({ _source: source })), - }, - }; -}; - -const getErrorsFromRequest = request => - request.logs.filter(log => log.tags.includes('error')).map(log => log.error); - -test(`returns 404 if job not found`, async () => { - mockPlugins.elasticsearch.adminClient = { - callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(getHits())), - }; - - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); - - const request = { - method: 'GET', - url: '/api/reporting/jobs/download/1', - }; - - const response = await mockServer.inject(request); - const { statusCode } = response; - expect(statusCode).toBe(404); -}); - -test(`returns 401 if not valid job type`, async () => { - mockPlugins.elasticsearch.adminClient = { - callAsInternalUser: jest - .fn() - .mockReturnValue(Promise.resolve(getHits({ jobtype: 'invalidJobType' }))), - }; - - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); - - const request = { - method: 'GET', - url: '/api/reporting/jobs/download/1', - }; - - const { statusCode } = await mockServer.inject(request); - expect(statusCode).toBe(401); -}); - -describe(`when job is incomplete`, () => { - const getIncompleteResponse = async () => { - mockPlugins.elasticsearch.adminClient = { - callAsInternalUser: jest - .fn() - .mockReturnValue( - Promise.resolve(getHits({ jobtype: 'unencodedJobType', status: 'pending' })) - ), - }; - - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); - - const request = { - method: 'GET', - url: '/api/reporting/jobs/download/1', - }; - - return await mockServer.inject(request); - }; - - test(`sets statusCode to 503`, async () => { - const { statusCode } = await getIncompleteResponse(); - expect(statusCode).toBe(503); - }); - - test(`uses status as payload`, async () => { - const { payload } = await getIncompleteResponse(); - expect(payload).toBe('pending'); - }); - - test(`sets content-type header to application/json; charset=utf-8`, async () => { - const { headers } = await getIncompleteResponse(); - expect(headers['content-type']).toBe('application/json; charset=utf-8'); - }); - - test(`sets retry-after header to 30`, async () => { - const { headers } = await getIncompleteResponse(); - expect(headers['retry-after']).toBe(30); - }); -}); - -describe(`when job is failed`, () => { - const getFailedResponse = async () => { - const hits = getHits({ - jobtype: 'unencodedJobType', - status: 'failed', - output: { content: 'job failure message' }, - }); - mockPlugins.elasticsearch.adminClient = { - callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), - }; - - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); - - const request = { - method: 'GET', - url: '/api/reporting/jobs/download/1', - }; - - return await mockServer.inject(request); - }; - - test(`sets status code to 500`, async () => { - const { statusCode } = await getFailedResponse(); - expect(statusCode).toBe(500); - }); - - test(`sets content-type header to application/json; charset=utf-8`, async () => { - const { headers } = await getFailedResponse(); - expect(headers['content-type']).toBe('application/json; charset=utf-8'); - }); - - test(`sets the payload.reason to the job content`, async () => { - const { payload } = await getFailedResponse(); - expect(JSON.parse(payload).reason).toBe('job failure message'); - }); -}); - -describe(`when job is completed`, () => { - const getCompletedResponse = async ({ - jobType = 'unencodedJobType', - outputContent = 'job output content', - outputContentType = 'application/pdf', - title = '', - } = {}) => { - const hits = getHits({ - jobtype: jobType, - status: 'completed', - output: { content: outputContent, content_type: outputContentType }, - payload: { - title, - }, - }); - mockPlugins.elasticsearch.adminClient = { - callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), - }; - - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); - - const request = { - method: 'GET', - url: '/api/reporting/jobs/download/1', - }; - - return await mockServer.inject(request); - }; - - test(`sets statusCode to 200`, async () => { - const { statusCode, request } = await getCompletedResponse(); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toEqual([]); - expect(statusCode).toBe(200); - }); - - test(`doesn't encode output content for not-specified jobTypes`, async () => { - const { payload, request } = await getCompletedResponse({ - jobType: 'unencodedJobType', - outputContent: 'test', - }); - - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toEqual([]); - - expect(payload).toBe('test'); - }); - - test(`base64 encodes output content for configured jobTypes`, async () => { - const { payload, request } = await getCompletedResponse({ - jobType: 'base64EncodedJobType', - outputContent: 'test', - }); - - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toEqual([]); - - expect(payload).toBe(Buffer.from('test', 'base64').toString()); - }); - - test(`specifies text/csv; charset=utf-8 contentType header from the job output`, async () => { - const { headers, request } = await getCompletedResponse({ outputContentType: 'text/csv' }); - - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toEqual([]); - - expect(headers['content-type']).toBe('text/csv; charset=utf-8'); - }); - - test(`specifies default filename in content-disposition header if no title`, async () => { - const { headers, request } = await getCompletedResponse({}); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toEqual([]); - expect(headers['content-disposition']).toBe('inline; filename="report.csv"'); - }); - - test(`specifies payload title in content-disposition header`, async () => { - const { headers, request } = await getCompletedResponse({ title: 'something' }); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toEqual([]); - expect(headers['content-disposition']).toBe('inline; filename="something.csv"'); - }); - - test(`specifies jobContentExtension in content-disposition header`, async () => { - const { headers, request } = await getCompletedResponse({ jobType: 'base64EncodedJobType' }); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toEqual([]); - expect(headers['content-disposition']).toBe('inline; filename="report.pdf"'); - }); - - test(`specifies application/pdf contentType header from the job output`, async () => { - const { headers, request } = await getCompletedResponse({ - outputContentType: 'application/pdf', - }); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toEqual([]); - expect(headers['content-type']).toBe('application/pdf'); - }); - - describe(`when non-whitelisted contentType specified in job output`, () => { - test(`sets statusCode to 500`, async () => { - const { statusCode, request } = await getCompletedResponse({ - outputContentType: 'application/html', - }); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toMatchInlineSnapshot(` - Array [ - [Error: Unsupported content-type of application/html specified by job output], - [Error: Unsupported content-type of application/html specified by job output], - ] - `); - expect(statusCode).toBe(500); - }); - - test(`doesn't include job output content in payload`, async () => { - const { payload, request } = await getCompletedResponse({ - outputContentType: 'application/html', - }); - expect(payload).toMatchInlineSnapshot( - `"{\\"statusCode\\":500,\\"error\\":\\"Internal Server Error\\",\\"message\\":\\"An internal server error occurred\\"}"` - ); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toMatchInlineSnapshot(` - Array [ - [Error: Unsupported content-type of application/html specified by job output], - [Error: Unsupported content-type of application/html specified by job output], - ] - `); - }); - - test(`logs error message about invalid content type`, async () => { - const { request } = await getCompletedResponse({ outputContentType: 'application/html' }); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toMatchInlineSnapshot(` - Array [ - [Error: Unsupported content-type of application/html specified by job output], - [Error: Unsupported content-type of application/html specified by job output], - ] - `); - }); - }); -}); diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.test.ts b/x-pack/legacy/plugins/reporting/server/routes/jobs.test.ts new file mode 100644 index 0000000000000..5c58a7dfa0110 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.test.ts @@ -0,0 +1,331 @@ +/* + * 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 Hapi from 'hapi'; +import { createMockReportingCore } from '../../test_helpers'; +import { ExportTypeDefinition } from '../../types'; +import { ExportTypesRegistry } from '../lib/export_types_registry'; +import { LevelLogger } from '../lib/level_logger'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; + +jest.mock('./lib/authorized_user_pre_routing', () => ({ + authorizedUserPreRoutingFactory: () => () => ({}), +})); +jest.mock('./lib/reporting_feature_pre_routing', () => ({ + reportingFeaturePreRoutingFactory: () => () => () => ({ + jobTypes: ['unencodedJobType', 'base64EncodedJobType'], + }), +})); + +import { registerJobInfoRoutes } from './jobs'; + +let mockServer: any; +let exportTypesRegistry: ExportTypesRegistry; +let mockReportingPlugin: ReportingCore; +let mockReportingConfig: ReportingConfig; +const mockLogger = ({ + error: jest.fn(), + debug: jest.fn(), +} as unknown) as LevelLogger; + +beforeEach(async () => { + mockServer = new Hapi.Server({ debug: false, port: 8080, routes: { log: { collect: true } } }); + exportTypesRegistry = new ExportTypesRegistry(); + exportTypesRegistry.register({ + id: 'unencoded', + jobType: 'unencodedJobType', + jobContentExtension: 'csv', + } as ExportTypeDefinition); + exportTypesRegistry.register({ + id: 'base64Encoded', + jobType: 'base64EncodedJobType', + jobContentEncoding: 'base64', + jobContentExtension: 'pdf', + } as ExportTypeDefinition); + + mockReportingConfig = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; + mockReportingPlugin = await createMockReportingCore(mockReportingConfig); + mockReportingPlugin.getExportTypesRegistry = () => exportTypesRegistry; +}); + +const mockPlugins = ({ + elasticsearch: { + adminClient: { callAsInternalUser: jest.fn() }, + }, + security: null, +} as unknown) as ReportingSetupDeps; + +const getHits = (...sources: any) => { + return { + hits: { + hits: sources.map((source: object) => ({ _source: source })), + }, + }; +}; + +const getErrorsFromRequest = (request: any) => + request.logs.filter((log: any) => log.tags.includes('error')).map((log: any) => log.error); + +test(`returns 404 if job not found`, async () => { + // @ts-ignore + mockPlugins.elasticsearch.adminClient = { + callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(getHits())), + }; + + registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + + const request = { + method: 'GET', + url: '/api/reporting/jobs/download/1', + }; + + const response = await mockServer.inject(request); + const { statusCode } = response; + expect(statusCode).toBe(404); +}); + +test(`returns 401 if not valid job type`, async () => { + // @ts-ignore + mockPlugins.elasticsearch.adminClient = { + callAsInternalUser: jest + .fn() + .mockReturnValue(Promise.resolve(getHits({ jobtype: 'invalidJobType' }))), + }; + + registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + + const request = { + method: 'GET', + url: '/api/reporting/jobs/download/1', + }; + + const { statusCode } = await mockServer.inject(request); + expect(statusCode).toBe(401); +}); + +describe(`when job is incomplete`, () => { + const getIncompleteResponse = async () => { + // @ts-ignore + mockPlugins.elasticsearch.adminClient = { + callAsInternalUser: jest + .fn() + .mockReturnValue( + Promise.resolve(getHits({ jobtype: 'unencodedJobType', status: 'pending' })) + ), + }; + + registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + + const request = { + method: 'GET', + url: '/api/reporting/jobs/download/1', + }; + + return await mockServer.inject(request); + }; + + test(`sets statusCode to 503`, async () => { + const { statusCode } = await getIncompleteResponse(); + expect(statusCode).toBe(503); + }); + + test(`uses status as payload`, async () => { + const { payload } = await getIncompleteResponse(); + expect(payload).toBe('pending'); + }); + + test(`sets content-type header to application/json; charset=utf-8`, async () => { + const { headers } = await getIncompleteResponse(); + expect(headers['content-type']).toBe('application/json; charset=utf-8'); + }); + + test(`sets retry-after header to 30`, async () => { + const { headers } = await getIncompleteResponse(); + expect(headers['retry-after']).toBe(30); + }); +}); + +describe(`when job is failed`, () => { + const getFailedResponse = async () => { + const hits = getHits({ + jobtype: 'unencodedJobType', + status: 'failed', + output: { content: 'job failure message' }, + }); + // @ts-ignore + mockPlugins.elasticsearch.adminClient = { + callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), + }; + + registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + + const request = { + method: 'GET', + url: '/api/reporting/jobs/download/1', + }; + + return await mockServer.inject(request); + }; + + test(`sets status code to 500`, async () => { + const { statusCode } = await getFailedResponse(); + expect(statusCode).toBe(500); + }); + + test(`sets content-type header to application/json; charset=utf-8`, async () => { + const { headers } = await getFailedResponse(); + expect(headers['content-type']).toBe('application/json; charset=utf-8'); + }); + + test(`sets the payload.reason to the job content`, async () => { + const { payload } = await getFailedResponse(); + expect(JSON.parse(payload).reason).toBe('job failure message'); + }); +}); + +describe(`when job is completed`, () => { + const getCompletedResponse = async ({ + jobType = 'unencodedJobType', + outputContent = 'job output content', + outputContentType = 'application/pdf', + title = '', + } = {}) => { + const hits = getHits({ + jobtype: jobType, + status: 'completed', + output: { content: outputContent, content_type: outputContentType }, + payload: { + title, + }, + }); + // @ts-ignore + mockPlugins.elasticsearch.adminClient = { + callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), + }; + + registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + + const request = { + method: 'GET', + url: '/api/reporting/jobs/download/1', + }; + + return await mockServer.inject(request); + }; + + test(`sets statusCode to 200`, async () => { + const { statusCode, request } = await getCompletedResponse(); + const errorLogs = getErrorsFromRequest(request); + expect(errorLogs).toEqual([]); + expect(statusCode).toBe(200); + }); + + test(`doesn't encode output content for not-specified jobTypes`, async () => { + const { payload, request } = await getCompletedResponse({ + jobType: 'unencodedJobType', + outputContent: 'test', + }); + + const errorLogs = getErrorsFromRequest(request); + expect(errorLogs).toEqual([]); + + expect(payload).toBe('test'); + }); + + test(`base64 encodes output content for configured jobTypes`, async () => { + const { payload, request } = await getCompletedResponse({ + jobType: 'base64EncodedJobType', + outputContent: 'test', + }); + + const errorLogs = getErrorsFromRequest(request); + expect(errorLogs).toEqual([]); + + expect(payload).toBe(Buffer.from('test', 'base64').toString()); + }); + + test(`specifies text/csv; charset=utf-8 contentType header from the job output`, async () => { + const { headers, request } = await getCompletedResponse({ outputContentType: 'text/csv' }); + + const errorLogs = getErrorsFromRequest(request); + expect(errorLogs).toEqual([]); + + expect(headers['content-type']).toBe('text/csv; charset=utf-8'); + }); + + test(`specifies default filename in content-disposition header if no title`, async () => { + const { headers, request } = await getCompletedResponse({}); + const errorLogs = getErrorsFromRequest(request); + expect(errorLogs).toEqual([]); + expect(headers['content-disposition']).toBe('inline; filename="report.csv"'); + }); + + test(`specifies payload title in content-disposition header`, async () => { + const { headers, request } = await getCompletedResponse({ title: 'something' }); + const errorLogs = getErrorsFromRequest(request); + expect(errorLogs).toEqual([]); + expect(headers['content-disposition']).toBe('inline; filename="something.csv"'); + }); + + test(`specifies jobContentExtension in content-disposition header`, async () => { + const { headers, request } = await getCompletedResponse({ jobType: 'base64EncodedJobType' }); + const errorLogs = getErrorsFromRequest(request); + expect(errorLogs).toEqual([]); + expect(headers['content-disposition']).toBe('inline; filename="report.pdf"'); + }); + + test(`specifies application/pdf contentType header from the job output`, async () => { + const { headers, request } = await getCompletedResponse({ + outputContentType: 'application/pdf', + }); + const errorLogs = getErrorsFromRequest(request); + expect(errorLogs).toEqual([]); + expect(headers['content-type']).toBe('application/pdf'); + }); + + describe(`when non-whitelisted contentType specified in job output`, () => { + test(`sets statusCode to 500`, async () => { + const { statusCode, request } = await getCompletedResponse({ + outputContentType: 'application/html', + }); + const errorLogs = getErrorsFromRequest(request); + expect(errorLogs).toMatchInlineSnapshot(` + Array [ + [Error: Unsupported content-type of application/html specified by job output], + [Error: Unsupported content-type of application/html specified by job output], + ] + `); + expect(statusCode).toBe(500); + }); + + test(`doesn't include job output content in payload`, async () => { + const { payload, request } = await getCompletedResponse({ + outputContentType: 'application/html', + }); + expect(payload).toMatchInlineSnapshot( + `"{\\"statusCode\\":500,\\"error\\":\\"Internal Server Error\\",\\"message\\":\\"An internal server error occurred\\"}"` + ); + const errorLogs = getErrorsFromRequest(request); + expect(errorLogs).toMatchInlineSnapshot(` + Array [ + [Error: Unsupported content-type of application/html specified by job output], + [Error: Unsupported content-type of application/html specified by job output], + ] + `); + }); + + test(`logs error message about invalid content type`, async () => { + const { request } = await getCompletedResponse({ outputContentType: 'application/html' }); + const errorLogs = getErrorsFromRequest(request); + expect(errorLogs).toMatchInlineSnapshot(` + Array [ + [Error: Unsupported content-type of application/html specified by job output], + [Error: Unsupported content-type of application/html specified by job output], + ] + `); + }); + }); +}); diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts index aef37754681ec..c243d0b4266ea 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts @@ -9,6 +9,7 @@ import contentDisposition from 'content-disposition'; import * as _ from 'lodash'; import { CSV_JOB_TYPE } from '../../../common/constants'; import { ExportTypeDefinition, ExportTypesRegistry, JobDocOutput, JobSource } from '../../../types'; +import { statuses } from '../../lib/esqueue/constants/statuses'; interface ICustomHeaders { [x: string]: any; @@ -99,11 +100,11 @@ export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegist const { status, jobtype: jobType, payload: { title } = { title: '' } } = doc._source; const { output } = doc._source; - if (status === 'completed') { + if (status === statuses.JOB_STATUS_COMPLETED || status === statuses.JOB_STATUS_WARNINGS) { return getCompleted(output, jobType, title); } - if (status === 'failed') { + if (status === statuses.JOB_STATUS_FAILED) { return getFailure(output); } diff --git a/x-pack/legacy/plugins/reporting/server/types.d.ts b/x-pack/legacy/plugins/reporting/server/types.d.ts index bec00688432cc..fb77eae4e7eea 100644 --- a/x-pack/legacy/plugins/reporting/server/types.d.ts +++ b/x-pack/legacy/plugins/reporting/server/types.d.ts @@ -11,7 +11,7 @@ import { PluginStart as DataPluginStart } from '../../../../../src/plugins/data/ import { SecurityPluginSetup } from '../../../../plugins/security/server'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; import { ReportingPluginSpecOptions } from '../types'; -import { ReportingConfig, ReportingConfigType } from './core'; +import { ReportingConfigType } from './core'; export interface ReportingSetupDeps { elasticsearch: ElasticsearchServiceSetup; @@ -30,7 +30,6 @@ export type ReportingSetup = object; export type ReportingStart = object; export interface LegacySetup { - config: Legacy.Server['config']; plugins: { xpack_main: XPackMainPlugin & { status?: any; diff --git a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js deleted file mode 100644 index 929109e66914d..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js +++ /dev/null @@ -1,424 +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 sinon from 'sinon'; -import { createMockReportingCore } from '../../test_helpers'; -import { getExportTypesRegistry } from '../lib/export_types_registry'; -import { - registerReportingUsageCollector, - getReportingUsageCollector, -} from './reporting_usage_collector'; - -const exportTypesRegistry = getExportTypesRegistry(); - -function getMockUsageCollection() { - class MockUsageCollector { - constructor(_server, { fetch }) { - this.fetch = fetch; - } - } - return { - makeUsageCollector: options => { - return new MockUsageCollector(this, options); - }, - registerCollector: sinon.stub(), - }; -} - -function getPluginsMock( - { license, usageCollection = getMockUsageCollection() } = { license: 'platinum' } -) { - const mockXpackMain = { - info: { - isAvailable: sinon.stub().returns(true), - feature: () => ({ - getLicenseCheckResults: sinon.stub(), - }), - license: { - isOneOf: sinon.stub().returns(false), - getType: sinon.stub().returns(license), - }, - toJSON: () => ({ b: 1 }), - }, - }; - return { - usageCollection, - __LEGACY: { - plugins: { - xpack_main: mockXpackMain, - }, - }, - }; -} - -const getMockReportingConfig = () => ({ - get: () => {}, - kbnConfig: { get: () => '' }, -}); -const getResponseMock = (customization = {}) => customization; - -describe('license checks', () => { - let mockConfig; - beforeAll(async () => { - mockConfig = getMockReportingConfig(); - }); - - describe('with a basic license', () => { - let usageStats; - beforeAll(async () => { - const plugins = getPluginsMock({ license: 'basic' }); - const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const { fetch } = getReportingUsageCollector( - mockConfig, - plugins.usageCollection, - plugins.__LEGACY.plugins.xpack_main.info, - exportTypesRegistry - ); - usageStats = await fetch(callClusterMock, exportTypesRegistry); - }); - - test('sets enables to true', async () => { - expect(usageStats.enabled).toBe(true); - }); - - test('sets csv available to true', async () => { - expect(usageStats.csv.available).toBe(true); - }); - - test('sets pdf availability to false', async () => { - expect(usageStats.printable_pdf.available).toBe(false); - }); - }); - - describe('with no license', () => { - let usageStats; - beforeAll(async () => { - const plugins = getPluginsMock({ license: 'none' }); - const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const { fetch } = getReportingUsageCollector( - mockConfig, - plugins.usageCollection, - plugins.__LEGACY.plugins.xpack_main.info, - exportTypesRegistry - ); - usageStats = await fetch(callClusterMock, exportTypesRegistry); - }); - - test('sets enables to true', async () => { - expect(usageStats.enabled).toBe(true); - }); - - test('sets csv available to false', async () => { - expect(usageStats.csv.available).toBe(false); - }); - - test('sets pdf availability to false', async () => { - expect(usageStats.printable_pdf.available).toBe(false); - }); - }); - - describe('with platinum license', () => { - let usageStats; - beforeAll(async () => { - const plugins = getPluginsMock({ license: 'platinum' }); - const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const { fetch } = getReportingUsageCollector( - mockConfig, - plugins.usageCollection, - plugins.__LEGACY.plugins.xpack_main.info, - exportTypesRegistry - ); - usageStats = await fetch(callClusterMock, exportTypesRegistry); - }); - - test('sets enables to true', async () => { - expect(usageStats.enabled).toBe(true); - }); - - test('sets csv available to true', async () => { - expect(usageStats.csv.available).toBe(true); - }); - - test('sets pdf availability to true', async () => { - expect(usageStats.printable_pdf.available).toBe(true); - }); - }); - - describe('with no usage data', () => { - let usageStats; - beforeAll(async () => { - const plugins = getPluginsMock({ license: 'basic' }); - const callClusterMock = jest.fn(() => Promise.resolve({})); - const { fetch } = getReportingUsageCollector( - mockConfig, - plugins.usageCollection, - plugins.__LEGACY.plugins.xpack_main.info, - exportTypesRegistry - ); - usageStats = await fetch(callClusterMock, exportTypesRegistry); - }); - - test('sets enables to true', async () => { - expect(usageStats.enabled).toBe(true); - }); - - test('sets csv available to true', async () => { - expect(usageStats.csv.available).toBe(true); - }); - }); -}); - -describe('data modeling', () => { - test('with normal looking usage data', async () => { - const mockConfig = getMockReportingConfig(); - const plugins = getPluginsMock(); - const { fetch } = getReportingUsageCollector( - mockConfig, - plugins.usageCollection, - plugins.__LEGACY.plugins.xpack_main.info, - exportTypesRegistry - ); - const callClusterMock = jest.fn(() => - Promise.resolve( - getResponseMock({ - aggregations: { - ranges: { - buckets: { - all: { - doc_count: 54, - layoutTypes: { - doc_count: 23, - pdf: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'preserve_layout', doc_count: 13 }, - { key: 'print', doc_count: 10 }, - ], - }, - }, - objectTypes: { - doc_count: 23, - pdf: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'dashboard', doc_count: 23 }], - }, - }, - statusTypes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'pending', doc_count: 33 }, - { key: 'completed', doc_count: 20 }, - { key: 'processing', doc_count: 1 }, - ], - }, - jobTypes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'csv', doc_count: 27 }, - { key: 'printable_pdf', doc_count: 23 }, - { key: 'PNG', doc_count: 4 }, - ], - }, - }, - lastDay: { - doc_count: 11, - layoutTypes: { - doc_count: 2, - pdf: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'print', doc_count: 2 }], - }, - }, - objectTypes: { - doc_count: 2, - pdf: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'dashboard', doc_count: 2 }], - }, - }, - statusTypes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'pending', doc_count: 11 }], - }, - jobTypes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'csv', doc_count: 5 }, - { key: 'PNG', doc_count: 4 }, - { key: 'printable_pdf', doc_count: 2 }, - ], - }, - }, - last7Days: { - doc_count: 27, - layoutTypes: { - doc_count: 13, - pdf: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'print', doc_count: 10 }, - { key: 'preserve_layout', doc_count: 3 }, - ], - }, - }, - objectTypes: { - doc_count: 13, - pdf: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'dashboard', doc_count: 13 }], - }, - }, - statusTypes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'pending', doc_count: 27 }], - }, - jobTypes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'printable_pdf', doc_count: 13 }, - { key: 'csv', doc_count: 10 }, - { key: 'PNG', doc_count: 4 }, - ], - }, - }, - }, - }, - }, - }) - ) - ); - - const usageStats = await fetch(callClusterMock); - expect(usageStats).toMatchInlineSnapshot(` - Object { - "PNG": Object { - "available": true, - "total": 4, - }, - "_all": 54, - "available": true, - "browser_type": undefined, - "csv": Object { - "available": true, - "total": 27, - }, - "enabled": true, - "last7Days": Object { - "PNG": Object { - "available": true, - "total": 4, - }, - "_all": 27, - "csv": Object { - "available": true, - "total": 10, - }, - "printable_pdf": Object { - "app": Object { - "dashboard": 13, - "visualization": 0, - }, - "available": true, - "layout": Object { - "preserve_layout": 3, - "print": 10, - }, - "total": 13, - }, - "status": Object { - "completed": 0, - "failed": 0, - "pending": 27, - }, - }, - "lastDay": Object { - "PNG": Object { - "available": true, - "total": 4, - }, - "_all": 11, - "csv": Object { - "available": true, - "total": 5, - }, - "printable_pdf": Object { - "app": Object { - "dashboard": 2, - "visualization": 0, - }, - "available": true, - "layout": Object { - "preserve_layout": 0, - "print": 2, - }, - "total": 2, - }, - "status": Object { - "completed": 0, - "failed": 0, - "pending": 11, - }, - }, - "printable_pdf": Object { - "app": Object { - "dashboard": 23, - "visualization": 0, - }, - "available": true, - "layout": Object { - "preserve_layout": 13, - "print": 10, - }, - "total": 23, - }, - "status": Object { - "completed": 20, - "failed": 0, - "pending": 33, - "processing": 1, - }, - } - `); - }); -}); - -describe('Ready for collection observable', () => { - test('converts observable to promise', async () => { - const mockConfig = getMockReportingConfig(); - const mockReporting = await createMockReportingCore(mockConfig); - - const usageCollection = getMockUsageCollection(); - const makeCollectorSpy = sinon.spy(); - usageCollection.makeUsageCollector = makeCollectorSpy; - - const plugins = getPluginsMock({ usageCollection }); - registerReportingUsageCollector(mockReporting, plugins); - - const [args] = makeCollectorSpy.firstCall.args; - expect(args).toMatchInlineSnapshot(` - Object { - "fetch": [Function], - "formatForBulkUpload": [Function], - "isReady": [Function], - "type": "reporting", - } - `); - - await expect(args.isReady()).resolves.toBe(true); - }); -}); diff --git a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.ts b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.ts new file mode 100644 index 0000000000000..dbc674ce36ec8 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.ts @@ -0,0 +1,442 @@ +/* + * 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 sinon from 'sinon'; +import { createMockReportingCore } from '../../test_helpers'; +import { getExportTypesRegistry } from '../lib/export_types_registry'; +import { + registerReportingUsageCollector, + getReportingUsageCollector, +} from './reporting_usage_collector'; +import { ReportingConfig } from '../types'; + +const exportTypesRegistry = getExportTypesRegistry(); + +function getMockUsageCollection() { + class MockUsageCollector { + // @ts-ignore fetch is not used + private fetch: any; + constructor(_server: any, { fetch }: any) { + this.fetch = fetch; + } + } + return { + makeUsageCollector: (options: any) => { + return new MockUsageCollector(null, options); + }, + registerCollector: sinon.stub(), + }; +} + +function getPluginsMock( + { license, usageCollection = getMockUsageCollection() } = { license: 'platinum' } +) { + const mockXpackMain = { + info: { + isAvailable: sinon.stub().returns(true), + feature: () => ({ + getLicenseCheckResults: sinon.stub(), + }), + license: { + isOneOf: sinon.stub().returns(false), + getType: sinon.stub().returns(license), + }, + toJSON: () => ({ b: 1 }), + }, + }; + return { + usageCollection, + __LEGACY: { + plugins: { + xpack_main: mockXpackMain, + }, + }, + } as any; +} + +const getMockReportingConfig = () => ({ + get: () => {}, + kbnConfig: { get: () => '' }, +}); +const getResponseMock = (customization = {}) => customization; + +describe('license checks', () => { + let mockConfig: ReportingConfig; + beforeAll(async () => { + mockConfig = getMockReportingConfig(); + }); + + describe('with a basic license', () => { + let usageStats: any; + beforeAll(async () => { + const plugins = getPluginsMock({ license: 'basic' }); + const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); + const { fetch } = getReportingUsageCollector( + mockConfig, + plugins.usageCollection, + plugins.__LEGACY.plugins.xpack_main.info, + exportTypesRegistry, + function isReady() { + return Promise.resolve(true); + } + ); + usageStats = await fetch(callClusterMock as any); + }); + + test('sets enables to true', async () => { + expect(usageStats.enabled).toBe(true); + }); + + test('sets csv available to true', async () => { + expect(usageStats.csv.available).toBe(true); + }); + + test('sets pdf availability to false', async () => { + expect(usageStats.printable_pdf.available).toBe(false); + }); + }); + + describe('with no license', () => { + let usageStats: any; + beforeAll(async () => { + const plugins = getPluginsMock({ license: 'none' }); + const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); + const { fetch } = getReportingUsageCollector( + mockConfig, + plugins.usageCollection, + plugins.__LEGACY.plugins.xpack_main.info, + exportTypesRegistry, + function isReady() { + return Promise.resolve(true); + } + ); + usageStats = await fetch(callClusterMock as any); + }); + + test('sets enables to true', async () => { + expect(usageStats.enabled).toBe(true); + }); + + test('sets csv available to false', async () => { + expect(usageStats.csv.available).toBe(false); + }); + + test('sets pdf availability to false', async () => { + expect(usageStats.printable_pdf.available).toBe(false); + }); + }); + + describe('with platinum license', () => { + let usageStats: any; + beforeAll(async () => { + const plugins = getPluginsMock({ license: 'platinum' }); + const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); + const { fetch } = getReportingUsageCollector( + mockConfig, + plugins.usageCollection, + plugins.__LEGACY.plugins.xpack_main.info, + exportTypesRegistry, + function isReady() { + return Promise.resolve(true); + } + ); + usageStats = await fetch(callClusterMock as any); + }); + + test('sets enables to true', async () => { + expect(usageStats.enabled).toBe(true); + }); + + test('sets csv available to true', async () => { + expect(usageStats.csv.available).toBe(true); + }); + + test('sets pdf availability to true', async () => { + expect(usageStats.printable_pdf.available).toBe(true); + }); + }); + + describe('with no usage data', () => { + let usageStats: any; + beforeAll(async () => { + const plugins = getPluginsMock({ license: 'basic' }); + const callClusterMock = jest.fn(() => Promise.resolve({})); + const { fetch } = getReportingUsageCollector( + mockConfig, + plugins.usageCollection, + plugins.__LEGACY.plugins.xpack_main.info, + exportTypesRegistry, + function isReady() { + return Promise.resolve(true); + } + ); + usageStats = await fetch(callClusterMock as any); + }); + + test('sets enables to true', async () => { + expect(usageStats.enabled).toBe(true); + }); + + test('sets csv available to true', async () => { + expect(usageStats.csv.available).toBe(true); + }); + }); +}); + +describe('data modeling', () => { + test('with normal looking usage data', async () => { + const mockConfig = getMockReportingConfig(); + const plugins = getPluginsMock(); + const { fetch } = getReportingUsageCollector( + mockConfig, + plugins.usageCollection, + plugins.__LEGACY.plugins.xpack_main.info, + exportTypesRegistry, + function isReady() { + return Promise.resolve(true); + } + ); + const callClusterMock = jest.fn(() => + Promise.resolve( + getResponseMock({ + aggregations: { + ranges: { + buckets: { + all: { + doc_count: 54, + layoutTypes: { + doc_count: 23, + pdf: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'preserve_layout', doc_count: 13 }, + { key: 'print', doc_count: 10 }, + ], + }, + }, + objectTypes: { + doc_count: 23, + pdf: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'dashboard', doc_count: 23 }], + }, + }, + statusTypes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'pending', doc_count: 33 }, + { key: 'completed', doc_count: 20 }, + { key: 'processing', doc_count: 1 }, + ], + }, + jobTypes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'csv', doc_count: 27 }, + { key: 'printable_pdf', doc_count: 23 }, + { key: 'PNG', doc_count: 4 }, + ], + }, + }, + lastDay: { + doc_count: 11, + layoutTypes: { + doc_count: 2, + pdf: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'print', doc_count: 2 }], + }, + }, + objectTypes: { + doc_count: 2, + pdf: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'dashboard', doc_count: 2 }], + }, + }, + statusTypes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'pending', doc_count: 11 }], + }, + jobTypes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'csv', doc_count: 5 }, + { key: 'PNG', doc_count: 4 }, + { key: 'printable_pdf', doc_count: 2 }, + ], + }, + }, + last7Days: { + doc_count: 27, + layoutTypes: { + doc_count: 13, + pdf: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'print', doc_count: 10 }, + { key: 'preserve_layout', doc_count: 3 }, + ], + }, + }, + objectTypes: { + doc_count: 13, + pdf: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'dashboard', doc_count: 13 }], + }, + }, + statusTypes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'pending', doc_count: 27 }], + }, + jobTypes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'printable_pdf', doc_count: 13 }, + { key: 'csv', doc_count: 10 }, + { key: 'PNG', doc_count: 4 }, + ], + }, + }, + }, + }, + }, + }) + ) + ); + + const usageStats = await fetch(callClusterMock as any); + expect(usageStats).toMatchInlineSnapshot(` + Object { + "PNG": Object { + "available": true, + "total": 4, + }, + "_all": 54, + "available": true, + "browser_type": undefined, + "csv": Object { + "available": true, + "total": 27, + }, + "enabled": true, + "last7Days": Object { + "PNG": Object { + "available": true, + "total": 4, + }, + "_all": 27, + "csv": Object { + "available": true, + "total": 10, + }, + "printable_pdf": Object { + "app": Object { + "dashboard": 13, + "visualization": 0, + }, + "available": true, + "layout": Object { + "preserve_layout": 3, + "print": 10, + }, + "total": 13, + }, + "status": Object { + "completed": 0, + "failed": 0, + "pending": 27, + }, + }, + "lastDay": Object { + "PNG": Object { + "available": true, + "total": 4, + }, + "_all": 11, + "csv": Object { + "available": true, + "total": 5, + }, + "printable_pdf": Object { + "app": Object { + "dashboard": 2, + "visualization": 0, + }, + "available": true, + "layout": Object { + "preserve_layout": 0, + "print": 2, + }, + "total": 2, + }, + "status": Object { + "completed": 0, + "failed": 0, + "pending": 11, + }, + }, + "printable_pdf": Object { + "app": Object { + "dashboard": 23, + "visualization": 0, + }, + "available": true, + "layout": Object { + "preserve_layout": 13, + "print": 10, + }, + "total": 23, + }, + "status": Object { + "completed": 20, + "failed": 0, + "pending": 33, + "processing": 1, + }, + } + `); + }); +}); + +describe('Ready for collection observable', () => { + test('converts observable to promise', async () => { + const mockConfig = getMockReportingConfig(); + const mockReporting = await createMockReportingCore(mockConfig); + + const usageCollection = getMockUsageCollection(); + const makeCollectorSpy = sinon.spy(); + usageCollection.makeUsageCollector = makeCollectorSpy; + + const plugins = getPluginsMock({ usageCollection } as any); + registerReportingUsageCollector(mockReporting, plugins); + + const [args] = makeCollectorSpy.firstCall.args; + expect(args).toMatchInlineSnapshot(` + Object { + "fetch": [Function], + "formatForBulkUpload": [Function], + "isReady": [Function], + "type": "reporting", + } + `); + + await expect(args.isReady()).resolves.toBe(true); + }); +}); diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts index 930aa7601b8cb..1be10f6a2056f 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts @@ -34,7 +34,7 @@ const getMockElementsPositionAndAttributes = ( ): ElementsPositionAndAttribute[] => [ { position: { - boundingClientRect: { top: 0, left: 0, width: 10, height: 11 }, + boundingClientRect: { top: 0, left: 0, width: 800, height: 600 }, scroll: { x: 0, y: 0 }, }, attributes: { title, description }, @@ -78,7 +78,7 @@ mockBrowserEvaluate.mockImplementation(() => { }); const mockScreenshot = jest.fn(); mockScreenshot.mockImplementation((item: ElementsPositionAndAttribute) => { - return Promise.resolve(`allyourBase64 of ${Object.keys(item)}`); + return Promise.resolve(`allyourBase64`); }); const getCreatePage = (driver: HeadlessChromiumDriver) => jest.fn().mockImplementation(() => Rx.of({ driver, exit$: Rx.never() })); @@ -92,33 +92,25 @@ const defaultOpts: CreateMockBrowserDriverFactoryOpts = { export const createMockBrowserDriverFactory = async ( logger: Logger, - opts: Partial + opts: Partial = {} ): Promise => { - const captureConfig = { + const captureConfig: CaptureConfig = { timeouts: { openUrl: 30000, waitForElements: 30000, renderComplete: 30000 }, browser: { type: 'chromium', chromium: { inspect: false, disableSandbox: false, - userDataDir: '/usr/data/dir', - viewport: { width: 12, height: 12 }, proxy: { enabled: false, server: undefined, bypass: undefined }, }, autoDownload: false, - inspect: true, - userDataDir: '/usr/data/dir', - viewport: { width: 12, height: 12 }, - disableSandbox: false, - proxy: { enabled: false, server: undefined, bypass: undefined }, - maxScreenshotDimension: undefined, }, networkPolicy: { enabled: true, rules: [] }, viewport: { width: 800, height: 600 }, loadDelay: 2000, - zoom: 1, + zoom: 2, maxAttempts: 1, - } as CaptureConfig; + }; const binaryPath = '/usr/local/share/common/secure/'; const mockBrowserDriverFactory = await createDriverFactory(binaryPath, logger, captureConfig); diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts index be60b56dcc0c1..81090e7616501 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts @@ -12,7 +12,7 @@ import { CaptureConfig } from '../server/types'; export const createMockLayoutInstance = (captureConfig: CaptureConfig) => { const mockLayout = createLayout(captureConfig, { id: LayoutTypes.PRESERVE_LAYOUT, - dimensions: { height: 12, width: 12 }, + dimensions: { height: 100, width: 100 }, }) as LayoutInstance; mockLayout.selectors = { renderComplete: 'renderedSelector', diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts index 34ff91d1972a0..ec00023b4d449 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts @@ -11,7 +11,6 @@ jest.mock('../server/browsers'); jest.mock('../server/lib/create_queue'); jest.mock('../server/lib/enqueue_job'); jest.mock('../server/lib/validate'); -jest.mock('../log_configuration'); import { EventEmitter } from 'events'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths diff --git a/x-pack/legacy/plugins/reporting/types.d.ts b/x-pack/legacy/plugins/reporting/types.d.ts index 7334a859005e0..2e7da6663ab03 100644 --- a/x-pack/legacy/plugins/reporting/types.d.ts +++ b/x-pack/legacy/plugins/reporting/types.d.ts @@ -8,6 +8,7 @@ import { EventEmitter } from 'events'; import { ResponseObject } from 'hapi'; import { Legacy } from 'kibana'; import { CallCluster } from '../../../../src/legacy/core_plugins/elasticsearch'; +import { JobStatus } from '../../../plugins/reporting'; // reporting new platform import { CancellationToken } from './common/cancellation_token'; import { ReportingCore } from './server/core'; import { LevelLogger } from './server/lib/level_logger'; @@ -150,7 +151,7 @@ export interface JobSource { jobtype: string; output: JobDocOutput; payload: JobDocPayload; - status: string; // completed, failed, etc + status: JobStatus; }; } @@ -186,7 +187,7 @@ export type ESQueueWorkerExecuteFn = ( jobId: string, job: JobDocPayloadType, cancellationToken?: CancellationToken -) => void; +) => Promise; /* * ImmediateExecuteFn receives the job doc payload because the payload was diff --git a/x-pack/legacy/plugins/rollup/index.ts b/x-pack/legacy/plugins/rollup/index.ts index 2c8363cc397f4..621667f3618b2 100644 --- a/x-pack/legacy/plugins/rollup/index.ts +++ b/x-pack/legacy/plugins/rollup/index.ts @@ -40,7 +40,7 @@ export function rollup(kibana: any) { }, init(server: any) { const { core: coreSetup, plugins } = server.newPlatform.setup; - const { usageCollection, metrics, indexManagement } = plugins; + const { usageCollection, visTypeTimeseries, indexManagement } = plugins; const rollupSetup = (plugins.rollup as unknown) as RollupSetup; @@ -53,7 +53,7 @@ export function rollup(kibana: any) { rollupPluginInstance.setup(coreSetup, { usageCollection, - metrics, + visTypeTimeseries, indexManagement, __LEGACY: { plugins: { diff --git a/x-pack/legacy/plugins/rollup/kibana.json b/x-pack/legacy/plugins/rollup/kibana.json index 3df8bd7c187d5..78458c9218be3 100644 --- a/x-pack/legacy/plugins/rollup/kibana.json +++ b/x-pack/legacy/plugins/rollup/kibana.json @@ -4,7 +4,7 @@ "requiredPlugins": [ "home", "index_management", - "metrics", + "visTypeTimeseries", "indexPatternManagement" ], "optionalPlugins": [ diff --git a/x-pack/legacy/plugins/rollup/public/plugin.ts b/x-pack/legacy/plugins/rollup/public/plugin.ts index 5782e88c3448b..17ec8a5a4aedf 100644 --- a/x-pack/legacy/plugins/rollup/public/plugin.ts +++ b/x-pack/legacy/plugins/rollup/public/plugin.ts @@ -24,7 +24,7 @@ import { // @ts-ignore import { CRUD_APP_BASE_PATH } from './crud_app/constants'; import { ManagementSetup } from '../../../../../src/plugins/management/public'; -import { IndexMgmtSetup } from '../../../../plugins/index_management/public'; +import { IndexManagementPluginSetup } from '../../../../plugins/index_management/public'; import { IndexPatternManagementSetup } from '../../../../../src/plugins/index_pattern_management/public'; import { search } from '../../../../../src/plugins/data/public'; // @ts-ignore @@ -35,7 +35,7 @@ import { renderApp } from './application'; export interface RollupPluginSetupDependencies { home?: HomePublicPluginSetup; management: ManagementSetup; - indexManagement?: IndexMgmtSetup; + indexManagement?: IndexManagementPluginSetup; indexPatternManagement: IndexPatternManagementSetup; } diff --git a/x-pack/legacy/plugins/rollup/server/plugin.ts b/x-pack/legacy/plugins/rollup/server/plugin.ts index 090cb8a47377a..05c22b030fff9 100644 --- a/x-pack/legacy/plugins/rollup/server/plugin.ts +++ b/x-pack/legacy/plugins/rollup/server/plugin.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { VisTypeTimeseriesSetup } from 'src/plugins/vis_type_timeseries/server'; -import { IndexMgmtSetup } from '../../../../plugins/index_management/server'; +import { IndexManagementPluginSetup } from '../../../../plugins/index_management/server'; import { registerLicenseChecker } from '../../../server/lib/register_license_checker'; import { PLUGIN } from '../common'; import { ServerShim, RouteDependencies } from './types'; @@ -38,13 +38,13 @@ export class RollupsServerPlugin implements Plugin { { __LEGACY: serverShim, usageCollection, - metrics, + visTypeTimeseries, indexManagement, }: { __LEGACY: ServerShim; usageCollection?: UsageCollectionSetup; - metrics?: VisTypeTimeseriesSetup; - indexManagement?: IndexMgmtSetup; + visTypeTimeseries?: VisTypeTimeseriesSetup; + indexManagement?: IndexManagementPluginSetup; } ) { const elasticsearch = await elasticsearchService.adminClient; @@ -83,8 +83,8 @@ export class RollupsServerPlugin implements Plugin { indexManagement.indexDataEnricher.add(rollupDataEnricher); } - if (metrics) { - const { addSearchStrategy } = metrics; + if (visTypeTimeseries) { + const { addSearchStrategy } = visTypeTimeseries; registerRollupSearchStrategy(routeDependencies, addSearchStrategy); } } diff --git a/x-pack/legacy/plugins/siem/cypress/README.md b/x-pack/legacy/plugins/siem/cypress/README.md index 41137ce6d8a9d..89bafce9c9dc8 100644 --- a/x-pack/legacy/plugins/siem/cypress/README.md +++ b/x-pack/legacy/plugins/siem/cypress/README.md @@ -111,6 +111,102 @@ elasticsearch: hosts: ['https://:9200'] ``` +## Running (Headless) Tests on the Command Line as a Jenkins execution (The preferred way) + +To run (headless) tests as a Jenkins execution. + +1. First bootstrap kibana changes from the Kibana root directory: + +```sh +yarn kbn bootstrap +``` + +2. Launch Cypress command line test runner: + +```sh +cd x-pack/legacy/plugins/siem +yarn cypress:run-as-ci +``` + +Note that with this type of execution you don't need to have running a kibana and elasticsearch instance. This is because + the command, as it would happen in the CI, will launch the instances. The elasticsearch instance will be fed data + found in: `x-pack/test/siem_cypress/es_archives` + +As in this case we want to mimic a CI execution we want to execute the tests with the same set of data, this is why +in this case does not make sense to override Cypress environment variables. + +### Test data + +As mentioned above, when running the tests as Jenkins the tests are populated with data ("archives") found in: `x-pack/test/siem_cypress/es_archives`. + +By default, each test is populated with some base data: an empty kibana index and a set of auditbeat data (the `empty_kibana` and `auditbeat` archives, respectively). This is usually enough to cover most of the scenarios that we are testing. + +#### Running tests with additional archives + +When the base data is insufficient, one can specify additional archives. Use `esArchiverLoad` to load the necessary archive, and `esArchiverUnload` to remove the archive from elasticsearch: + +```typescript +import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; + +describe('This are going to be a set of tests', () => { + before(() => { + esArchiverLoad('name_of_the_data_set_you_want_to_load'); + }); + + after(() => { + esArchiverUnload('name_of_the_data_set_you_want_to_unload'); + }); + + it('Going to test something awesome', () => { + hereGoesYourAwesomeTestcode + }); +}); + +``` + +Note that loading and unloading data take a significant amount of time, so try to minimize their use. + +### Current archives + +The current archives can be found in `x-pack/test/siem_cypress/es_archives/`. + +- auditbeat + - Auditbeat data generated in Sep, 2019 with the following hosts present: + - suricata-iowa + - siem-kibana + - siem-es + - jessie +- closed_signals + - Set of data with 108 closed signals linked to "Signals test" custom rule. +- custome_rules + - Set if data with just 4 custom activated rules. +- empty_kibana + - Empty kibana board. +- prebuilt_rules_loaded + - Elastic prebuilt loaded rules and deactivated. +- signals + - Set of data with 108 opened signals linked to "Signals test" custom rule. + +### How to generate a new archive + +We are using es_archiver in order to manage the data that our Cypress tests needs. + +1. Setup if possible a clean instance of kibana and elasticsearch (if not, possible please try to clean the data that you are going to generate). +2. With the kibana and elasticsearch instance up and running, create the data that you need for your test. +3. When you are sure that you have all the data you need run the following command from: `x-pack/legacy/plugins/siem` + +```sh +node ../../../../scripts/es_archiver save --dir ../../../test/siem_cypress/es_archives --config ../../../../test/functional/config.js --es-url http://:@: +``` + +Example: +```sh +node ../../../../scripts/es_archiver save custom_rules ".kibana",".siem-signal*" --dir ../../../test/siem_cypress/es_archives --config ../../../../test/functional/config.js --es-url http://elastic:changeme@localhost:9220 +``` + +Note that the command is going to create the folder if does not exist in the directory with the imported data. + + ## Running Tests Interactively Use the Cypress interactive test runner to develop and debug specific tests @@ -210,30 +306,6 @@ cd x-pack/legacy/plugins/siem CYPRESS_baseUrl=http://localhost:5601 CYPRESS_ELASTICSEARCH_USERNAME=elastic CYPRESS_ELASTICSEARCH_PASSWORD= yarn cypress:run ``` -## Running (Headless) Tests on the Command Line as a Jenkins execution - -To run (headless) tests as a Jenkins execution. - -1. First bootstrap kibana changes from the Kibana root directory: - -```sh -yarn kbn bootstrap -``` - -2. Launch Cypress command line test runner: - -```sh -cd x-pack/legacy/plugins/siem -yarn cypress:run-as-ci -``` - -Note that with this type of execution you don't need to have running a kibana and elasticsearch instance. This is because - the command, as it would happen in the CI, will launch the instances. The elasticsearch instance will be fed with the data - placed in: `x-pack/test/siem_cypress/es_archives`. - -As in this case we want to mimic a CI execution we want to execute the tests with the same set of data, this is why -in this case does not make sense to override Cypress environment variables. - ## Reporting When Cypress tests are run on the command line via `yarn cypress:run`, @@ -280,4 +352,4 @@ target/kibana-siem/cypress/videos ## Linting -Optional linting rules for Cypress and linting setup can be found [here](https://github.com/cypress-io/eslint-plugin-cypress#usage) \ No newline at end of file +Optional linting rules for Cypress and linting setup can be found [here](https://github.com/cypress-io/eslint-plugin-cypress#usage) diff --git a/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts index 2d2db9e70255b..ce6a49b675ef1 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts @@ -7,11 +7,16 @@ import { FIFTH_RULE, FIRST_RULE, RULE_NAME, + RULE_SWITCH, SECOND_RULE, SEVENTH_RULE, } from '../screens/signal_detection_rules'; -import { goToManageSignalDetectionRules } from '../tasks/detections'; +import { + goToManageSignalDetectionRules, + waitForSignalsPanelToBeLoaded, + waitForSignalsIndexToBeCreated, +} from '../tasks/detections'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; import { @@ -32,8 +37,10 @@ describe('Signal detection rules', () => { esArchiverUnload('prebuilt_rules_loaded'); }); - it.skip('Sorts by activated rules', () => { + it('Sorts by activated rules', () => { loginAndWaitForPageWithoutDateRange(DETECTIONS); + waitForSignalsPanelToBeLoaded(); + waitForSignalsIndexToBeCreated(); goToManageSignalDetectionRules(); waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); cy.get(RULE_NAME) @@ -52,10 +59,24 @@ describe('Signal detection rules', () => { cy.get(RULE_NAME) .eq(FIRST_RULE) - .should('have.text', fifthRuleName); - cy.get(RULE_NAME) + .invoke('text') + .then(firstRuleName => { + cy.get(RULE_NAME) + .eq(SECOND_RULE) + .invoke('text') + .then(secondRuleName => { + const expectedRulesNames = `${firstRuleName} ${secondRuleName}`; + cy.wrap(expectedRulesNames).should('include', fifthRuleName); + cy.wrap(expectedRulesNames).should('include', seventhRuleName); + }); + }); + + cy.get(RULE_SWITCH) + .eq(FIRST_RULE) + .should('have.attr', 'role', 'switch'); + cy.get(RULE_SWITCH) .eq(SECOND_RULE) - .should('have.text', seventhRuleName); + .should('have.attr', 'role', 'switch'); }); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/alerts_viewer/alerts_table.tsx b/x-pack/legacy/plugins/siem/public/components/alerts_viewer/alerts_table.tsx index 05d8f97bb8849..dd608babef48f 100644 --- a/x-pack/legacy/plugins/siem/public/components/alerts_viewer/alerts_table.tsx +++ b/x-pack/legacy/plugins/siem/public/components/alerts_viewer/alerts_table.tsx @@ -17,7 +17,7 @@ export interface OwnProps { start: number; } -const ALERTS_TABLE_ID = 'timeline-alerts-table'; +const ALERTS_TABLE_ID = 'alerts-table'; const defaultAlertsFilters: Filter[] = [ { meta: { diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/map_config.ts b/x-pack/legacy/plugins/siem/public/components/embeddables/map_config.ts index e8b267122f86f..8c96e0b75a136 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/map_config.ts +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/map_config.ts @@ -13,6 +13,7 @@ import { LayerMappingDetails, } from './types'; import * as i18n from './translations'; +import { SOURCE_TYPES } from '../../../../../../plugins/maps/common/constants'; const euiVisColorPalette = euiPaletteColorBlind(); // Update field mappings to modify what fields will be returned to map tooltip @@ -101,7 +102,7 @@ export const lmc: LayerMappingCollection = { export const getLayerList = (indexPatternIds: IndexPatternMapping[]) => { return [ { - sourceDescriptor: { type: 'EMS_TMS', isAutoSelect: true }, + sourceDescriptor: { type: SOURCE_TYPES.EMS_TMS, isAutoSelect: true }, id: uuid.v4(), label: null, minZoom: 0, @@ -260,7 +261,7 @@ export const getLineLayer = ( layerDetails: LayerMapping ) => ({ sourceDescriptor: { - type: 'ES_PEW_PEW', + type: SOURCE_TYPES.ES_PEW_PEW, applyGlobalQuery: true, id: uuid.v4(), indexPatternId, diff --git a/x-pack/legacy/plugins/siem/public/components/filter_popover/index.tsx b/x-pack/legacy/plugins/siem/public/components/filter_popover/index.tsx index 3c01ec18a879f..fca6396a53745 100644 --- a/x-pack/legacy/plugins/siem/public/components/filter_popover/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/filter_popover/index.tsx @@ -89,6 +89,7 @@ export const FilterPopoverComponent = ({ {options.map((option, index) => ( diff --git a/x-pack/legacy/plugins/siem/public/components/header_page/editable_title.tsx b/x-pack/legacy/plugins/siem/public/components/header_page/editable_title.tsx index 165be00384779..0c6f7258d09dc 100644 --- a/x-pack/legacy/plugins/siem/public/components/header_page/editable_title.tsx +++ b/x-pack/legacy/plugins/siem/public/components/header_page/editable_title.tsx @@ -60,12 +60,9 @@ const EditableTitleComponent: React.FC = ({ }, [changedTitle, title]); const handleOnChange = useCallback( - (e: ChangeEvent) => { - onTitleChange(e.target.value); - }, - [onTitleChange] + (e: ChangeEvent) => onTitleChange(e.target.value), + [] ); - return editMode ? ( @@ -107,7 +104,7 @@ const EditableTitleComponent: React.FC = ({ </EuiFlexItem> <EuiFlexItem grow={false}> - {isLoading && <MySpinner />} + {isLoading && <MySpinner data-test-subj="editable-title-loading" />} {!isLoading && ( <MyEuiButtonIcon isDisabled={disabled} diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/insert_timeline_popover/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/insert_timeline_popover/index.test.tsx index adac26a8ac92b..e63bce388ae80 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/insert_timeline_popover/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/insert_timeline_popover/index.test.tsx @@ -26,6 +26,7 @@ const mockLocationWithState = { state: { insertTimeline: { timelineId: 'timeline-id', + timelineSavedObjectId: '34578-3497-5893-47589-34759', timelineTitle: 'Timeline title', }, }, @@ -49,7 +50,7 @@ describe('Insert timeline popover ', () => { payload: { id: 'timeline-id', show: false }, type: 'x-pack/siem/local/timeline/SHOW_TIMELINE', }); - expect(onTimelineChange).toBeCalledWith('Timeline title', 'timeline-id'); + expect(onTimelineChange).toBeCalledWith('Timeline title', '34578-3497-5893-47589-34759'); }); it('should do nothing when router state', () => { jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/insert_timeline_popover/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/insert_timeline_popover/index.tsx index cf1a4ebec9bb6..573e010868bab 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/insert_timeline_popover/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/insert_timeline_popover/index.tsx @@ -23,6 +23,7 @@ interface InsertTimelinePopoverProps { interface RouterState { insertTimeline: { timelineId: string; + timelineSavedObjectId: string; timelineTitle: string; }; } @@ -46,7 +47,7 @@ export const InsertTimelinePopoverComponent: React.FC<Props> = ({ ); onTimelineChange( routerState.insertTimeline.timelineTitle, - routerState.insertTimeline.timelineId + routerState.insertTimeline.timelineSavedObjectId ); setRouterState(null); } diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/properties/helpers.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/properties/helpers.tsx index 0a2ab5c9d186a..6f7e1f782d3f6 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/properties/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/properties/helpers.tsx @@ -21,6 +21,7 @@ import React, { useCallback } from 'react'; import uuid from 'uuid'; import styled from 'styled-components'; import { useHistory } from 'react-router-dom'; +import { useSelector } from 'react-redux'; import { Note } from '../../../lib/note'; import { Notes } from '../../notes'; @@ -29,6 +30,8 @@ import { NOTES_PANEL_WIDTH } from './notes_size'; import { ButtonContainer, DescriptionContainer, LabelText, NameField, StyledStar } from './styles'; import * as i18n from './translations'; import { SiemPageName } from '../../../pages/home/types'; +import { timelineSelectors } from '../../../store/timeline'; +import { State } from '../../../store'; export const historyToolTip = 'The chronological history of actions related to this timeline'; export const streamLiveToolTip = 'Update the Timeline as new data arrives'; @@ -121,6 +124,9 @@ interface NewCaseProps { export const NewCase = React.memo<NewCaseProps>(({ onClosePopover, timelineId, timelineTitle }) => { const history = useHistory(); + const { savedObjectId } = useSelector((state: State) => + timelineSelectors.selectTimeline(state, timelineId) + ); const handleClick = useCallback(() => { onClosePopover(); history.push({ @@ -128,6 +134,7 @@ export const NewCase = React.memo<NewCaseProps>(({ onClosePopover, timelineId, t state: { insertTimeline: { timelineId, + timelineSavedObjectId: savedObjectId, timelineTitle: timelineTitle.length > 0 ? timelineTitle : i18n.UNTITLED_TIMELINE, }, }, diff --git a/x-pack/legacy/plugins/siem/public/containers/case/__mocks__/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/__mocks__/api.ts new file mode 100644 index 0000000000000..6d2cfb7147537 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/__mocks__/api.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ActionLicense, + AllCases, + BulkUpdateStatus, + Case, + CasesStatus, + CaseUserActions, + FetchCasesProps, + SortFieldCase, +} from '../types'; +import { + actionLicenses, + allCases, + basicCase, + basicCaseCommentPatch, + basicCasePost, + casesStatus, + caseUserActions, + pushedCase, + respReporters, + serviceConnector, + tags, +} from '../mock'; +import { + CaseExternalServiceRequest, + CasePatchRequest, + CasePostRequest, + CommentRequest, + ServiceConnectorCaseParams, + ServiceConnectorCaseResponse, + User, +} from '../../../../../../../plugins/case/common/api'; + +export const getCase = async ( + caseId: string, + includeComments: boolean = true, + signal: AbortSignal +): Promise<Case> => { + return Promise.resolve(basicCase); +}; + +export const getCasesStatus = async (signal: AbortSignal): Promise<CasesStatus> => + Promise.resolve(casesStatus); + +export const getTags = async (signal: AbortSignal): Promise<string[]> => Promise.resolve(tags); + +export const getReporters = async (signal: AbortSignal): Promise<User[]> => + Promise.resolve(respReporters); + +export const getCaseUserActions = async ( + caseId: string, + signal: AbortSignal +): Promise<CaseUserActions[]> => Promise.resolve(caseUserActions); + +export const getCases = async ({ + filterOptions = { + search: '', + reporters: [], + status: 'open', + tags: [], + }, + queryParams = { + page: 1, + perPage: 5, + sortField: SortFieldCase.createdAt, + sortOrder: 'desc', + }, + signal, +}: FetchCasesProps): Promise<AllCases> => Promise.resolve(allCases); + +export const postCase = async (newCase: CasePostRequest, signal: AbortSignal): Promise<Case> => + Promise.resolve(basicCasePost); + +export const patchCase = async ( + caseId: string, + updatedCase: Pick<CasePatchRequest, 'description' | 'status' | 'tags' | 'title'>, + version: string, + signal: AbortSignal +): Promise<Case[]> => Promise.resolve([basicCase]); + +export const patchCasesStatus = async ( + cases: BulkUpdateStatus[], + signal: AbortSignal +): Promise<Case[]> => Promise.resolve(allCases.cases); + +export const postComment = async ( + newComment: CommentRequest, + caseId: string, + signal: AbortSignal +): Promise<Case> => Promise.resolve(basicCase); + +export const patchComment = async ( + caseId: string, + commentId: string, + commentUpdate: string, + version: string, + signal: AbortSignal +): Promise<Case> => Promise.resolve(basicCaseCommentPatch); + +export const deleteCases = async (caseIds: string[], signal: AbortSignal): Promise<boolean> => + Promise.resolve(true); + +export const pushCase = async ( + caseId: string, + push: CaseExternalServiceRequest, + signal: AbortSignal +): Promise<Case> => Promise.resolve(pushedCase); + +export const pushToService = async ( + connectorId: string, + casePushParams: ServiceConnectorCaseParams, + signal: AbortSignal +): Promise<ServiceConnectorCaseResponse> => Promise.resolve(serviceConnector); + +export const getActionLicense = async (signal: AbortSignal): Promise<ActionLicense[]> => + Promise.resolve(actionLicenses); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/api.test.tsx new file mode 100644 index 0000000000000..4f5655cc9f221 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.test.tsx @@ -0,0 +1,463 @@ +/* + * 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 { KibanaServices } from '../../lib/kibana'; +import { + deleteCases, + getActionLicense, + getCase, + getCases, + getCasesStatus, + getCaseUserActions, + getReporters, + getTags, + patchCase, + patchCasesStatus, + patchComment, + postCase, + postComment, + pushCase, + pushToService, +} from './api'; +import { + actionLicenses, + allCases, + basicCase, + allCasesSnake, + basicCaseSnake, + actionTypeExecutorResult, + pushedCaseSnake, + casesStatus, + casesSnake, + cases, + caseUserActions, + pushedCase, + pushSnake, + reporters, + respReporters, + serviceConnector, + casePushParams, + tags, + caseUserActionsSnake, + casesStatusSnake, +} from './mock'; +import { CASES_URL } from './constants'; +import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; +import * as i18n from './translations'; + +const abortCtrl = new AbortController(); +const mockKibanaServices = KibanaServices.get as jest.Mock; +jest.mock('../../lib/kibana'); + +const fetchMock = jest.fn(); +mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } }); + +describe('Case Configuration API', () => { + describe('deleteCases', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(''); + }); + const data = ['1', '2']; + + test('check url, method, signal', async () => { + await deleteCases(data, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}`, { + method: 'DELETE', + query: { ids: JSON.stringify(data) }, + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await deleteCases(data, abortCtrl.signal); + expect(resp).toEqual(''); + }); + }); + describe('getActionLicense', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(actionLicenses); + }); + test('check url, method, signal', async () => { + await getActionLicense(abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`/api/action/types`, { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await getActionLicense(abortCtrl.signal); + expect(resp).toEqual(actionLicenses); + }); + }); + describe('getCase', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(basicCaseSnake); + }); + const data = basicCase.id; + + test('check url, method, signal', async () => { + await getCase(data, true, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}`, { + method: 'GET', + query: { includeComments: true }, + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await getCase(data, true, abortCtrl.signal); + expect(resp).toEqual(basicCase); + }); + }); + describe('getCases', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(allCasesSnake); + }); + test('check url, method, signal', async () => { + await getCases({ + filterOptions: DEFAULT_FILTER_OPTIONS, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { + method: 'GET', + query: { + ...DEFAULT_QUERY_PARAMS, + reporters: [], + tags: [], + status: 'open', + }, + signal: abortCtrl.signal, + }); + }); + test('correctly applies filters', async () => { + await getCases({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + reporters: [...respReporters, { username: null, full_name: null, email: null }], + tags, + status: '', + search: 'hello', + }, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { + method: 'GET', + query: { + ...DEFAULT_QUERY_PARAMS, + reporters, + tags, + search: 'hello', + }, + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await getCases({ + filterOptions: DEFAULT_FILTER_OPTIONS, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + expect(resp).toEqual({ ...allCases }); + }); + }); + describe('getCasesStatus', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(casesStatusSnake); + }); + test('check url, method, signal', async () => { + await getCasesStatus(abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/status`, { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await getCasesStatus(abortCtrl.signal); + expect(resp).toEqual(casesStatus); + }); + }); + describe('getCaseUserActions', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(caseUserActionsSnake); + }); + + test('check url, method, signal', async () => { + await getCaseUserActions(basicCase.id, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/user_actions`, { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await getCaseUserActions(basicCase.id, abortCtrl.signal); + expect(resp).toEqual(caseUserActions); + }); + }); + describe('getReporters', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(respReporters); + }); + + test('check url, method, signal', async () => { + await getReporters(abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/reporters`, { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await getReporters(abortCtrl.signal); + expect(resp).toEqual(respReporters); + }); + }); + describe('getTags', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(tags); + }); + + test('check url, method, signal', async () => { + await getTags(abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/tags`, { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await getTags(abortCtrl.signal); + expect(resp).toEqual(tags); + }); + }); + describe('patchCase', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue([basicCaseSnake]); + }); + const data = { description: 'updated description' }; + test('check url, method, signal', async () => { + await patchCase(basicCase.id, data, basicCase.version, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}`, { + method: 'PATCH', + body: JSON.stringify({ + cases: [{ ...data, id: basicCase.id, version: basicCase.version }], + }), + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await patchCase( + basicCase.id, + { description: 'updated description' }, + basicCase.version, + abortCtrl.signal + ); + expect(resp).toEqual({ ...[basicCase] }); + }); + }); + describe('patchCasesStatus', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(casesSnake); + }); + const data = [ + { + status: 'closed', + id: basicCase.id, + version: basicCase.version, + }, + ]; + + test('check url, method, signal', async () => { + await patchCasesStatus(data, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}`, { + method: 'PATCH', + body: JSON.stringify({ cases: data }), + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await patchCasesStatus(data, abortCtrl.signal); + expect(resp).toEqual({ ...cases }); + }); + }); + describe('patchComment', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(basicCaseSnake); + }); + + test('check url, method, signal', async () => { + await patchComment( + basicCase.id, + basicCase.comments[0].id, + 'updated comment', + basicCase.comments[0].version, + abortCtrl.signal + ); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/comments`, { + method: 'PATCH', + body: JSON.stringify({ + comment: 'updated comment', + id: basicCase.comments[0].id, + version: basicCase.comments[0].version, + }), + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await patchComment( + basicCase.id, + basicCase.comments[0].id, + 'updated comment', + basicCase.comments[0].version, + abortCtrl.signal + ); + expect(resp).toEqual(basicCase); + }); + }); + describe('postCase', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(basicCaseSnake); + }); + const data = { + description: 'description', + tags: ['tag'], + title: 'title', + }; + + test('check url, method, signal', async () => { + await postCase(data, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}`, { + method: 'POST', + body: JSON.stringify(data), + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await postCase(data, abortCtrl.signal); + expect(resp).toEqual(basicCase); + }); + }); + describe('postComment', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(basicCaseSnake); + }); + const data = { + comment: 'comment', + }; + + test('check url, method, signal', async () => { + await postComment(data, basicCase.id, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/comments`, { + method: 'POST', + body: JSON.stringify(data), + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await postComment(data, basicCase.id, abortCtrl.signal); + expect(resp).toEqual(basicCase); + }); + }); + describe('pushCase', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(pushedCaseSnake); + }); + + test('check url, method, signal', async () => { + await pushCase(basicCase.id, pushSnake, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/_push`, { + method: 'POST', + body: JSON.stringify(pushSnake), + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await pushCase(basicCase.id, pushSnake, abortCtrl.signal); + expect(resp).toEqual(pushedCase); + }); + }); + describe('pushToService', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(actionTypeExecutorResult); + }); + const connectorId = 'connectorId'; + test('check url, method, signal', async () => { + await pushToService(connectorId, casePushParams, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`/api/action/${connectorId}/_execute`, { + method: 'POST', + body: JSON.stringify({ params: casePushParams }), + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await pushToService(connectorId, casePushParams, abortCtrl.signal); + expect(resp).toEqual(serviceConnector); + }); + + test('unhappy path - serviceMessage', async () => { + const theError = 'the error'; + fetchMock.mockResolvedValue({ + ...actionTypeExecutorResult, + status: 'error', + serviceMessage: theError, + message: 'not it', + }); + await expect( + pushToService(connectorId, casePushParams, abortCtrl.signal) + ).rejects.toMatchObject({ message: theError }); + }); + + test('unhappy path - message', async () => { + const theError = 'the error'; + fetchMock.mockResolvedValue({ + ...actionTypeExecutorResult, + status: 'error', + message: theError, + }); + await expect( + pushToService(connectorId, casePushParams, abortCtrl.signal) + ).rejects.toMatchObject({ message: theError }); + }); + + test('unhappy path - no message', async () => { + const theError = i18n.ERROR_PUSH_TO_SERVICE; + fetchMock.mockResolvedValue({ + ...actionTypeExecutorResult, + status: 'error', + }); + await expect( + pushToService(connectorId, casePushParams, abortCtrl.signal) + ).rejects.toMatchObject({ message: theError }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts index 69e1602b3d981..12b4c80a2dd89 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -204,13 +204,13 @@ export const patchComment = async ( return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response)); }; -export const deleteCases = async (caseIds: string[], signal: AbortSignal): Promise<boolean> => { +export const deleteCases = async (caseIds: string[], signal: AbortSignal): Promise<string> => { const response = await KibanaServices.get().http.fetch<string>(CASES_URL, { method: 'DELETE', query: { ids: JSON.stringify(caseIds) }, signal, }); - return response === 'true' ? true : false; + return response; }; export const pushCase = async ( diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/__mocks__/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/configure/__mocks__/api.ts new file mode 100644 index 0000000000000..03f7d241e5dff --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/__mocks__/api.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 { + CasesConfigurePatch, + CasesConfigureRequest, + Connector, +} from '../../../../../../../../plugins/case/common/api'; + +import { ApiProps } from '../../types'; +import { CaseConfigure } from '../types'; +import { connectorsMock, caseConfigurationCamelCaseResponseMock } from '../mock'; + +export const fetchConnectors = async ({ signal }: ApiProps): Promise<Connector[]> => + Promise.resolve(connectorsMock); + +export const getCaseConfigure = async ({ signal }: ApiProps): Promise<CaseConfigure> => + Promise.resolve(caseConfigurationCamelCaseResponseMock); + +export const postCaseConfigure = async ( + caseConfiguration: CasesConfigureRequest, + signal: AbortSignal +): Promise<CaseConfigure> => Promise.resolve(caseConfigurationCamelCaseResponseMock); + +export const patchCaseConfigure = async ( + caseConfiguration: CasesConfigurePatch, + signal: AbortSignal +): Promise<CaseConfigure> => Promise.resolve(caseConfigurationCamelCaseResponseMock); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/api.test.ts b/x-pack/legacy/plugins/siem/public/containers/case/configure/api.test.ts new file mode 100644 index 0000000000000..ef0e51fb1c24d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/api.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaServices } from '../../../lib/kibana'; +import { fetchConnectors, getCaseConfigure, postCaseConfigure, patchCaseConfigure } from './api'; +import { + connectorsMock, + caseConfigurationMock, + caseConfigurationResposeMock, + caseConfigurationCamelCaseResponseMock, +} from './mock'; + +const abortCtrl = new AbortController(); +const mockKibanaServices = KibanaServices.get as jest.Mock; +jest.mock('../../../lib/kibana'); + +const fetchMock = jest.fn(); +mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } }); + +describe('Case Configuration API', () => { + describe('fetch connectors', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(connectorsMock); + }); + + test('check url, method, signal', async () => { + await fetchConnectors({ signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure/connectors/_find', { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await fetchConnectors({ signal: abortCtrl.signal }); + expect(resp).toEqual(connectorsMock); + }); + }); + + describe('fetch configuration', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(caseConfigurationResposeMock); + }); + + test('check url, method, signal', async () => { + await getCaseConfigure({ signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure', { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await getCaseConfigure({ signal: abortCtrl.signal }); + expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); + }); + + test('return null on empty response', async () => { + fetchMock.mockResolvedValue({}); + const resp = await getCaseConfigure({ signal: abortCtrl.signal }); + expect(resp).toBe(null); + }); + }); + + describe('create configuration', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(caseConfigurationResposeMock); + }); + + test('check url, body, method, signal', async () => { + await postCaseConfigure(caseConfigurationMock, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure', { + body: + '{"connector_id":"123","connector_name":"My Connector","closure_type":"close-by-user"}', + method: 'POST', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await postCaseConfigure(caseConfigurationMock, abortCtrl.signal); + expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); + }); + }); + + describe('update configuration', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(caseConfigurationResposeMock); + }); + + test('check url, body, method, signal', async () => { + await patchCaseConfigure({ connector_id: '456', version: 'WzHJ12' }, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure', { + body: '{"connector_id":"456","version":"WzHJ12"}', + method: 'PATCH', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await patchCaseConfigure( + { connector_id: '456', version: 'WzHJ12' }, + abortCtrl.signal + ); + expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/mock.ts b/x-pack/legacy/plugins/siem/public/containers/case/configure/mock.ts new file mode 100644 index 0000000000000..d2491b39fdf56 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/mock.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + Connector, + CasesConfigureResponse, + CasesConfigureRequest, +} from '../../../../../../../plugins/case/common/api'; +import { CaseConfigure } from './types'; + +export const connectorsMock: Connector[] = [ + { + id: '123', + actionTypeId: '.servicenow', + name: 'My Connector', + config: { + apiUrl: 'https://instance1.service-now.com', + casesConfiguration: { + mapping: [ + { + source: 'title', + target: 'short_description', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'append', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, + ], + }, + }, + isPreconfigured: true, + }, + { + id: '456', + actionTypeId: '.servicenow', + name: 'My Connector 2', + config: { + apiUrl: 'https://instance2.service-now.com', + casesConfiguration: { + mapping: [ + { + source: 'title', + target: 'short_description', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, + ], + }, + }, + isPreconfigured: true, + }, +]; + +export const caseConfigurationResposeMock: CasesConfigureResponse = { + created_at: '2020-04-06T13:03:18.657Z', + created_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, + connector_id: '123', + connector_name: 'My Connector', + closure_type: 'close-by-user', + updated_at: '2020-04-06T14:03:18.657Z', + updated_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, + version: 'WzHJ12', +}; + +export const caseConfigurationMock: CasesConfigureRequest = { + connector_id: '123', + connector_name: 'My Connector', + closure_type: 'close-by-user', +}; + +export const caseConfigurationCamelCaseResponseMock: CaseConfigure = { + createdAt: '2020-04-06T13:03:18.657Z', + createdBy: { username: 'elastic', fullName: 'Elastic', email: 'elastic@elastic.co' }, + connectorId: '123', + connectorName: 'My Connector', + closureType: 'close-by-user', + updatedAt: '2020-04-06T14:03:18.657Z', + updatedBy: { username: 'elastic', fullName: 'Elastic', email: 'elastic@elastic.co' }, + version: 'WzHJ12', +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.test.tsx new file mode 100644 index 0000000000000..3ee16e19eaf9f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.test.tsx @@ -0,0 +1,299 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useCaseConfigure, ReturnUseCaseConfigure, PersistCaseConfigure } from './use_configure'; +import { caseConfigurationCamelCaseResponseMock } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +const configuration: PersistCaseConfigure = { + connectorId: '456', + connectorName: 'My Connector 2', + closureType: 'close-by-pushing', +}; + +describe('useConfigure', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + const args = { + setConnector: jest.fn(), + setClosureType: jest.fn(), + setCurrentConfiguration: jest.fn(), + }; + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure(args) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + loading: true, + persistLoading: false, + refetchCaseConfigure: result.current.refetchCaseConfigure, + persistCaseConfigure: result.current.persistCaseConfigure, + }); + }); + }); + + test('fetch case configuration', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure(args) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + loading: false, + persistLoading: false, + refetchCaseConfigure: result.current.refetchCaseConfigure, + persistCaseConfigure: result.current.persistCaseConfigure, + }); + }); + }); + + test('fetch case configuration - setConnector', async () => { + await act(async () => { + const { waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure(args) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(args.setConnector).toHaveBeenCalledWith('123', 'My Connector'); + }); + }); + + test('fetch case configuration - setClosureType', async () => { + await act(async () => { + const { waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure(args) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(args.setClosureType).toHaveBeenCalledWith('close-by-user'); + }); + }); + + test('fetch case configuration - setCurrentConfiguration', async () => { + await act(async () => { + const { waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure(args) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(args.setCurrentConfiguration).toHaveBeenCalledWith({ + connectorId: '123', + closureType: 'close-by-user', + }); + }); + }); + + test('fetch case configuration - only setConnector', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure({ setConnector: jest.fn() }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + loading: false, + persistLoading: false, + refetchCaseConfigure: result.current.refetchCaseConfigure, + persistCaseConfigure: result.current.persistCaseConfigure, + }); + }); + }); + + test('refetch case configuration', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure(args) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.refetchCaseConfigure(); + expect(spyOnGetCaseConfigure).toHaveBeenCalledTimes(2); + }); + }); + + test('set isLoading to true when fetching case configuration', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure(args) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.refetchCaseConfigure(); + + expect(result.current.loading).toBe(true); + }); + }); + + test('persist case configuration', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure(args) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + result.current.persistCaseConfigure(configuration); + + expect(result.current).toEqual({ + loading: false, + persistLoading: true, + refetchCaseConfigure: result.current.refetchCaseConfigure, + persistCaseConfigure: result.current.persistCaseConfigure, + }); + }); + }); + + test('save case configuration - postCaseConfigure', async () => { + // When there is no version, a configuration is created. Otherwise is updated. + const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); + spyOnGetCaseConfigure.mockImplementation(() => + Promise.resolve({ + ...caseConfigurationCamelCaseResponseMock, + version: '', + }) + ); + + const spyOnPostCaseConfigure = jest.spyOn(api, 'postCaseConfigure'); + spyOnPostCaseConfigure.mockImplementation(() => + Promise.resolve({ + ...caseConfigurationCamelCaseResponseMock, + ...configuration, + }) + ); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure(args) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + result.current.persistCaseConfigure(configuration); + + await waitForNextUpdate(); + + expect(args.setConnector).toHaveBeenNthCalledWith(2, '456'); + expect(args.setClosureType).toHaveBeenNthCalledWith(2, 'close-by-pushing'); + expect(args.setCurrentConfiguration).toHaveBeenNthCalledWith(2, { + connectorId: '456', + closureType: 'close-by-pushing', + }); + }); + }); + + test('save case configuration - patchCaseConfigure', async () => { + const spyOnPatchCaseConfigure = jest.spyOn(api, 'patchCaseConfigure'); + spyOnPatchCaseConfigure.mockImplementation(() => + Promise.resolve({ + ...caseConfigurationCamelCaseResponseMock, + ...configuration, + }) + ); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure(args) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + result.current.persistCaseConfigure(configuration); + + await waitForNextUpdate(); + + expect(args.setConnector).toHaveBeenNthCalledWith(2, '456'); + expect(args.setClosureType).toHaveBeenNthCalledWith(2, 'close-by-pushing'); + expect(args.setCurrentConfiguration).toHaveBeenNthCalledWith(2, { + connectorId: '456', + closureType: 'close-by-pushing', + }); + }); + }); + + test('save case configuration - only setConnector', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure({ setConnector: jest.fn() }) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + result.current.persistCaseConfigure(configuration); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + persistLoading: false, + refetchCaseConfigure: result.current.refetchCaseConfigure, + persistCaseConfigure: result.current.persistCaseConfigure, + }); + }); + }); + + test('unhappy path - fetch case configuration', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); + spyOnGetCaseConfigure.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure(args) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + persistLoading: false, + refetchCaseConfigure: result.current.refetchCaseConfigure, + persistCaseConfigure: result.current.persistCaseConfigure, + }); + }); + }); + + test('unhappy path - persist case configuration', async () => { + const spyOnPostCaseConfigure = jest.spyOn(api, 'postCaseConfigure'); + spyOnPostCaseConfigure.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure(args) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + result.current.persistCaseConfigure(configuration); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + persistLoading: false, + refetchCaseConfigure: result.current.refetchCaseConfigure, + persistCaseConfigure: result.current.persistCaseConfigure, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx index 19d80bba1e0f8..1c03a09a8c2ea 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx @@ -12,7 +12,7 @@ import * as i18n from './translations'; import { ClosureType } from './types'; import { CurrentConfiguration } from '../../../pages/case/components/configure_cases/reducer'; -interface PersistCaseConfigure { +export interface PersistCaseConfigure { connectorId: string; connectorName: string; closureType: ClosureType; @@ -55,7 +55,6 @@ export const useCaseConfigure = ({ setLoading(true); const res = await getCaseConfigure({ signal: abortCtrl.signal }); if (!didCancel) { - setLoading(false); if (res != null) { setConnector(res.connectorId, res.connectorName); if (setClosureType != null) { @@ -73,6 +72,7 @@ export const useCaseConfigure = ({ } } } + setLoading(false); } } catch (error) { if (!didCancel) { @@ -117,7 +117,6 @@ export const useCaseConfigure = ({ abortCtrl.signal ); if (!didCancel) { - setPersistLoading(false); setConnector(res.connectorId); if (setClosureType) { setClosureType(res.closureType); @@ -131,6 +130,7 @@ export const useCaseConfigure = ({ } displaySuccessToast(i18n.SUCCESS_CONFIGURE, dispatchToaster); + setPersistLoading(false); } } catch (error) { if (!didCancel) { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_connectors.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_connectors.test.tsx new file mode 100644 index 0000000000000..0d6b6acfd9065 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_connectors.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useConnectors, ReturnConnectors } from './use_connectors'; +import { connectorsMock } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useConnectors', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnConnectors>(() => + useConnectors() + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + loading: true, + connectors: [], + refetchConnectors: result.current.refetchConnectors, + }); + }); + }); + + test('fetch connectors', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnConnectors>(() => + useConnectors() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + loading: false, + connectors: connectorsMock, + refetchConnectors: result.current.refetchConnectors, + }); + }); + }); + + test('refetch connectors', async () => { + const spyOnfetchConnectors = jest.spyOn(api, 'fetchConnectors'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnConnectors>(() => + useConnectors() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.refetchConnectors(); + expect(spyOnfetchConnectors).toHaveBeenCalledTimes(2); + }); + }); + + test('set isLoading to true when refetching connectors', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnConnectors>(() => + useConnectors() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.refetchConnectors(); + + expect(result.current.loading).toBe(true); + }); + }); + + test('unhappy path', async () => { + const spyOnfetchConnectors = jest.spyOn(api, 'fetchConnectors'); + spyOnfetchConnectors.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnConnectors>(() => + useConnectors() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + connectors: [], + refetchConnectors: result.current.refetchConnectors, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/mock.ts b/x-pack/legacy/plugins/siem/public/containers/case/mock.ts new file mode 100644 index 0000000000000..0bda75e5bc9e0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/mock.ts @@ -0,0 +1,307 @@ +/* + * 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 { ActionLicense, AllCases, Case, CasesStatus, CaseUserActions, Comment } from './types'; + +import { + CommentResponse, + ServiceConnectorCaseResponse, + Status, + UserAction, + UserActionField, + CaseResponse, + CasesStatusResponse, + CaseUserActionsResponse, + CasesResponse, + CasesFindResponse, +} from '../../../../../../plugins/case/common/api/cases'; +import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; + +export const basicCaseId = 'basic-case-id'; +const basicCommentId = 'basic-comment-id'; +const basicCreatedAt = '2020-02-19T23:06:33.798Z'; +const basicUpdatedAt = '2020-02-20T15:02:57.995Z'; +const laterTime = '2020-02-28T15:02:57.995Z'; +export const elasticUser = { + fullName: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', +}; + +export const tags: string[] = ['coke', 'pepsi']; + +export const basicComment: Comment = { + comment: 'Solve this fast!', + id: basicCommentId, + createdAt: basicCreatedAt, + createdBy: elasticUser, + pushedAt: null, + pushedBy: null, + updatedAt: null, + updatedBy: null, + version: 'WzQ3LDFc', +}; + +export const basicCase: Case = { + closedAt: null, + closedBy: null, + id: basicCaseId, + comments: [basicComment], + createdAt: basicCreatedAt, + createdBy: elasticUser, + description: 'Security banana Issue', + externalService: null, + status: 'open', + tags, + title: 'Another horrible breach!!', + totalComment: 1, + updatedAt: basicUpdatedAt, + updatedBy: elasticUser, + version: 'WzQ3LDFd', +}; + +export const basicCasePost: Case = { + ...basicCase, + updatedAt: null, + updatedBy: null, +}; + +export const basicCommentPatch: Comment = { + ...basicComment, + updatedAt: basicUpdatedAt, + updatedBy: { + username: 'elastic', + }, +}; + +export const basicCaseCommentPatch = { + ...basicCase, + comments: [basicCommentPatch], +}; + +export const casesStatus: CasesStatus = { + countClosedCases: 130, + countOpenCases: 20, +}; + +const basicPush = { + connectorId: 'connector_id', + connectorName: 'connector name', + externalId: 'external_id', + externalTitle: 'external title', + externalUrl: 'basicPush.com', + pushedAt: basicUpdatedAt, + pushedBy: elasticUser, +}; + +export const pushedCase: Case = { + ...basicCase, + externalService: basicPush, +}; + +export const serviceConnector: ServiceConnectorCaseResponse = { + number: '123', + incidentId: '444', + pushedDate: basicUpdatedAt, + url: 'connector.com', + comments: [ + { + commentId: basicCommentId, + pushedDate: basicUpdatedAt, + }, + ], +}; + +const basicAction = { + actionAt: basicCreatedAt, + actionBy: elasticUser, + oldValue: null, + newValue: 'what a cool value', + caseId: basicCaseId, + commentId: null, +}; + +export const casePushParams = { + actionBy: elasticUser, + caseId: basicCaseId, + createdAt: basicCreatedAt, + createdBy: elasticUser, + incidentId: null, + title: 'what a cool value', + commentId: null, + updatedAt: basicCreatedAt, + updatedBy: elasticUser, + description: 'nice', +}; +export const actionTypeExecutorResult = { + actionId: 'string', + status: 'ok', + data: serviceConnector, +}; + +export const cases: Case[] = [ + basicCase, + { ...pushedCase, id: '1', totalComment: 0, comments: [] }, + { ...pushedCase, updatedAt: laterTime, id: '2', totalComment: 0, comments: [] }, + { ...basicCase, id: '3', totalComment: 0, comments: [] }, + { ...basicCase, id: '4', totalComment: 0, comments: [] }, +]; + +export const allCases: AllCases = { + cases, + page: 1, + perPage: 5, + total: 10, + ...casesStatus, +}; +export const actionLicenses: ActionLicense[] = [ + { + id: '.servicenow', + name: 'ServiceNow', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, +]; + +// Snake case for mock api responses +export const elasticUserSnake = { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', +}; +export const basicCommentSnake: CommentResponse = { + ...basicComment, + comment: 'Solve this fast!', + id: basicCommentId, + created_at: basicCreatedAt, + created_by: elasticUserSnake, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, +}; + +export const basicCaseSnake: CaseResponse = { + ...basicCase, + status: 'open' as Status, + closed_at: null, + closed_by: null, + comments: [basicCommentSnake], + created_at: basicCreatedAt, + created_by: elasticUserSnake, + external_service: null, + updated_at: basicUpdatedAt, + updated_by: elasticUserSnake, +}; + +export const casesStatusSnake: CasesStatusResponse = { + count_closed_cases: 130, + count_open_cases: 20, +}; + +export const pushSnake = { + connector_id: 'connector_id', + connector_name: 'connector name', + external_id: 'external_id', + external_title: 'external title', + external_url: 'basicPush.com', +}; +const basicPushSnake = { + ...pushSnake, + pushed_at: basicUpdatedAt, + pushed_by: elasticUserSnake, +}; +export const pushedCaseSnake = { + ...basicCaseSnake, + external_service: basicPushSnake, +}; + +export const reporters: string[] = ['alexis', 'kim', 'maria', 'steph']; +export const respReporters = [ + { username: 'alexis', full_name: null, email: null }, + { username: 'kim', full_name: null, email: null }, + { username: 'maria', full_name: null, email: null }, + { username: 'steph', full_name: null, email: null }, +]; +export const casesSnake: CasesResponse = [ + basicCaseSnake, + { ...pushedCaseSnake, id: '1', totalComment: 0, comments: [] }, + { ...pushedCaseSnake, updated_at: laterTime, id: '2', totalComment: 0, comments: [] }, + { ...basicCaseSnake, id: '3', totalComment: 0, comments: [] }, + { ...basicCaseSnake, id: '4', totalComment: 0, comments: [] }, +]; + +export const allCasesSnake: CasesFindResponse = { + cases: casesSnake, + page: 1, + per_page: 5, + total: 10, + ...casesStatusSnake, +}; + +const basicActionSnake = { + action_at: basicCreatedAt, + action_by: elasticUserSnake, + old_value: null, + new_value: 'what a cool value', + case_id: basicCaseId, + comment_id: null, +}; +export const getUserActionSnake = (af: UserActionField, a: UserAction) => ({ + ...basicActionSnake, + action_id: `${af[0]}-${a}`, + action_field: af, + action: a, + comment_id: af[0] === 'comment' ? basicCommentId : null, + new_value: + a === 'push-to-service' && af[0] === 'pushed' + ? JSON.stringify(basicPushSnake) + : basicAction.newValue, +}); + +export const caseUserActionsSnake: CaseUserActionsResponse = [ + getUserActionSnake(['description'], 'create'), + getUserActionSnake(['comment'], 'create'), + getUserActionSnake(['description'], 'update'), +]; + +// user actions + +export const getUserAction = (af: UserActionField, a: UserAction) => ({ + ...basicAction, + actionId: `${af[0]}-${a}`, + actionField: af, + action: a, + commentId: af[0] === 'comment' ? basicCommentId : null, + newValue: + a === 'push-to-service' && af[0] === 'pushed' + ? JSON.stringify(basicPushSnake) + : basicAction.newValue, +}); + +export const caseUserActions: CaseUserActions[] = [ + getUserAction(['description'], 'create'), + getUserAction(['comment'], 'create'), + getUserAction(['description'], 'update'), +]; + +// components tests +export const useGetCasesMockState: UseGetCasesState = { + data: allCases, + loading: [], + selectedCases: [], + isError: false, + queryParams: DEFAULT_QUERY_PARAMS, + filterOptions: DEFAULT_FILTER_OPTIONS, +}; + +export const basicCaseClosed: Case = { + ...basicCase, + closedAt: '2020-02-25T23:06:33.798Z', + closedBy: elasticUser, + status: 'closed', +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index d2a58e9eeeff4..e552f22b55fa4 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -31,7 +31,7 @@ export interface CaseUserActions { export interface CaseExternalService { pushedAt: string; - pushedBy: string; + pushedBy: ElasticUser; connectorId: string; connectorName: string; externalId: string; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.test.tsx new file mode 100644 index 0000000000000..329fda10424a8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.test.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useUpdateCases, UseUpdateCases } from './use_bulk_update_case'; +import { basicCase } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useUpdateCases', () => { + const abortCtrl = new AbortController(); + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseUpdateCases>(() => + useUpdateCases() + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + isError: false, + isUpdated: false, + updateBulkStatus: result.current.updateBulkStatus, + dispatchResetIsUpdated: result.current.dispatchResetIsUpdated, + }); + }); + }); + + it('calls patchCase with correct arguments', async () => { + const spyOnPatchCases = jest.spyOn(api, 'patchCasesStatus'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseUpdateCases>(() => + useUpdateCases() + ); + await waitForNextUpdate(); + + result.current.updateBulkStatus([basicCase], 'closed'); + await waitForNextUpdate(); + expect(spyOnPatchCases).toBeCalledWith( + [ + { + status: 'closed', + id: basicCase.id, + version: basicCase.version, + }, + ], + abortCtrl.signal + ); + }); + }); + + it('patch cases', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseUpdateCases>(() => + useUpdateCases() + ); + await waitForNextUpdate(); + result.current.updateBulkStatus([basicCase], 'closed'); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isUpdated: true, + isLoading: false, + isError: false, + updateBulkStatus: result.current.updateBulkStatus, + dispatchResetIsUpdated: result.current.dispatchResetIsUpdated, + }); + }); + }); + + it('set isLoading to true when posting case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseUpdateCases>(() => + useUpdateCases() + ); + await waitForNextUpdate(); + result.current.updateBulkStatus([basicCase], 'closed'); + + expect(result.current.isLoading).toBe(true); + }); + }); + + it('dispatchResetIsUpdated resets is updated', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseUpdateCases>(() => + useUpdateCases() + ); + + await waitForNextUpdate(); + result.current.updateBulkStatus([basicCase], 'closed'); + await waitForNextUpdate(); + expect(result.current.isUpdated).toBeTruthy(); + result.current.dispatchResetIsUpdated(); + expect(result.current.isUpdated).toBeFalsy(); + }); + }); + + it('unhappy path', async () => { + const spyOnPatchCases = jest.spyOn(api, 'patchCasesStatus'); + spyOnPatchCases.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseUpdateCases>(() => + useUpdateCases() + ); + await waitForNextUpdate(); + result.current.updateBulkStatus([basicCase], 'closed'); + + expect(result.current).toEqual({ + isUpdated: false, + isLoading: false, + isError: true, + updateBulkStatus: result.current.updateBulkStatus, + dispatchResetIsUpdated: result.current.dispatchResetIsUpdated, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx index 7d040c49f1971..d0cc4d99f8f9f 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx @@ -51,12 +51,12 @@ const dataFetchReducer = (state: UpdateState, action: Action): UpdateState => { return state; } }; -interface UseUpdateCase extends UpdateState { +export interface UseUpdateCases extends UpdateState { updateBulkStatus: (cases: Case[], status: string) => void; dispatchResetIsUpdated: () => void; } -export const useUpdateCases = (): UseUpdateCase => { +export const useUpdateCases = (): UseUpdateCases => { const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: false, isError: false, diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_delete_cases.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_delete_cases.test.tsx new file mode 100644 index 0000000000000..45ba392f3b5b4 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_delete_cases.test.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useDeleteCases, UseDeleteCase } from './use_delete_cases'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useDeleteCases', () => { + const abortCtrl = new AbortController(); + const deleteObj = [{ id: '1' }, { id: '2' }, { id: '3' }]; + const deleteArr = ['1', '2', '3']; + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseDeleteCase>(() => + useDeleteCases() + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isDisplayConfirmDeleteModal: false, + isLoading: false, + isError: false, + isDeleted: false, + dispatchResetIsDeleted: result.current.dispatchResetIsDeleted, + handleOnDeleteConfirm: result.current.handleOnDeleteConfirm, + handleToggleModal: result.current.handleToggleModal, + }); + }); + }); + + it('calls deleteCases with correct arguments', async () => { + const spyOnDeleteCases = jest.spyOn(api, 'deleteCases'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseDeleteCase>(() => + useDeleteCases() + ); + await waitForNextUpdate(); + + result.current.handleOnDeleteConfirm(deleteObj); + await waitForNextUpdate(); + expect(spyOnDeleteCases).toBeCalledWith(deleteArr, abortCtrl.signal); + }); + }); + + it('deletes cases', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseDeleteCase>(() => + useDeleteCases() + ); + await waitForNextUpdate(); + result.current.handleToggleModal(); + result.current.handleOnDeleteConfirm(deleteObj); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isDisplayConfirmDeleteModal: false, + isLoading: false, + isError: false, + isDeleted: true, + dispatchResetIsDeleted: result.current.dispatchResetIsDeleted, + handleOnDeleteConfirm: result.current.handleOnDeleteConfirm, + handleToggleModal: result.current.handleToggleModal, + }); + }); + }); + + it('resets is deleting', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseDeleteCase>(() => + useDeleteCases() + ); + await waitForNextUpdate(); + result.current.handleToggleModal(); + result.current.handleOnDeleteConfirm(deleteObj); + await waitForNextUpdate(); + expect(result.current.isDeleted).toBeTruthy(); + result.current.handleToggleModal(); + result.current.dispatchResetIsDeleted(); + expect(result.current.isDeleted).toBeFalsy(); + }); + }); + + it('set isLoading to true when deleting cases', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseDeleteCase>(() => + useDeleteCases() + ); + await waitForNextUpdate(); + result.current.handleToggleModal(); + result.current.handleOnDeleteConfirm(deleteObj); + expect(result.current.isLoading).toBe(true); + }); + }); + + it('unhappy path', async () => { + const spyOnDeleteCases = jest.spyOn(api, 'deleteCases'); + spyOnDeleteCases.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseDeleteCase>(() => + useDeleteCases() + ); + await waitForNextUpdate(); + result.current.handleToggleModal(); + result.current.handleOnDeleteConfirm(deleteObj); + + expect(result.current).toEqual({ + isDisplayConfirmDeleteModal: false, + isLoading: false, + isError: true, + isDeleted: false, + dispatchResetIsDeleted: result.current.dispatchResetIsDeleted, + handleOnDeleteConfirm: result.current.handleOnDeleteConfirm, + handleToggleModal: result.current.handleToggleModal, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_delete_cases.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_delete_cases.tsx index 07e3786758aeb..3c49be551c064 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_delete_cases.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_delete_cases.tsx @@ -59,9 +59,9 @@ const dataFetchReducer = (state: DeleteState, action: Action): DeleteState => { } }; -interface UseDeleteCase extends DeleteState { +export interface UseDeleteCase extends DeleteState { dispatchResetIsDeleted: () => void; - handleOnDeleteConfirm: (caseIds: DeleteCase[]) => void; + handleOnDeleteConfirm: (cases: DeleteCase[]) => void; handleToggleModal: () => void; } @@ -117,8 +117,8 @@ export const useDeleteCases = (): UseDeleteCase => { }, [state.isDisplayConfirmDeleteModal]); const handleOnDeleteConfirm = useCallback( - caseIds => { - dispatchDeleteCases(caseIds); + (cases: DeleteCase[]) => { + dispatchDeleteCases(cases); dispatchToggleDeleteModal(); }, [state.isDisplayConfirmDeleteModal] diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.test.tsx new file mode 100644 index 0000000000000..23c9ff5e49586 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.test.tsx @@ -0,0 +1,89 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { initialData, useGetActionLicense, ActionLicenseState } from './use_get_action_license'; +import { actionLicenses } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useGetActionLicense', () => { + const abortCtrl = new AbortController(); + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ActionLicenseState>(() => + useGetActionLicense() + ); + await waitForNextUpdate(); + expect(result.current).toEqual(initialData); + }); + }); + + it('calls getActionLicense with correct arguments', async () => { + const spyOnGetActionLicense = jest.spyOn(api, 'getActionLicense'); + + await act(async () => { + const { waitForNextUpdate } = renderHook<string, ActionLicenseState>(() => + useGetActionLicense() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(spyOnGetActionLicense).toBeCalledWith(abortCtrl.signal); + }); + }); + + it('gets action license', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ActionLicenseState>(() => + useGetActionLicense() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + isError: false, + actionLicense: actionLicenses[0], + }); + }); + }); + + it('set isLoading to true when posting case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ActionLicenseState>(() => + useGetActionLicense() + ); + await waitForNextUpdate(); + expect(result.current.isLoading).toBe(true); + }); + }); + + it('unhappy path', async () => { + const spyOnGetActionLicense = jest.spyOn(api, 'getActionLicense'); + spyOnGetActionLicense.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ActionLicenseState>(() => + useGetActionLicense() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + actionLicense: null, + isLoading: false, + isError: true, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx index 12f92b2db039b..0d28a1b20c61f 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx @@ -11,13 +11,13 @@ import { getActionLicense } from './api'; import * as i18n from './translations'; import { ActionLicense } from './types'; -interface ActionLicenseState { +export interface ActionLicenseState { actionLicense: ActionLicense | null; isLoading: boolean; isError: boolean; } -const initialData: ActionLicenseState = { +export const initialData: ActionLicenseState = { actionLicense: null, isLoading: true, isError: false, diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.test.tsx new file mode 100644 index 0000000000000..10649da548d43 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.test.tsx @@ -0,0 +1,112 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { initialData, useGetCase, UseGetCase } from './use_get_case'; +import { basicCase } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useGetCase', () => { + const abortCtrl = new AbortController(); + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCase>(() => + useGetCase(basicCase.id) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + data: initialData, + isLoading: true, + isError: false, + fetchCase: result.current.fetchCase, + updateCase: result.current.updateCase, + }); + }); + }); + + it('calls getCase with correct arguments', async () => { + const spyOnGetCase = jest.spyOn(api, 'getCase'); + await act(async () => { + const { waitForNextUpdate } = renderHook<string, UseGetCase>(() => useGetCase(basicCase.id)); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(spyOnGetCase).toBeCalledWith(basicCase.id, true, abortCtrl.signal); + }); + }); + + it('fetch case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCase>(() => + useGetCase(basicCase.id) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + data: basicCase, + isLoading: false, + isError: false, + fetchCase: result.current.fetchCase, + updateCase: result.current.updateCase, + }); + }); + }); + + it('refetch case', async () => { + const spyOnGetCase = jest.spyOn(api, 'getCase'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCase>(() => + useGetCase(basicCase.id) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.fetchCase(); + expect(spyOnGetCase).toHaveBeenCalledTimes(2); + }); + }); + + it('set isLoading to true when refetching case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCase>(() => + useGetCase(basicCase.id) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.fetchCase(); + + expect(result.current.isLoading).toBe(true); + }); + }); + + it('unhappy path', async () => { + const spyOnGetCase = jest.spyOn(api, 'getCase'); + spyOnGetCase.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCase>(() => + useGetCase(basicCase.id) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + data: initialData, + isLoading: false, + isError: true, + fetchCase: result.current.fetchCase, + updateCase: result.current.updateCase, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index 835fb7153dc95..b2e3b6d0cacf6 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -53,7 +53,7 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { return state; } }; -const initialData: Case = { +export const initialData: Case = { id: '', closedAt: null, closedBy: null, @@ -73,7 +73,7 @@ const initialData: Case = { version: '', }; -interface UseGetCase extends CaseState { +export interface UseGetCase extends CaseState { fetchCase: () => void; updateCase: (newCase: Case) => void; } diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.test.tsx new file mode 100644 index 0000000000000..cdd40b84f8724 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { + initialData, + useGetCaseUserActions, + UseGetCaseUserActions, +} from './use_get_case_user_actions'; +import { basicCaseId, caseUserActions, elasticUser } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useGetCaseUserActions', () => { + const abortCtrl = new AbortController(); + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCaseUserActions>(() => + useGetCaseUserActions(basicCaseId) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + ...initialData, + fetchCaseUserActions: result.current.fetchCaseUserActions, + }); + }); + }); + + it('calls getCaseUserActions with correct arguments', async () => { + const spyOnPostCase = jest.spyOn(api, 'getCaseUserActions'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCaseUserActions>(() => + useGetCaseUserActions(basicCaseId) + ); + await waitForNextUpdate(); + + result.current.fetchCaseUserActions(basicCaseId); + await waitForNextUpdate(); + expect(spyOnPostCase).toBeCalledWith(basicCaseId, abortCtrl.signal); + }); + }); + + it('retuns proper state on getCaseUserActions', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCaseUserActions>(() => + useGetCaseUserActions(basicCaseId) + ); + await waitForNextUpdate(); + result.current.fetchCaseUserActions(basicCaseId); + await waitForNextUpdate(); + expect(result.current).toEqual({ + ...initialData, + caseUserActions: caseUserActions.slice(1), + fetchCaseUserActions: result.current.fetchCaseUserActions, + hasDataToPush: true, + isError: false, + isLoading: false, + participants: [elasticUser], + }); + }); + }); + + it('set isLoading to true when posting case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCaseUserActions>(() => + useGetCaseUserActions(basicCaseId) + ); + await waitForNextUpdate(); + result.current.fetchCaseUserActions(basicCaseId); + + expect(result.current.isLoading).toBe(true); + }); + }); + + it('unhappy path', async () => { + const spyOnPostCase = jest.spyOn(api, 'getCaseUserActions'); + spyOnPostCase.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCaseUserActions>(() => + useGetCaseUserActions(basicCaseId) + ); + await waitForNextUpdate(); + result.current.fetchCaseUserActions(basicCaseId); + + expect(result.current).toEqual({ + ...initialData, + isLoading: false, + isError: true, + fetchCaseUserActions: result.current.fetchCaseUserActions, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx index 4c278bc038134..6d9874a655e97 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx @@ -22,7 +22,7 @@ interface CaseUserActionsState { lastIndexPushToService: number; } -const initialData: CaseUserActionsState = { +export const initialData: CaseUserActionsState = { caseUserActions: [], firstIndexPushToService: -1, lastIndexPushToService: -1, @@ -32,7 +32,7 @@ const initialData: CaseUserActionsState = { participants: [], }; -interface UseGetCaseUserActions extends CaseUserActionsState { +export interface UseGetCaseUserActions extends CaseUserActionsState { fetchCaseUserActions: (caseId: string) => void; } @@ -80,6 +80,7 @@ export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => const participants = !isEmpty(response) ? uniqBy('actionBy.username', response).map(cau => cau.actionBy) : []; + const caseUserActions = !isEmpty(response) ? response.slice(1) : []; setCaseUserActionsState({ caseUserActions, diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.test.tsx new file mode 100644 index 0000000000000..4e274e074b036 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.test.tsx @@ -0,0 +1,202 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { + DEFAULT_FILTER_OPTIONS, + DEFAULT_QUERY_PARAMS, + initialData, + useGetCases, + UseGetCases, +} from './use_get_cases'; +import { UpdateKey } from './use_update_case'; +import { allCases, basicCase } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useGetCases', () => { + const abortCtrl = new AbortController(); + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + expect(result.current).toEqual({ + data: initialData, + dispatchUpdateCaseProperty: result.current.dispatchUpdateCaseProperty, + filterOptions: DEFAULT_FILTER_OPTIONS, + isError: false, + loading: [], + queryParams: DEFAULT_QUERY_PARAMS, + refetchCases: result.current.refetchCases, + selectedCases: [], + setFilters: result.current.setFilters, + setQueryParams: result.current.setQueryParams, + setSelectedCases: result.current.setSelectedCases, + }); + }); + }); + + it('calls getCases with correct arguments', async () => { + const spyOnGetCases = jest.spyOn(api, 'getCases'); + await act(async () => { + const { waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(spyOnGetCases).toBeCalledWith({ + filterOptions: DEFAULT_FILTER_OPTIONS, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + }); + }); + + it('fetch cases', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + data: allCases, + dispatchUpdateCaseProperty: result.current.dispatchUpdateCaseProperty, + filterOptions: DEFAULT_FILTER_OPTIONS, + isError: false, + loading: [], + queryParams: DEFAULT_QUERY_PARAMS, + refetchCases: result.current.refetchCases, + selectedCases: [], + setFilters: result.current.setFilters, + setQueryParams: result.current.setQueryParams, + setSelectedCases: result.current.setSelectedCases, + }); + }); + }); + it('dispatch update case property', async () => { + const spyOnPatchCase = jest.spyOn(api, 'patchCase'); + await act(async () => { + const updateCase = { + updateKey: 'description' as UpdateKey, + updateValue: 'description update', + caseId: basicCase.id, + refetchCasesStatus: jest.fn(), + version: '99999', + }; + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.dispatchUpdateCaseProperty(updateCase); + expect(result.current.loading).toEqual(['caseUpdate']); + expect(spyOnPatchCase).toBeCalledWith( + basicCase.id, + { [updateCase.updateKey]: updateCase.updateValue }, + updateCase.version, + abortCtrl.signal + ); + }); + }); + + it('refetch cases', async () => { + const spyOnGetCases = jest.spyOn(api, 'getCases'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.refetchCases(); + expect(spyOnGetCases).toHaveBeenCalledTimes(2); + }); + }); + + it('set isLoading to true when refetching case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.refetchCases(); + + expect(result.current.loading).toEqual(['cases']); + }); + }); + + it('unhappy path', async () => { + const spyOnGetCases = jest.spyOn(api, 'getCases'); + spyOnGetCases.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + data: initialData, + dispatchUpdateCaseProperty: result.current.dispatchUpdateCaseProperty, + filterOptions: DEFAULT_FILTER_OPTIONS, + isError: true, + loading: [], + queryParams: DEFAULT_QUERY_PARAMS, + refetchCases: result.current.refetchCases, + selectedCases: [], + setFilters: result.current.setFilters, + setQueryParams: result.current.setQueryParams, + setSelectedCases: result.current.setSelectedCases, + }); + }); + }); + it('set filters', async () => { + await act(async () => { + const spyOnGetCases = jest.spyOn(api, 'getCases'); + const newFilters = { + search: 'new', + tags: ['new'], + status: 'closed', + }; + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.setFilters(newFilters); + await waitForNextUpdate(); + expect(spyOnGetCases.mock.calls[1][0]).toEqual({ + filterOptions: { ...DEFAULT_FILTER_OPTIONS, ...newFilters }, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + }); + }); + it('set query params', async () => { + await act(async () => { + const spyOnGetCases = jest.spyOn(api, 'getCases'); + const newQueryParams = { + page: 2, + }; + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.setQueryParams(newQueryParams); + await waitForNextUpdate(); + expect(spyOnGetCases.mock.calls[1][0]).toEqual({ + filterOptions: DEFAULT_FILTER_OPTIONS, + queryParams: { ...DEFAULT_QUERY_PARAMS, ...newQueryParams }, + signal: abortCtrl.signal, + }); + }); + }); + it('set selected cases', async () => { + await act(async () => { + const selectedCases = [basicCase]; + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.setSelectedCases(selectedCases); + expect(result.current.selectedCases).toEqual(selectedCases); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx index 1cbce5af6304b..465b50dbdc1bc 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx @@ -105,7 +105,7 @@ export const DEFAULT_QUERY_PARAMS: QueryParams = { sortOrder: 'desc', }; -const initialData: AllCases = { +export const initialData: AllCases = { cases: [], countClosedCases: null, countOpenCases: null, @@ -113,7 +113,7 @@ const initialData: AllCases = { perPage: 0, total: 0, }; -interface UseGetCases extends UseGetCasesState { +export interface UseGetCases extends UseGetCasesState { dispatchUpdateCaseProperty: ({ updateKey, updateValue, @@ -121,7 +121,7 @@ interface UseGetCases extends UseGetCasesState { version, refetchCasesStatus, }: UpdateCase) => void; - refetchCases: (filters: FilterOptions, queryParams: QueryParams) => void; + refetchCases: () => void; setFilters: (filters: Partial<FilterOptions>) => void; setQueryParams: (queryParams: Partial<QueryParams>) => void; setSelectedCases: (mySelectedCases: Case[]) => void; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases_status.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases_status.test.tsx new file mode 100644 index 0000000000000..bfbcbd2525e3b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases_status.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useGetCasesStatus, UseGetCasesStatus } from './use_get_cases_status'; +import { casesStatus } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useGetCasesStatus', () => { + const abortCtrl = new AbortController(); + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCasesStatus>(() => + useGetCasesStatus() + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + countClosedCases: null, + countOpenCases: null, + isLoading: true, + isError: false, + fetchCasesStatus: result.current.fetchCasesStatus, + }); + }); + }); + + it('calls getCasesStatus api', async () => { + const spyOnGetCasesStatus = jest.spyOn(api, 'getCasesStatus'); + await act(async () => { + const { waitForNextUpdate } = renderHook<string, UseGetCasesStatus>(() => + useGetCasesStatus() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(spyOnGetCasesStatus).toBeCalledWith(abortCtrl.signal); + }); + }); + + it('fetch reporters', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCasesStatus>(() => + useGetCasesStatus() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + countClosedCases: casesStatus.countClosedCases, + countOpenCases: casesStatus.countOpenCases, + isLoading: false, + isError: false, + fetchCasesStatus: result.current.fetchCasesStatus, + }); + }); + }); + + it('unhappy path', async () => { + const spyOnGetCasesStatus = jest.spyOn(api, 'getCasesStatus'); + spyOnGetCasesStatus.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCasesStatus>(() => + useGetCasesStatus() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + countClosedCases: 0, + countOpenCases: 0, + isLoading: false, + isError: true, + fetchCasesStatus: result.current.fetchCasesStatus, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases_status.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases_status.tsx index 7f56d27ef160e..0788464602357 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases_status.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases_status.tsx @@ -23,7 +23,7 @@ const initialData: CasesStatusState = { isError: false, }; -interface UseGetCasesStatus extends CasesStatusState { +export interface UseGetCasesStatus extends CasesStatusState { fetchCasesStatus: () => void; } diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_reporters.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_reporters.test.tsx new file mode 100644 index 0000000000000..27b963eb6cb54 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_reporters.test.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useGetReporters, UseGetReporters } from './use_get_reporters'; +import { reporters, respReporters } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useGetReporters', () => { + const abortCtrl = new AbortController(); + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetReporters>(() => + useGetReporters() + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + reporters: [], + respReporters: [], + isLoading: true, + isError: false, + fetchReporters: result.current.fetchReporters, + }); + }); + }); + + it('calls getReporters api', async () => { + const spyOnGetReporters = jest.spyOn(api, 'getReporters'); + await act(async () => { + const { waitForNextUpdate } = renderHook<string, UseGetReporters>(() => useGetReporters()); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(spyOnGetReporters).toBeCalledWith(abortCtrl.signal); + }); + }); + + it('fetch reporters', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetReporters>(() => + useGetReporters() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + reporters, + respReporters, + isLoading: false, + isError: false, + fetchReporters: result.current.fetchReporters, + }); + }); + }); + + it('refetch reporters', async () => { + const spyOnGetReporters = jest.spyOn(api, 'getReporters'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetReporters>(() => + useGetReporters() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.fetchReporters(); + expect(spyOnGetReporters).toHaveBeenCalledTimes(2); + }); + }); + + it('unhappy path', async () => { + const spyOnGetReporters = jest.spyOn(api, 'getReporters'); + spyOnGetReporters.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetReporters>(() => + useGetReporters() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + reporters: [], + respReporters: [], + isLoading: false, + isError: true, + fetchReporters: result.current.fetchReporters, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_reporters.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_reporters.tsx index 2478172a3394b..2fc9b8294c8e0 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_reporters.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_reporters.tsx @@ -26,7 +26,7 @@ const initialData: ReportersState = { isError: false, }; -interface UseGetReporters extends ReportersState { +export interface UseGetReporters extends ReportersState { fetchReporters: () => void; } diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.test.tsx new file mode 100644 index 0000000000000..2d70c4390e4dd --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useGetTags, UseGetTags } from './use_get_tags'; +import { tags } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useGetTags', () => { + const abortCtrl = new AbortController(); + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetTags>(() => useGetTags()); + await waitForNextUpdate(); + expect(result.current).toEqual({ + tags: [], + isLoading: true, + isError: false, + fetchTags: result.current.fetchTags, + }); + }); + }); + + it('calls getTags api', async () => { + const spyOnGetTags = jest.spyOn(api, 'getTags'); + await act(async () => { + const { waitForNextUpdate } = renderHook<string, UseGetTags>(() => useGetTags()); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(spyOnGetTags).toBeCalledWith(abortCtrl.signal); + }); + }); + + it('fetch tags', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetTags>(() => useGetTags()); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + tags, + isLoading: false, + isError: false, + fetchTags: result.current.fetchTags, + }); + }); + }); + + it('refetch tags', async () => { + const spyOnGetTags = jest.spyOn(api, 'getTags'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetTags>(() => useGetTags()); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.fetchTags(); + expect(spyOnGetTags).toHaveBeenCalledTimes(2); + }); + }); + + it('unhappy path', async () => { + const spyOnGetTags = jest.spyOn(api, 'getTags'); + spyOnGetTags.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetTags>(() => useGetTags()); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + tags: [], + isLoading: false, + isError: true, + fetchTags: result.current.fetchTags, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx index b41d5aab5c07a..99bb65fa160f7 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx @@ -10,7 +10,7 @@ import { errorToToaster, useStateToaster } from '../../components/toasters'; import { getTags } from './api'; import * as i18n from './translations'; -interface TagsState { +export interface TagsState { tags: string[]; isLoading: boolean; isError: boolean; @@ -20,6 +20,10 @@ type Action = | { type: 'FETCH_SUCCESS'; payload: string[] } | { type: 'FETCH_FAILURE' }; +export interface UseGetTags extends TagsState { + fetchTags: () => void; +} + const dataFetchReducer = (state: TagsState, action: Action): TagsState => { switch (action.type) { case 'FETCH_INIT': @@ -47,15 +51,15 @@ const dataFetchReducer = (state: TagsState, action: Action): TagsState => { }; const initialData: string[] = []; -export const useGetTags = (): TagsState => { +export const useGetTags = (): UseGetTags => { const [state, dispatch] = useReducer(dataFetchReducer, { - isLoading: false, + isLoading: true, isError: false, tags: initialData, }); const [, dispatchToaster] = useStateToaster(); - useEffect(() => { + const callFetch = () => { let didCancel = false; const abortCtrl = new AbortController(); @@ -82,6 +86,9 @@ export const useGetTags = (): TagsState => { abortCtrl.abort(); didCancel = true; }; + }; + useEffect(() => { + callFetch(); }, []); - return state; + return { ...state, fetchTags: callFetch }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.test.tsx new file mode 100644 index 0000000000000..8b105fe041d27 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.test.tsx @@ -0,0 +1,96 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { usePostCase, UsePostCase } from './use_post_case'; +import { basicCasePost } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('usePostCase', () => { + const abortCtrl = new AbortController(); + const samplePost = { + description: 'description', + tags: ['tags'], + title: 'title', + }; + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase()); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + isError: false, + caseData: null, + postCase: result.current.postCase, + }); + }); + }); + + it('calls postCase with correct arguments', async () => { + const spyOnPostCase = jest.spyOn(api, 'postCase'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase()); + await waitForNextUpdate(); + + result.current.postCase(samplePost); + await waitForNextUpdate(); + expect(spyOnPostCase).toBeCalledWith(samplePost, abortCtrl.signal); + }); + }); + + it('post case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase()); + await waitForNextUpdate(); + result.current.postCase(samplePost); + await waitForNextUpdate(); + expect(result.current).toEqual({ + caseData: basicCasePost, + isLoading: false, + isError: false, + postCase: result.current.postCase, + }); + }); + }); + + it('set isLoading to true when posting case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase()); + await waitForNextUpdate(); + result.current.postCase(samplePost); + + expect(result.current.isLoading).toBe(true); + }); + }); + + it('unhappy path', async () => { + const spyOnPostCase = jest.spyOn(api, 'postCase'); + spyOnPostCase.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase()); + await waitForNextUpdate(); + result.current.postCase(samplePost); + + expect(result.current).toEqual({ + caseData: null, + isLoading: false, + isError: true, + postCase: result.current.postCase, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx index 0e01364721dc5..aeb50fc098eee 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx @@ -48,7 +48,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => } }; -interface UsePostCase extends NewCaseState { +export interface UsePostCase extends NewCaseState { postCase: (data: CasePostRequest) => void; } export const usePostCase = (): UsePostCase => { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.test.tsx new file mode 100644 index 0000000000000..d7d9cf9c557c9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.test.tsx @@ -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 { renderHook, act } from '@testing-library/react-hooks'; +import { usePostComment, UsePostComment } from './use_post_comment'; +import { basicCaseId } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('usePostComment', () => { + const abortCtrl = new AbortController(); + const samplePost = { + comment: 'a comment', + }; + const updateCaseCallback = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostComment>(() => + usePostComment(basicCaseId) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + isError: false, + postComment: result.current.postComment, + }); + }); + }); + + it('calls postComment with correct arguments', async () => { + const spyOnPostCase = jest.spyOn(api, 'postComment'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostComment>(() => + usePostComment(basicCaseId) + ); + await waitForNextUpdate(); + + result.current.postComment(samplePost, updateCaseCallback); + await waitForNextUpdate(); + expect(spyOnPostCase).toBeCalledWith(samplePost, basicCaseId, abortCtrl.signal); + }); + }); + + it('post case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostComment>(() => + usePostComment(basicCaseId) + ); + await waitForNextUpdate(); + result.current.postComment(samplePost, updateCaseCallback); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + isError: false, + postComment: result.current.postComment, + }); + }); + }); + + it('set isLoading to true when posting case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostComment>(() => + usePostComment(basicCaseId) + ); + await waitForNextUpdate(); + result.current.postComment(samplePost, updateCaseCallback); + + expect(result.current.isLoading).toBe(true); + }); + }); + + it('unhappy path', async () => { + const spyOnPostCase = jest.spyOn(api, 'postComment'); + spyOnPostCase.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostComment>(() => + usePostComment(basicCaseId) + ); + await waitForNextUpdate(); + result.current.postComment(samplePost, updateCaseCallback); + + expect(result.current).toEqual({ + isLoading: false, + isError: true, + postComment: result.current.postComment, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx index 207b05814717f..c6d34b5449977 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx @@ -41,7 +41,7 @@ const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentSta } }; -interface UsePostComment extends NewCommentState { +export interface UsePostComment extends NewCommentState { postComment: (data: CommentRequest, updateCase: (newCase: Case) => void) => void; } diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.test.tsx new file mode 100644 index 0000000000000..b07a346a8da46 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.test.tsx @@ -0,0 +1,137 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { + formatServiceRequestData, + usePostPushToService, + UsePostPushToService, +} from './use_post_push_to_service'; +import { basicCase, pushedCase, serviceConnector } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('usePostPushToService', () => { + const abortCtrl = new AbortController(); + const updateCase = jest.fn(); + const samplePush = { + caseId: pushedCase.id, + connectorName: 'sample', + connectorId: '22', + updateCase, + }; + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostPushToService>(() => + usePostPushToService() + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + serviceData: null, + pushedCaseData: null, + isLoading: false, + isError: false, + postPushToService: result.current.postPushToService, + }); + }); + }); + + it('calls pushCase with correct arguments', async () => { + const spyOnPushCase = jest.spyOn(api, 'pushCase'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostPushToService>(() => + usePostPushToService() + ); + await waitForNextUpdate(); + result.current.postPushToService(samplePush); + await waitForNextUpdate(); + expect(spyOnPushCase).toBeCalledWith( + samplePush.caseId, + { + connector_id: samplePush.connectorId, + connector_name: samplePush.connectorName, + external_id: serviceConnector.incidentId, + external_title: serviceConnector.number, + external_url: serviceConnector.url, + }, + abortCtrl.signal + ); + }); + }); + + it('calls pushToService with correct arguments', async () => { + const spyOnPushToService = jest.spyOn(api, 'pushToService'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostPushToService>(() => + usePostPushToService() + ); + await waitForNextUpdate(); + result.current.postPushToService(samplePush); + await waitForNextUpdate(); + expect(spyOnPushToService).toBeCalledWith( + samplePush.connectorId, + formatServiceRequestData(basicCase), + abortCtrl.signal + ); + }); + }); + + it('post push to service', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostPushToService>(() => + usePostPushToService() + ); + await waitForNextUpdate(); + result.current.postPushToService(samplePush); + await waitForNextUpdate(); + expect(result.current).toEqual({ + serviceData: serviceConnector, + pushedCaseData: pushedCase, + isLoading: false, + isError: false, + postPushToService: result.current.postPushToService, + }); + }); + }); + + it('set isLoading to true when deleting cases', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostPushToService>(() => + usePostPushToService() + ); + await waitForNextUpdate(); + result.current.postPushToService(samplePush); + expect(result.current.isLoading).toBe(true); + }); + }); + + it('unhappy path', async () => { + const spyOnPushToService = jest.spyOn(api, 'pushToService'); + spyOnPushToService.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostPushToService>(() => + usePostPushToService() + ); + await waitForNextUpdate(); + result.current.postPushToService(samplePush); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + serviceData: null, + pushedCaseData: null, + isLoading: false, + isError: true, + postPushToService: result.current.postPushToService, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx index d9a32f26f7fe7..89e7e18cf0688 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx @@ -68,7 +68,7 @@ interface PushToServiceRequest { updateCase: (newCase: Case) => void; } -interface UsePostPushToService extends PushToServiceState { +export interface UsePostPushToService extends PushToServiceState { postPushToService: ({ caseId, connectorId, updateCase }: PushToServiceRequest) => void; } @@ -131,7 +131,7 @@ export const usePostPushToService = (): UsePostPushToService => { return { ...state, postPushToService }; }; -const formatServiceRequestData = (myCase: Case): ServiceConnectorCaseParams => { +export const formatServiceRequestData = (myCase: Case): ServiceConnectorCaseParams => { const { id: caseId, createdAt, diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.test.tsx new file mode 100644 index 0000000000000..86cfc3459c595 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.test.tsx @@ -0,0 +1,119 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useUpdateCase, UseUpdateCase, UpdateKey } from './use_update_case'; +import { basicCase } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useUpdateCase', () => { + const abortCtrl = new AbortController(); + const fetchCaseUserActions = jest.fn(); + const updateCase = jest.fn(); + const updateKey: UpdateKey = 'description'; + const sampleUpdate = { + fetchCaseUserActions, + updateKey, + updateValue: 'updated description', + updateCase, + version: basicCase.version, + }; + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseUpdateCase>(() => + useUpdateCase({ caseId: basicCase.id }) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + isError: false, + updateKey: null, + updateCaseProperty: result.current.updateCaseProperty, + }); + }); + }); + + it('calls patchCase with correct arguments', async () => { + const spyOnPatchCase = jest.spyOn(api, 'patchCase'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseUpdateCase>(() => + useUpdateCase({ caseId: basicCase.id }) + ); + await waitForNextUpdate(); + + result.current.updateCaseProperty(sampleUpdate); + await waitForNextUpdate(); + expect(spyOnPatchCase).toBeCalledWith( + basicCase.id, + { description: 'updated description' }, + basicCase.version, + abortCtrl.signal + ); + }); + }); + + it('patch case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseUpdateCase>(() => + useUpdateCase({ caseId: basicCase.id }) + ); + await waitForNextUpdate(); + result.current.updateCaseProperty(sampleUpdate); + await waitForNextUpdate(); + expect(result.current).toEqual({ + updateKey: null, + isLoading: false, + isError: false, + updateCaseProperty: result.current.updateCaseProperty, + }); + expect(fetchCaseUserActions).toBeCalledWith(basicCase.id); + expect(updateCase).toBeCalledWith(basicCase); + }); + }); + + it('set isLoading to true when posting case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseUpdateCase>(() => + useUpdateCase({ caseId: basicCase.id }) + ); + await waitForNextUpdate(); + result.current.updateCaseProperty(sampleUpdate); + + expect(result.current.isLoading).toBe(true); + expect(result.current.updateKey).toBe(updateKey); + }); + }); + + it('unhappy path', async () => { + const spyOnPatchCase = jest.spyOn(api, 'patchCase'); + spyOnPatchCase.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseUpdateCase>(() => + useUpdateCase({ caseId: basicCase.id }) + ); + await waitForNextUpdate(); + result.current.updateCaseProperty(sampleUpdate); + + expect(result.current).toEqual({ + updateKey: null, + isLoading: false, + isError: true, + updateCaseProperty: result.current.updateCaseProperty, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index 4973deef4d91a..7ebbbba076c12 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -12,7 +12,7 @@ import { patchCase } from './api'; import * as i18n from './translations'; import { Case } from './types'; -type UpdateKey = keyof Pick<CasePatchRequest, 'description' | 'status' | 'tags' | 'title'>; +export type UpdateKey = keyof Pick<CasePatchRequest, 'description' | 'status' | 'tags' | 'title'>; interface NewCaseState { isLoading: boolean; @@ -62,7 +62,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => } }; -interface UseUpdateCase extends NewCaseState { +export interface UseUpdateCase extends NewCaseState { updateCaseProperty: (updates: UpdateByKey) => void; } export const useUpdateCase = ({ caseId }: { caseId: string }): UseUpdateCase => { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.test.tsx new file mode 100644 index 0000000000000..5772ff4246866 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.test.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useUpdateComment, UseUpdateComment } from './use_update_comment'; +import { basicCase, basicCaseCommentPatch } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useUpdateComment', () => { + const abortCtrl = new AbortController(); + const fetchUserActions = jest.fn(); + const updateCase = jest.fn(); + const sampleUpdate = { + caseId: basicCase.id, + commentId: basicCase.comments[0].id, + commentUpdate: 'updated comment', + fetchUserActions, + updateCase, + version: basicCase.comments[0].version, + }; + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseUpdateComment>(() => + useUpdateComment() + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoadingIds: [], + isError: false, + patchComment: result.current.patchComment, + }); + }); + }); + + it('calls patchComment with correct arguments', async () => { + const spyOnPatchComment = jest.spyOn(api, 'patchComment'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseUpdateComment>(() => + useUpdateComment() + ); + await waitForNextUpdate(); + + result.current.patchComment(sampleUpdate); + await waitForNextUpdate(); + expect(spyOnPatchComment).toBeCalledWith( + basicCase.id, + basicCase.comments[0].id, + 'updated comment', + basicCase.comments[0].version, + abortCtrl.signal + ); + }); + }); + + it('patch comment', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseUpdateComment>(() => + useUpdateComment() + ); + await waitForNextUpdate(); + result.current.patchComment(sampleUpdate); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoadingIds: [], + isError: false, + patchComment: result.current.patchComment, + }); + expect(fetchUserActions).toBeCalled(); + expect(updateCase).toBeCalledWith(basicCaseCommentPatch); + }); + }); + + it('set isLoading to true when posting case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseUpdateComment>(() => + useUpdateComment() + ); + await waitForNextUpdate(); + result.current.patchComment(sampleUpdate); + + expect(result.current.isLoadingIds).toEqual([basicCase.comments[0].id]); + }); + }); + + it('unhappy path', async () => { + const spyOnPatchComment = jest.spyOn(api, 'patchComment'); + spyOnPatchComment.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseUpdateComment>(() => + useUpdateComment() + ); + await waitForNextUpdate(); + result.current.patchComment(sampleUpdate); + + expect(result.current).toEqual({ + isLoadingIds: [], + isError: true, + patchComment: result.current.patchComment, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx index faf9649a705c5..ffc5cffee7a55 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx @@ -60,7 +60,7 @@ interface UpdateComment { version: string; } -interface UseUpdateComment extends CommentUpdateState { +export interface UseUpdateComment extends CommentUpdateState { patchComment: ({ caseId, commentId, commentUpdate, fetchUserActions }: UpdateComment) => void; } diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts index e8019659d49c6..9eb4acbdb6164 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts @@ -501,10 +501,8 @@ describe('Detections Rules API', () => { test('check parameter url, query', async () => { await getRuleStatusById({ id: 'mySuperRuleId', signal: abortCtrl.signal }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_find_statuses', { - query: { - ids: '["mySuperRuleId"]', - }, - method: 'GET', + body: '{"ids":["mySuperRuleId"]}', + method: 'POST', signal: abortCtrl.signal, }); }); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index 2dd6955581eff..5cc73e17662c6 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -266,8 +266,8 @@ export const getRuleStatusById = async ({ signal: AbortSignal; }): Promise<RuleStatusResponse> => KibanaServices.get().http.fetch<RuleStatusResponse>(DETECTION_ENGINE_RULES_STATUS_URL, { - method: 'GET', - query: { ids: JSON.stringify([id]) }, + method: 'POST', + body: JSON.stringify({ ids: [id] }), signal, }); @@ -289,8 +289,8 @@ export const getRulesStatusByIds = async ({ const res = await KibanaServices.get().http.fetch<RuleStatusResponse>( DETECTION_ENGINE_RULES_STATUS_URL, { - method: 'GET', - query: { ids: JSON.stringify(ids) }, + method: 'POST', + body: JSON.stringify({ ids }), signal, } ); diff --git a/x-pack/legacy/plugins/siem/public/mock/test_providers.tsx b/x-pack/legacy/plugins/siem/public/mock/test_providers.tsx index c7692755c1330..952f7f51b63f2 100644 --- a/x-pack/legacy/plugins/siem/public/mock/test_providers.tsx +++ b/x-pack/legacy/plugins/siem/public/mock/test_providers.tsx @@ -20,6 +20,7 @@ import { ThemeProvider } from 'styled-components'; import { createStore, State } from '../store'; import { mockGlobalState } from './global_state'; import { createKibanaContextProviderMock } from './kibana_react'; +import { FieldHook, useForm } from '../shared_imports'; jest.mock('ui/new_platform'); @@ -91,3 +92,29 @@ const TestProviderWithoutDragAndDropComponent: React.FC<Props> = ({ ); export const TestProviderWithoutDragAndDrop = React.memo(TestProviderWithoutDragAndDropComponent); + +export const useFormFieldMock = (options?: Partial<FieldHook>): FieldHook => { + const { form } = useForm(); + + return { + path: 'path', + type: 'type', + value: [], + isPristine: false, + isValidating: false, + isValidated: false, + isChangingValue: false, + form, + errors: [], + isValid: true, + getErrorsMessages: jest.fn(), + onChange: jest.fn(), + setValue: jest.fn(), + setErrors: jest.fn(), + clearErrors: jest.fn(), + validate: jest.fn(), + reset: jest.fn(), + __serializeOutput: jest.fn(), + ...options, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/case.tsx b/x-pack/legacy/plugins/siem/public/pages/case/case.tsx index 2ae35796387b8..2b613f6692df1 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/case.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/case.tsx @@ -11,11 +11,9 @@ import { useGetUserSavedObjectPermissions } from '../../lib/kibana'; import { SpyRoute } from '../../utils/route/spy_routes'; import { AllCases } from './components/all_cases'; -import { getSavedObjectReadOnly, CaseCallOut } from './components/callout'; +import { savedObjectReadOnly, CaseCallOut } from './components/callout'; import { CaseSavedObjectNoPermissions } from './saved_object_no_permissions'; -const infoReadSavedObject = getSavedObjectReadOnly(); - export const CasesPage = React.memo(() => { const userPermissions = useGetUserSavedObjectPermissions(); @@ -24,8 +22,8 @@ export const CasesPage = React.memo(() => { <WrapperPage> {userPermissions != null && !userPermissions?.crud && userPermissions?.read && ( <CaseCallOut - title={infoReadSavedObject.title} - message={infoReadSavedObject.description} + title={savedObjectReadOnly.title} + message={savedObjectReadOnly.description} /> )} <AllCases userCanCrud={userPermissions?.crud ?? false} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/case/case_details.tsx b/x-pack/legacy/plugins/siem/public/pages/case/case_details.tsx index cbc7bbc62fbf9..4bb8afa7f8d42 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/case_details.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/case_details.tsx @@ -13,9 +13,7 @@ import { SpyRoute } from '../../utils/route/spy_routes'; import { getCaseUrl } from '../../components/link_to'; import { navTabs } from '../home/home_navigations'; import { CaseView } from './components/case_view'; -import { getSavedObjectReadOnly, CaseCallOut } from './components/callout'; - -const infoReadSavedObject = getSavedObjectReadOnly(); +import { savedObjectReadOnly, CaseCallOut } from './components/callout'; export const CaseDetailsPage = React.memo(() => { const userPermissions = useGetUserSavedObjectPermissions(); @@ -29,7 +27,7 @@ export const CaseDetailsPage = React.memo(() => { return caseId != null ? ( <> {userPermissions != null && !userPermissions?.crud && userPermissions?.read && ( - <CaseCallOut title={infoReadSavedObject.title} message={infoReadSavedObject.description} /> + <CaseCallOut title={savedObjectReadOnly.title} message={savedObjectReadOnly.description} /> )} <CaseView caseId={caseId} userCanCrud={userPermissions?.crud ?? false} /> <SpyRoute /> diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/__mock__/form.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/__mock__/form.ts new file mode 100644 index 0000000000000..9d2ac29bc47d7 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/__mock__/form.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. + */ +export const mockFormHook = { + isSubmitted: false, + isSubmitting: false, + isValid: true, + submit: jest.fn(), + subscribe: jest.fn(), + setFieldValue: jest.fn(), + setFieldErrors: jest.fn(), + getFields: jest.fn(), + getFormData: jest.fn(), + getFieldDefaultValue: jest.fn(), + /* Returns a list of all errors in the form */ + getErrors: jest.fn(), + reset: jest.fn(), + __options: {}, + __formData$: {}, + __addField: jest.fn(), + __removeField: jest.fn(), + __validateFields: jest.fn(), + __updateFormDataAt: jest.fn(), + __readFieldConfigFromSchema: jest.fn(), +}; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const getFormMock = (sampleData: any) => ({ + ...mockFormHook, + submit: () => + Promise.resolve({ + data: sampleData, + isValid: true, + }), + getFormData: () => sampleData, +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/__mock__/router.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/__mock__/router.ts new file mode 100644 index 0000000000000..a20ab00852a36 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/__mock__/router.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Router } from 'react-router-dom'; +// eslint-disable-next-line @kbn/eslint/module_migration +import routeData from 'react-router'; +type Action = 'PUSH' | 'POP' | 'REPLACE'; +const pop: Action = 'POP'; +const location = { + pathname: '/network', + search: '', + state: '', + hash: '', +}; +export const mockHistory = { + length: 2, + location, + action: pop, + push: jest.fn(), + replace: jest.fn(), + go: jest.fn(), + goBack: jest.fn(), + goForward: jest.fn(), + block: jest.fn(), + createHref: jest.fn(), + listen: jest.fn(), +}; + +export const mockLocation = { + pathname: '/welcome', + hash: '', + search: '', + state: '', +}; + +export { Router, routeData }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.test.tsx new file mode 100644 index 0000000000000..74f6411f17fa0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.test.tsx @@ -0,0 +1,144 @@ +/* + * 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 { mount } from 'enzyme'; + +import { AddComment } from './'; +import { TestProviders } from '../../../../mock'; +import { getFormMock } from '../__mock__/form'; +import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; + +import { useInsertTimeline } from '../../../../components/timeline/insert_timeline_popover/use_insert_timeline'; +import { usePostComment } from '../../../../containers/case/use_post_comment'; +import { useForm } from '../../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'; +import { wait } from '../../../../lib/helpers'; +jest.mock( + '../../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form' +); +jest.mock('../../../../components/timeline/insert_timeline_popover/use_insert_timeline'); +jest.mock('../../../../containers/case/use_post_comment'); + +export const useFormMock = useForm as jest.Mock; + +const useInsertTimelineMock = useInsertTimeline as jest.Mock; +const usePostCommentMock = usePostComment as jest.Mock; + +const onCommentSaving = jest.fn(); +const onCommentPosted = jest.fn(); +const postComment = jest.fn(); +const handleCursorChange = jest.fn(); +const handleOnTimelineChange = jest.fn(); + +const addCommentProps = { + caseId: '1234', + disabled: false, + insertQuote: null, + onCommentSaving, + onCommentPosted, + showLoading: false, +}; + +const defaultInsertTimeline = { + cursorPosition: { + start: 0, + end: 0, + }, + handleCursorChange, + handleOnTimelineChange, +}; + +const defaultPostCommment = { + isLoading: false, + isError: false, + postComment, +}; +const sampleData = { + comment: 'what a cool comment', +}; +describe('AddComment ', () => { + const formHookMock = getFormMock(sampleData); + + beforeEach(() => { + jest.resetAllMocks(); + useInsertTimelineMock.mockImplementation(() => defaultInsertTimeline); + usePostCommentMock.mockImplementation(() => defaultPostCommment); + useFormMock.mockImplementation(() => ({ form: formHookMock })); + jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); + }); + + it('should post comment on submit click', async () => { + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <AddComment {...addCommentProps} /> + </Router> + </TestProviders> + ); + expect(wrapper.find(`[data-test-subj="add-comment"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="loading-spinner"]`).exists()).toBeFalsy(); + + wrapper + .find(`[data-test-subj="submit-comment"]`) + .first() + .simulate('click'); + await wait(); + expect(onCommentSaving).toBeCalled(); + expect(postComment).toBeCalledWith(sampleData, onCommentPosted); + expect(formHookMock.reset).toBeCalled(); + }); + + it('should render spinner and disable submit when loading', () => { + usePostCommentMock.mockImplementation(() => ({ ...defaultPostCommment, isLoading: true })); + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <AddComment {...{ ...addCommentProps, showLoading: true }} /> + </Router> + </TestProviders> + ); + expect(wrapper.find(`[data-test-subj="loading-spinner"]`).exists()).toBeTruthy(); + expect( + wrapper + .find(`[data-test-subj="submit-comment"]`) + .first() + .prop('isDisabled') + ).toBeTruthy(); + }); + + it('should disable submit button when disabled prop passed', () => { + usePostCommentMock.mockImplementation(() => ({ ...defaultPostCommment, isLoading: true })); + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <AddComment {...{ ...addCommentProps, disabled: true }} /> + </Router> + </TestProviders> + ); + expect( + wrapper + .find(`[data-test-subj="submit-comment"]`) + .first() + .prop('isDisabled') + ).toBeTruthy(); + }); + + it('should insert a quote if one is available', () => { + const sampleQuote = 'what a cool quote'; + mount( + <TestProviders> + <Router history={mockHistory}> + <AddComment {...{ ...addCommentProps, insertQuote: sampleQuote }} /> + </Router> + </TestProviders> + ); + + expect(formHookMock.setFieldValue).toBeCalledWith( + 'comment', + `${sampleData.comment}\n\n${sampleQuote}` + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx index ecc57c50e28eb..eaba708948a99 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx @@ -71,10 +71,9 @@ export const AddComment = React.memo<AddCommentProps>( form.reset(); } }, [form, onCommentPosted, onCommentSaving]); - return ( <span id="add-comment-permLink"> - {isLoading && showLoading && <MySpinner size="xl" />} + {isLoading && showLoading && <MySpinner data-test-subj="loading-spinner" size="xl" />} <Form form={form}> <UseField path="comment" @@ -82,11 +81,12 @@ export const AddComment = React.memo<AddCommentProps>( componentProps={{ idAria: 'caseComment', isDisabled: isLoading, - dataTestSubj: 'caseComment', + dataTestSubj: 'add-comment', placeholder: i18n.ADD_COMMENT_HELP_TEXT, onCursorPositionUpdate: handleCursorChange, bottomRightContent: ( <EuiButton + data-test-subj="submit-comment" iconType="plusInCircle" isDisabled={isLoading || disabled} isLoading={isLoading} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx deleted file mode 100644 index d4ec32dfd070b..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ /dev/null @@ -1,115 +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 { SortFieldCase } from '../../../../../containers/case/types'; -import { UseGetCasesState } from '../../../../../containers/case/use_get_cases'; - -export const useGetCasesMockState: UseGetCasesState = { - data: { - countClosedCases: 0, - countOpenCases: 5, - cases: [ - { - closedAt: null, - closedBy: null, - id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', - createdAt: '2020-02-13T19:44:23.627Z', - createdBy: { username: 'elastic' }, - comments: [], - description: 'Security banana Issue', - externalService: null, - status: 'open', - tags: ['defacement'], - title: 'Another horrible breach', - totalComment: 0, - updatedAt: null, - updatedBy: null, - version: 'WzQ3LDFd', - }, - { - closedAt: null, - closedBy: null, - id: '362a5c10-4e99-11ea-9290-35d05cb55c15', - createdAt: '2020-02-13T19:44:13.328Z', - createdBy: { username: 'elastic' }, - comments: [], - description: 'Security banana Issue', - externalService: null, - status: 'open', - tags: ['phishing'], - title: 'Bad email', - totalComment: 0, - updatedAt: null, - updatedBy: null, - version: 'WzQ3LDFd', - }, - { - closedAt: null, - closedBy: null, - id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', - createdAt: '2020-02-13T19:44:11.328Z', - createdBy: { username: 'elastic' }, - comments: [], - description: 'Security banana Issue', - externalService: null, - status: 'open', - tags: ['phishing'], - title: 'Bad email', - totalComment: 0, - updatedAt: null, - updatedBy: null, - version: 'WzQ3LDFd', - }, - { - closedAt: '2020-02-13T19:44:13.328Z', - closedBy: { username: 'elastic' }, - id: '31890e90-4e99-11ea-9290-35d05cb55c15', - createdAt: '2020-02-13T19:44:05.563Z', - createdBy: { username: 'elastic' }, - comments: [], - description: 'Security banana Issue', - externalService: null, - status: 'closed', - tags: ['phishing'], - title: 'Uh oh', - totalComment: 0, - updatedAt: null, - updatedBy: null, - version: 'WzQ3LDFd', - }, - { - closedAt: null, - closedBy: null, - id: '2f5b3210-4e99-11ea-9290-35d05cb55c15', - createdAt: '2020-02-13T19:44:01.901Z', - createdBy: { username: 'elastic' }, - comments: [], - description: 'Security banana Issue', - externalService: null, - status: 'open', - tags: ['phishing'], - title: 'Uh oh', - totalComment: 0, - updatedAt: null, - updatedBy: null, - version: 'WzQ3LDFd', - }, - ], - page: 1, - perPage: 5, - total: 10, - }, - loading: [], - selectedCases: [], - isError: false, - queryParams: { - page: 1, - perPage: 5, - sortField: SortFieldCase.createdAt, - sortOrder: 'desc', - }, - filterOptions: { search: '', reporters: [], tags: [], status: 'open' }, -}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.test.tsx new file mode 100644 index 0000000000000..31c795c05edd5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.test.tsx @@ -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 React from 'react'; +import { mount } from 'enzyme'; + +import { ServiceNowColumn } from './columns'; + +import { useGetCasesMockState } from '../../../../containers/case/mock'; + +describe('ServiceNowColumn ', () => { + it('Not pushed render', () => { + const wrapper = mount( + <ServiceNowColumn {...{ theCase: useGetCasesMockState.data.cases[0] }} /> + ); + expect( + wrapper + .find(`[data-test-subj="case-table-column-external-notPushed"]`) + .last() + .exists() + ).toBeTruthy(); + }); + it('Up to date', () => { + const wrapper = mount( + <ServiceNowColumn {...{ theCase: useGetCasesMockState.data.cases[1] }} /> + ); + expect( + wrapper + .find(`[data-test-subj="case-table-column-external-upToDate"]`) + .last() + .exists() + ).toBeTruthy(); + }); + it('Needs update', () => { + const wrapper = mount( + <ServiceNowColumn {...{ theCase: useGetCasesMockState.data.cases[2] }} /> + ); + expect( + wrapper + .find(`[data-test-subj="case-table-column-external-requiresUpdate"]`) + .last() + .exists() + ).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index 0e12f78e29bc2..e48e5cb0c5959 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -114,7 +114,9 @@ export const getCasesColumns = ( name: i18n.COMMENTS, sortable: true, render: (totalComment: Case['totalComment']) => - renderStringField(`${totalComment}`, `case-table-column-commentCount`), + totalComment != null + ? renderStringField(`${totalComment}`, `case-table-column-commentCount`) + : getEmptyTagValue(), }, filterStatus === 'open' ? { @@ -150,7 +152,7 @@ export const getCasesColumns = ( }, }, { - name: 'ServiceNow Incident', + name: i18n.SERVICENOW_INCIDENT, render: (theCase: Case) => { if (theCase.id != null) { return <ServiceNowColumn theCase={theCase} />; @@ -159,7 +161,7 @@ export const getCasesColumns = ( }, }, { - name: 'Actions', + name: i18n.ACTIONS, actions, }, ]; @@ -168,7 +170,7 @@ interface Props { theCase: Case; } -const ServiceNowColumn: React.FC<Props> = ({ theCase }) => { +export const ServiceNowColumn: React.FC<Props> = ({ theCase }) => { const handleRenderDataToPush = useCallback(() => { const lastCaseUpdate = theCase.updatedAt != null ? new Date(theCase.updatedAt) : null; const lastCasePush = @@ -190,7 +192,9 @@ const ServiceNowColumn: React.FC<Props> = ({ theCase }) => { > {theCase.externalService?.externalTitle} </EuiLink> - {hasDataToPush ? i18n.REQUIRES_UPDATE : i18n.UP_TO_DATE} + {hasDataToPush + ? renderStringField(i18n.REQUIRES_UPDATE, `case-table-column-external-requiresUpdate`) + : renderStringField(i18n.UP_TO_DATE, `case-table-column-external-upToDate`)} </p> ); }, [theCase]); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx index a6da45a8c5bb1..58d0c1b0faaf3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx @@ -9,11 +9,15 @@ import { mount } from 'enzyme'; import moment from 'moment-timezone'; import { AllCases } from './'; import { TestProviders } from '../../../../mock'; -import { useGetCasesMockState } from './__mock__'; +import { useGetCasesMockState } from '../../../../containers/case/mock'; +import * as i18n from './translations'; + +import { getEmptyTagValue } from '../../../../components/empty_value'; import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; import { useGetCases } from '../../../../containers/case/use_get_cases'; import { useGetCasesStatus } from '../../../../containers/case/use_get_cases_status'; import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; +import { getCasesColumns } from './columns'; jest.mock('../../../../containers/case/use_bulk_update_case'); jest.mock('../../../../containers/case/use_delete_cases'); jest.mock('../../../../containers/case/use_get_cases'); @@ -35,6 +39,7 @@ describe('AllCases', () => { const setSelectedCases = jest.fn(); const updateBulkStatus = jest.fn(); const fetchCasesStatus = jest.fn(); + const emptyTag = getEmptyTagValue().props.children; const defaultGetCases = { ...useGetCasesMockState, @@ -115,7 +120,7 @@ describe('AllCases', () => { .find(`[data-test-subj="case-table-column-createdBy"]`) .first() .text() - ).toEqual(useGetCasesMockState.data.cases[0].createdBy.username); + ).toEqual(useGetCasesMockState.data.cases[0].createdBy.fullName); expect( wrapper .find(`[data-test-subj="case-table-column-createdAt"]`) @@ -129,6 +134,39 @@ describe('AllCases', () => { .text() ).toEqual('Showing 10 cases'); }); + it('should render empty fields', () => { + useGetCasesMock.mockImplementation(() => ({ + ...defaultGetCases, + data: { + ...defaultGetCases.data, + cases: [ + { + ...defaultGetCases.data.cases[0], + id: null, + createdAt: null, + createdBy: null, + tags: null, + title: null, + totalComment: null, + }, + ], + }, + })); + const wrapper = mount( + <TestProviders> + <AllCases userCanCrud={true} /> + </TestProviders> + ); + const checkIt = (columnName: string, key: number) => { + const column = wrapper.find('[data-test-subj="cases-table"] tbody .euiTableRowCell').at(key); + if (columnName === i18n.ACTIONS) { + return; + } + expect(column.find('.euiTableRowCell--hideForDesktop').text()).toEqual(columnName); + expect(column.find('span').text()).toEqual(emptyTag); + }; + getCasesColumns([], 'open').map((i, key) => i.name != null && checkIt(`${i.name}`, key)); + }); it('should tableHeaderSortButton AllCases', () => { const wrapper = mount( <TestProviders> @@ -165,6 +203,30 @@ describe('AllCases', () => { version: firstCase.version, }); }); + it('opens case when row action icon clicked', () => { + useGetCasesMock.mockImplementation(() => ({ + ...defaultGetCases, + filterOptions: { ...defaultGetCases.filterOptions, status: 'closed' }, + })); + + const wrapper = mount( + <TestProviders> + <AllCases userCanCrud={true} /> + </TestProviders> + ); + wrapper + .find('[data-test-subj="action-open"]') + .first() + .simulate('click'); + const firstCase = useGetCasesMockState.data.cases[0]; + expect(dispatchUpdateCaseProperty).toBeCalledWith({ + caseId: firstCase.id, + updateKey: 'status', + updateValue: 'open', + refetchCasesStatus: fetchCasesStatus, + version: firstCase.version, + }); + }); it('Bulk delete', () => { useGetCasesMock.mockImplementation(() => ({ ...defaultGetCases, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index b0ff3dbada6c9..a6eca717a82a3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { EuiBasicTable, EuiButton, @@ -129,13 +129,25 @@ export const AllCases = React.memo<AllCasesProps>(({ userCanCrud }) => { id: '', }); const [deleteBulk, setDeleteBulk] = useState<DeleteCase[]>([]); - - const refreshCases = useCallback(() => { - refetchCases(filterOptions, queryParams); - fetchCasesStatus(); - setSelectedCases([]); - setDeleteBulk([]); - }, [filterOptions, queryParams]); + const filterRefetch = useRef<() => void>(); + const setFilterRefetch = useCallback( + (refetchFilter: () => void) => { + filterRefetch.current = refetchFilter; + }, + [filterRefetch.current] + ); + const refreshCases = useCallback( + (dataRefresh = true) => { + if (dataRefresh) refetchCases(); + fetchCasesStatus(); + setSelectedCases([]); + setDeleteBulk([]); + if (filterRefetch.current != null) { + filterRefetch.current(); + } + }, + [filterOptions, queryParams, filterRefetch.current] + ); useEffect(() => { if (isDeleted) { @@ -247,6 +259,7 @@ export const AllCases = React.memo<AllCasesProps>(({ userCanCrud }) => { }; } setQueryParams(newQueryParams); + refreshCases(false); }, [queryParams] ); @@ -259,6 +272,7 @@ export const AllCases = React.memo<AllCasesProps>(({ userCanCrud }) => { setQueryParams({ sortField: SortFieldCase.createdAt }); } setFilters(newFilterOptions); + refreshCases(false); }, [filterOptions, queryParams] ); @@ -347,6 +361,7 @@ export const AllCases = React.memo<AllCasesProps>(({ userCanCrud }) => { tags: filterOptions.tags, status: filterOptions.status, }} + setFilterRefetch={setFilterRefetch} /> {isCasesLoading && isDataEmpty ? ( <Div> diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.test.tsx new file mode 100644 index 0000000000000..21dcc9732440d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.test.tsx @@ -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 React from 'react'; +import { mount } from 'enzyme'; + +import { CasesTableFilters } from './table_filters'; +import { TestProviders } from '../../../../mock'; + +import { useGetTags } from '../../../../containers/case/use_get_tags'; +import { useGetReporters } from '../../../../containers/case/use_get_reporters'; +import { DEFAULT_FILTER_OPTIONS } from '../../../../containers/case/use_get_cases'; +jest.mock('../../../../components/timeline/insert_timeline_popover/use_insert_timeline'); +jest.mock('../../../../containers/case/use_get_reporters'); +jest.mock('../../../../containers/case/use_get_tags'); + +const onFilterChanged = jest.fn(); +const fetchReporters = jest.fn(); +const fetchTags = jest.fn(); +const setFilterRefetch = jest.fn(); + +const props = { + countClosedCases: 1234, + countOpenCases: 99, + onFilterChanged, + initial: DEFAULT_FILTER_OPTIONS, + setFilterRefetch, +}; +describe('CasesTableFilters ', () => { + beforeEach(() => { + jest.resetAllMocks(); + (useGetTags as jest.Mock).mockReturnValue({ tags: ['coke', 'pepsi'], fetchTags }); + (useGetReporters as jest.Mock).mockReturnValue({ + reporters: ['casetester'], + respReporters: [{ username: 'casetester' }], + isLoading: true, + isError: false, + fetchReporters, + }); + }); + it('should render the initial case count', () => { + const wrapper = mount( + <TestProviders> + <CasesTableFilters {...props} /> + </TestProviders> + ); + expect( + wrapper + .find(`[data-test-subj="open-case-count"]`) + .last() + .text() + ).toEqual('Open cases (99)'); + expect( + wrapper + .find(`[data-test-subj="closed-case-count"]`) + .last() + .text() + ).toEqual('Closed cases (1234)'); + }); + it('should call onFilterChange when selected tags change', () => { + const wrapper = mount( + <TestProviders> + <CasesTableFilters {...props} /> + </TestProviders> + ); + wrapper + .find(`[data-test-subj="options-filter-popover-button-Tags"]`) + .last() + .simulate('click'); + wrapper + .find(`[data-test-subj="options-filter-popover-item-0"]`) + .last() + .simulate('click'); + + expect(onFilterChanged).toBeCalledWith({ tags: ['coke'] }); + }); + it('should call onFilterChange when selected reporters change', () => { + const wrapper = mount( + <TestProviders> + <CasesTableFilters {...props} /> + </TestProviders> + ); + wrapper + .find(`[data-test-subj="options-filter-popover-button-Reporter"]`) + .last() + .simulate('click'); + + wrapper + .find(`[data-test-subj="options-filter-popover-item-0"]`) + .last() + .simulate('click'); + + expect(onFilterChanged).toBeCalledWith({ reporters: [{ username: 'casetester' }] }); + }); + it('should call onFilterChange when search changes', () => { + const wrapper = mount( + <TestProviders> + <CasesTableFilters {...props} /> + </TestProviders> + ); + + wrapper + .find(`[data-test-subj="search-cases"]`) + .last() + .simulate('keyup', { keyCode: 13, target: { value: 'My search' } }); + expect(onFilterChanged).toBeCalledWith({ search: 'My search' }); + }); + it('should call onFilterChange when status toggled', () => { + const wrapper = mount( + <TestProviders> + <CasesTableFilters {...props} /> + </TestProviders> + ); + wrapper + .find(`[data-test-subj="closed-case-count"]`) + .last() + .simulate('click'); + + expect(onFilterChanged).toBeCalledWith({ status: 'closed' }); + }); + it('should call on load setFilterRefetch', () => { + mount( + <TestProviders> + <CasesTableFilters {...props} /> + </TestProviders> + ); + expect(setFilterRefetch).toHaveBeenCalled(); + }); + it('should remove tag from selected tags when tag no longer exists', () => { + const ourProps = { + ...props, + initial: { + ...DEFAULT_FILTER_OPTIONS, + tags: ['pepsi', 'rc'], + }, + }; + mount( + <TestProviders> + <CasesTableFilters {...ourProps} /> + </TestProviders> + ); + expect(onFilterChanged).toHaveBeenCalledWith({ tags: ['pepsi'] }); + }); + it('should remove reporter from selected reporters when reporter no longer exists', () => { + const ourProps = { + ...props, + initial: { + ...DEFAULT_FILTER_OPTIONS, + reporters: [ + { username: 'casetester', full_name: null, email: null }, + { username: 'batman', full_name: null, email: null }, + ], + }, + }; + mount( + <TestProviders> + <CasesTableFilters {...ourProps} /> + </TestProviders> + ); + expect(onFilterChanged).toHaveBeenCalledWith({ reporters: [{ username: 'casetester' }] }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx index a344dd7891010..901fb133753e8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { isEqual } from 'lodash/fp'; import { EuiFieldSearch, @@ -25,6 +25,7 @@ interface CasesTableFiltersProps { countOpenCases: number | null; onFilterChanged: (filterOptions: Partial<FilterOptions>) => void; initial: FilterOptions; + setFilterRefetch: (val: () => void) => void; } /** @@ -41,20 +42,42 @@ const CasesTableFiltersComponent = ({ countOpenCases, onFilterChanged, initial = defaultInitial, + setFilterRefetch, }: CasesTableFiltersProps) => { - const [selectedReporters, setselectedReporters] = useState( + const [selectedReporters, setSelectedReporters] = useState( initial.reporters.map(r => r.full_name ?? r.username ?? '') ); const [search, setSearch] = useState(initial.search); const [selectedTags, setSelectedTags] = useState(initial.tags); const [showOpenCases, setShowOpenCases] = useState(initial.status === 'open'); - const { tags } = useGetTags(); - const { reporters, respReporters } = useGetReporters(); + const { tags, fetchTags } = useGetTags(); + const { reporters, respReporters, fetchReporters } = useGetReporters(); + const refetch = useCallback(() => { + fetchTags(); + fetchReporters(); + }, [fetchReporters, fetchTags]); + useEffect(() => { + if (setFilterRefetch != null) { + setFilterRefetch(refetch); + } + }, [refetch, setFilterRefetch]); + useEffect(() => { + if (selectedReporters.length) { + const newReporters = selectedReporters.filter(r => reporters.includes(r)); + handleSelectedReporters(newReporters); + } + }, [reporters]); + useEffect(() => { + if (selectedTags.length) { + const newTags = selectedTags.filter(t => tags.includes(t)); + handleSelectedTags(newTags); + } + }, [tags]); const handleSelectedReporters = useCallback( newReporters => { if (!isEqual(newReporters, selectedReporters)) { - setselectedReporters(newReporters); + setSelectedReporters(newReporters); const reportersObj = respReporters.filter( r => newReporters.includes(r.username) || newReporters.includes(r.full_name) ); @@ -97,6 +120,7 @@ const CasesTableFiltersComponent = ({ <EuiFlexItem grow={true}> <EuiFieldSearch aria-label={i18n.SEARCH_CASES} + data-test-subj="search-cases" fullWidth incremental={false} placeholder={i18n.SEARCH_PLACEHOLDER} @@ -107,6 +131,7 @@ const CasesTableFiltersComponent = ({ <EuiFlexItem grow={false}> <EuiFilterGroup> <EuiFilterButton + data-test-subj="open-case-count" withNext hasActiveFilters={showOpenCases} onClick={handleToggleFilter.bind(null, true)} @@ -115,6 +140,7 @@ const CasesTableFiltersComponent = ({ {countOpenCases != null ? ` (${countOpenCases})` : ''} </EuiFilterButton> <EuiFilterButton + data-test-subj="closed-case-count" hasActiveFilters={!showOpenCases} onClick={handleToggleFilter.bind(null, false)} > diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts index 1bee96bc23fff..d3dcfa50ecfa5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts @@ -46,6 +46,10 @@ export const BULK_ACTIONS = i18n.translate('xpack.siem.case.caseTable.bulkAction defaultMessage: 'Bulk actions', }); +export const SERVICENOW_INCIDENT = i18n.translate('xpack.siem.case.caseTable.snIncident', { + defaultMessage: 'ServiceNow Incident', +}); + export const SEARCH_PLACEHOLDER = i18n.translate('xpack.siem.case.caseTable.searchPlaceholder', { defaultMessage: 'e.g. case name', }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/callout/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/callout/helpers.tsx index 929e8640dceb6..3237104274473 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/callout/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/callout/helpers.tsx @@ -6,7 +6,7 @@ import * as i18n from './translations'; -export const getSavedObjectReadOnly = () => ({ +export const savedObjectReadOnly = { title: i18n.READ_ONLY_SAVED_OBJECT_TITLE, description: i18n.READ_ONLY_SAVED_OBJECT_MSG, -}); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/callout/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/callout/index.test.tsx new file mode 100644 index 0000000000000..126ea13e96af6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/callout/index.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { CaseCallOut } from './'; + +const defaultProps = { + title: 'hey title', +}; + +describe('CaseCallOut ', () => { + it('Renders single message callout', () => { + const props = { + ...defaultProps, + message: 'we have one message', + }; + const wrapper = mount(<CaseCallOut {...props} />); + expect( + wrapper + .find(`[data-test-subj="callout-message"]`) + .last() + .exists() + ).toBeTruthy(); + expect( + wrapper + .find(`[data-test-subj="callout-messages"]`) + .last() + .exists() + ).toBeFalsy(); + }); + it('Renders multi message callout', () => { + const props = { + ...defaultProps, + messages: [ + { ...defaultProps, description: <p>{'we have two messages'}</p> }, + { ...defaultProps, description: <p>{'for real'}</p> }, + ], + }; + const wrapper = mount(<CaseCallOut {...props} />); + expect( + wrapper + .find(`[data-test-subj="callout-message"]`) + .last() + .exists() + ).toBeFalsy(); + expect( + wrapper + .find(`[data-test-subj="callout-messages"]`) + .last() + .exists() + ).toBeTruthy(); + }); + it('Dismisses callout', () => { + const props = { + ...defaultProps, + message: 'we have one message', + }; + const wrapper = mount(<CaseCallOut {...props} />); + expect(wrapper.find(`[data-test-subj="case-call-out"]`).exists()).toBeTruthy(); + wrapper + .find(`[data-test-subj="callout-dismiss"]`) + .last() + .simulate('click'); + expect(wrapper.find(`[data-test-subj="case-call-out"]`).exists()).toBeFalsy(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/callout/index.tsx index 30a95db2d82a5..0fc93af7f318d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/callout/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/callout/index.tsx @@ -24,10 +24,12 @@ const CaseCallOutComponent = ({ title, message, messages }: CaseCallOutProps) => return showCallOut ? ( <> - <EuiCallOut title={title} color="primary" iconType="gear"> - {!isEmpty(messages) && <EuiDescriptionList listItems={messages} />} - {!isEmpty(message) && <p>{message}</p>} - <EuiButton color="primary" onClick={handleCallOut}> + <EuiCallOut title={title} color="primary" iconType="gear" data-test-subj="case-call-out"> + {!isEmpty(messages) && ( + <EuiDescriptionList data-test-subj="callout-messages" listItems={messages} /> + )} + {!isEmpty(message) && <p data-test-subj="callout-message">{message}</p>} + <EuiButton data-test-subj="callout-dismiss" color="primary" onClick={handleCallOut}> {i18n.DISMISS_CALLOUT} </EuiButton> </EuiCallOut> diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx index 2b16dfa150d61..718eb95767f2e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx @@ -84,7 +84,7 @@ const CaseStatusComp: React.FC<CaseStatusProps> = ({ <EuiFlexItem grow={false}> <EuiFlexGroup gutterSize="l" alignItems="center"> <EuiFlexItem> - <EuiButtonEmpty iconType="refresh" onClick={onRefresh}> + <EuiButtonEmpty data-test-subj="case-refresh" iconType="refresh" onClick={onRefresh}> {i18n.CASE_REFRESH} </EuiButtonEmpty> </EuiFlexItem> diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx deleted file mode 100644 index 0e57326707e97..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ /dev/null @@ -1,93 +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 { CaseProps } from '../index'; -import { Case } from '../../../../../containers/case/types'; - -const updateCase = jest.fn(); -const fetchCase = jest.fn(); - -export const caseProps: CaseProps = { - caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', - userCanCrud: true, - caseData: { - closedAt: null, - closedBy: null, - id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', - comments: [ - { - comment: 'Solve this fast!', - id: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', - createdAt: '2020-02-20T23:06:33.798Z', - createdBy: { - fullName: 'Steph Milovic', - username: 'smilovic', - email: 'notmyrealemailfool@elastic.co', - }, - pushedAt: null, - pushedBy: null, - updatedAt: '2020-02-20T23:06:33.798Z', - updatedBy: { - username: 'elastic', - }, - version: 'WzQ3LDFd', - }, - ], - createdAt: '2020-02-13T19:44:23.627Z', - createdBy: { fullName: null, email: 'testemail@elastic.co', username: 'elastic' }, - description: 'Security banana Issue', - externalService: null, - status: 'open', - tags: ['defacement'], - title: 'Another horrible breach!!', - totalComment: 1, - updatedAt: '2020-02-19T15:02:57.995Z', - updatedBy: { - username: 'elastic', - }, - version: 'WzQ3LDFd', - }, - fetchCase, - updateCase, -}; - -export const caseClosedProps: CaseProps = { - ...caseProps, - caseData: { - ...caseProps.caseData, - closedAt: '2020-02-20T23:06:33.798Z', - closedBy: { - username: 'elastic', - }, - status: 'closed', - }, -}; - -export const data: Case = { - ...caseProps.caseData, -}; - -export const dataClosed: Case = { - ...caseClosedProps.caseData, -}; - -export const caseUserActions = [ - { - actionField: ['comment'], - action: 'create', - actionAt: '2020-03-20T17:10:09.814Z', - actionBy: { - fullName: 'Steph Milovic', - username: 'smilovic', - email: 'notmyrealemailfool@elastic.co', - }, - newValue: 'Solve this fast!', - oldValue: null, - actionId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', - caseId: '9b833a50-6acd-11ea-8fad-af86b1071bd9', - commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', - }, -]; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx index 49f5f44cba271..8b6ee76dd783d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx @@ -9,7 +9,7 @@ import { mount } from 'enzyme'; import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; import { TestProviders } from '../../../../mock'; -import { data } from './__mock__'; +import { basicCase } from '../../../../containers/case/mock'; import { CaseViewActions } from './actions'; jest.mock('../../../../containers/case/use_delete_cases'); const useDeleteCasesMock = useDeleteCases as jest.Mock; @@ -34,7 +34,7 @@ describe('CaseView actions', () => { it('clicking trash toggles modal', () => { const wrapper = mount( <TestProviders> - <CaseViewActions caseData={data} /> + <CaseViewActions caseData={basicCase} /> </TestProviders> ); @@ -54,12 +54,14 @@ describe('CaseView actions', () => { })); const wrapper = mount( <TestProviders> - <CaseViewActions caseData={data} /> + <CaseViewActions caseData={basicCase} /> </TestProviders> ); expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy(); wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click'); - expect(handleOnDeleteConfirm.mock.calls[0][0]).toEqual([{ id: data.id, title: data.title }]); + expect(handleOnDeleteConfirm.mock.calls[0][0]).toEqual([ + { id: basicCase.id, title: basicCase.title }, + ]); }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx index 0b08b866df964..216180eb2cf0a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx @@ -40,7 +40,6 @@ const CaseViewActionsComponent: React.FC<CaseViewActions> = ({ caseData, disable ), [isDisplayConfirmDeleteModal, caseData] ); - // TO DO refactor each of these const's into their own components const propertyActions = useMemo( () => [ { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index 3f5b3a3127177..7ce9d7b8533e4 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -5,56 +5,51 @@ */ import React from 'react'; -import { Router } from 'react-router-dom'; import { mount } from 'enzyme'; -/* eslint-disable @kbn/eslint/module_migration */ -import routeData from 'react-router'; -/* eslint-enable @kbn/eslint/module_migration */ -import { CaseComponent } from './'; -import { caseProps, caseClosedProps, data, dataClosed, caseUserActions } from './__mock__'; + +import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; +import { CaseComponent, CaseProps, CaseView } from './'; +import { basicCase, basicCaseClosed, caseUserActions } from '../../../../containers/case/mock'; import { TestProviders } from '../../../../mock'; import { useUpdateCase } from '../../../../containers/case/use_update_case'; +import { useGetCase } from '../../../../containers/case/use_get_case'; import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; import { wait } from '../../../../lib/helpers'; import { usePushToService } from '../use_push_to_service'; jest.mock('../../../../containers/case/use_update_case'); jest.mock('../../../../containers/case/use_get_case_user_actions'); +jest.mock('../../../../containers/case/use_get_case'); jest.mock('../use_push_to_service'); const useUpdateCaseMock = useUpdateCase as jest.Mock; const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock; const usePushToServiceMock = usePushToService as jest.Mock; -type Action = 'PUSH' | 'POP' | 'REPLACE'; -const pop: Action = 'POP'; -const location = { - pathname: '/network', - search: '', - state: '', - hash: '', -}; -const mockHistory = { - length: 2, - location, - action: pop, - push: jest.fn(), - replace: jest.fn(), - go: jest.fn(), - goBack: jest.fn(), - goForward: jest.fn(), - block: jest.fn(), - createHref: jest.fn(), - listen: jest.fn(), + +export const caseProps: CaseProps = { + caseId: basicCase.id, + userCanCrud: true, + caseData: basicCase, + fetchCase: jest.fn(), + updateCase: jest.fn(), }; -const mockLocation = { - pathname: '/welcome', - hash: '', - search: '', - state: '', +export const caseClosedProps: CaseProps = { + ...caseProps, + caseData: basicCaseClosed, }; describe('CaseView ', () => { const updateCaseProperty = jest.fn(); const fetchCaseUserActions = jest.fn(); + const fetchCase = jest.fn(); + const updateCase = jest.fn(); + const data = caseProps.caseData; + const defaultGetCase = { + isLoading: false, + isError: false, + data, + updateCase, + fetchCase, + }; /* eslint-disable no-console */ // Silence until enzyme fixed to use ReactTestUtils.act() const originalError = console.error; @@ -84,17 +79,23 @@ describe('CaseView ', () => { participants: [data.createdBy], }; - const defaultUsePushToServiceMock = { - pushButton: <>{'Hello Button'}</>, - pushCallouts: null, - }; - beforeEach(() => { jest.resetAllMocks(); useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState); jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions); - usePushToServiceMock.mockImplementation(() => defaultUsePushToServiceMock); + usePushToServiceMock.mockImplementation(({ updateCase: updateCaseMockCall }) => ({ + pushButton: ( + <button + data-test-subj="mock-button" + onClick={() => updateCaseMockCall(caseProps.caseData)} + type="button" + > + {'Hello Button'} + </button> + ), + pushCallouts: null, + })); }); it('should render CaseComponent', async () => { @@ -120,7 +121,7 @@ describe('CaseView ', () => { ).toEqual(data.status); expect( wrapper - .find(`[data-test-subj="case-view-tag-list"] .euiBadge__text`) + .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="case-tag"]`) .first() .text() ).toEqual(data.tags[0]); @@ -139,7 +140,7 @@ describe('CaseView ', () => { ).toEqual(data.createdAt); expect( wrapper - .find(`[data-test-subj="case-view-description"]`) + .find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]`) .first() .prop('raw') ).toEqual(data.description); @@ -148,7 +149,7 @@ describe('CaseView ', () => { it('should show closed indicators in header when case is closed', async () => { useUpdateCaseMock.mockImplementation(() => ({ ...defaultUpdateCaseState, - caseData: dataClosed, + caseData: basicCaseClosed, })); const wrapper = mount( <TestProviders> @@ -164,13 +165,13 @@ describe('CaseView ', () => { .find(`[data-test-subj="case-view-closedAt"]`) .first() .prop('value') - ).toEqual(dataClosed.closedAt); + ).toEqual(basicCaseClosed.closedAt); expect( wrapper .find(`[data-test-subj="case-view-status"]`) .first() .text() - ).toEqual(dataClosed.status); + ).toEqual(basicCaseClosed.status); }); it('should dispatch update state when button is toggled', async () => { @@ -188,7 +189,12 @@ describe('CaseView ', () => { expect(updateCaseProperty).toHaveBeenCalled(); }); - it('should render comments', async () => { + it('should display EditableTitle isLoading', () => { + useUpdateCaseMock.mockImplementation(() => ({ + ...defaultUpdateCaseState, + isLoading: true, + updateKey: 'title', + })); const wrapper = mount( <TestProviders> <Router history={mockHistory}> @@ -196,32 +202,230 @@ describe('CaseView ', () => { </Router> </TestProviders> ); - await wait(); expect( wrapper - .find( - `div[data-test-subj="user-action-${data.comments[0].id}-avatar"] [data-test-subj="user-action-avatar"]` - ) + .find('[data-test-subj="editable-title-loading"]') + .first() + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="editable-title-edit-icon"]') .first() - .prop('name') - ).toEqual(data.comments[0].createdBy.fullName); + .exists() + ).toBeFalsy(); + }); + it('should display Toggle Status isLoading', () => { + useUpdateCaseMock.mockImplementation(() => ({ + ...defaultUpdateCaseState, + isLoading: true, + updateKey: 'status', + })); + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <CaseComponent {...caseProps} /> + </Router> + </TestProviders> + ); expect( wrapper - .find( - `div[data-test-subj="user-action-${data.comments[0].id}"] [data-test-subj="user-action-title"] strong` - ) + .find('[data-test-subj="toggle-case-status"]') .first() - .text() - ).toEqual(data.comments[0].createdBy.username); + .prop('isLoading') + ).toBeTruthy(); + }); + + it('should display description isLoading', () => { + useUpdateCaseMock.mockImplementation(() => ({ + ...defaultUpdateCaseState, + isLoading: true, + updateKey: 'description', + })); + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <CaseComponent {...caseProps} /> + </Router> + </TestProviders> + ); + expect( + wrapper + .find('[data-test-subj="description-action"] [data-test-subj="user-action-title-loading"]') + .first() + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="description-action"] [data-test-subj="property-actions"]') + .first() + .exists() + ).toBeFalsy(); + }); + it('should display tags isLoading', () => { + useUpdateCaseMock.mockImplementation(() => ({ + ...defaultUpdateCaseState, + isLoading: true, + updateKey: 'tags', + })); + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <CaseComponent {...caseProps} /> + </Router> + </TestProviders> + ); expect( wrapper - .find( - `div[data-test-subj="user-action-${data.comments[0].id}"] [data-test-subj="markdown"]` - ) + .find('[data-test-subj="case-view-tag-list"] [data-test-subj="tag-list-loading"]') .first() - .prop('source') - ).toEqual(data.comments[0].comment); + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="tag-list-edit"]') + .first() + .exists() + ).toBeFalsy(); + }); + + it('should update title', () => { + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <CaseComponent {...caseProps} /> + </Router> + </TestProviders> + ); + const newTitle = 'The new title'; + wrapper + .find(`[data-test-subj="editable-title-edit-icon"]`) + .first() + .simulate('click'); + wrapper.update(); + wrapper + .find(`[data-test-subj="editable-title-input-field"]`) + .last() + .simulate('change', { target: { value: newTitle } }); + + wrapper.update(); + wrapper + .find(`[data-test-subj="editable-title-submit-btn"]`) + .first() + .simulate('click'); + + wrapper.update(); + const updateObject = updateCaseProperty.mock.calls[0][0]; + expect(updateObject.updateKey).toEqual('title'); + expect(updateObject.updateValue).toEqual(newTitle); + }); + + it('should push updates on button click', async () => { + useGetCaseUserActionsMock.mockImplementation(() => ({ + ...defaultUseGetCaseUserActions, + hasDataToPush: true, + })); + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <CaseComponent {...{ ...caseProps, updateCase }} /> + </Router> + </TestProviders> + ); + expect( + wrapper + .find('[data-test-subj="has-data-to-push-button"]') + .first() + .exists() + ).toBeTruthy(); + wrapper + .find('[data-test-subj="mock-button"]') + .first() + .simulate('click'); + wrapper.update(); + await wait(); + expect(updateCase).toBeCalledWith(caseProps.caseData); + expect(fetchCaseUserActions).toBeCalledWith(caseProps.caseData.id); + }); + + it('should return null if error', () => { + (useGetCase as jest.Mock).mockImplementation(() => ({ + ...defaultGetCase, + isError: true, + })); + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <CaseView + {...{ + caseId: '1234', + userCanCrud: true, + }} + /> + </Router> + </TestProviders> + ); + expect(wrapper).toEqual({}); + }); + + it('should return spinner if loading', () => { + (useGetCase as jest.Mock).mockImplementation(() => ({ + ...defaultGetCase, + isLoading: true, + })); + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <CaseView + {...{ + caseId: '1234', + userCanCrud: true, + }} + /> + </Router> + </TestProviders> + ); + expect(wrapper.find('[data-test-subj="case-view-loading"]').exists()).toBeTruthy(); + }); + + it('should return case view when data is there', () => { + (useGetCase as jest.Mock).mockImplementation(() => defaultGetCase); + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <CaseView + {...{ + caseId: '1234', + userCanCrud: true, + }} + /> + </Router> + </TestProviders> + ); + expect(wrapper.find('[data-test-subj="case-view-title"]').exists()).toBeTruthy(); + }); + + it('should refresh data on refresh', () => { + (useGetCase as jest.Mock).mockImplementation(() => defaultGetCase); + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <CaseView + {...{ + caseId: '1234', + userCanCrud: true, + }} + /> + </Router> + </TestProviders> + ); + wrapper + .find('[data-test-subj="case-refresh"]') + .first() + .simulate('click'); + expect(fetchCaseUserActions).toBeCalledWith(caseProps.caseData.id); + expect(fetchCase).toBeCalled(); }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 947da51365d66..3cf0405f40637 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -271,7 +271,11 @@ export const CaseComponent = React.memo<CaseProps>( onChange={toggleStatusCase} /> </EuiFlexItem> - {hasDataToPush && <EuiFlexItem grow={false}>{pushButton}</EuiFlexItem>} + {hasDataToPush && ( + <EuiFlexItem data-test-subj="has-data-to-push-button" grow={false}> + {pushButton} + </EuiFlexItem> + )} </EuiFlexGroup> </> )} @@ -316,7 +320,7 @@ export const CaseView = React.memo(({ caseId, userCanCrud }: Props) => { return ( <MyEuiFlexGroup justifyContent="center" alignItems="center"> <EuiFlexItem grow={false}> - <EuiLoadingSpinner size="xl" /> + <EuiLoadingSpinner data-test-subj="case-view-loading" size="xl" /> </EuiFlexItem> </MyEuiFlexGroup> ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index 17132b9610754..70b8035db5c16 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -118,3 +118,6 @@ export const EMAIL_BODY = (caseUrl: string) => values: { caseUrl }, defaultMessage: 'Case reference: {caseUrl}', }); +export const UNKNOWN = i18n.translate('xpack.siem.case.caseView.unknown', { + defaultMessage: 'Unknown', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.test.tsx new file mode 100644 index 0000000000000..d480744fc932a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.test.tsx @@ -0,0 +1,121 @@ +/* + * 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 { mount } from 'enzyme'; + +import { Create } from './'; +import { TestProviders } from '../../../../mock'; +import { getFormMock } from '../__mock__/form'; +import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; + +import { useInsertTimeline } from '../../../../components/timeline/insert_timeline_popover/use_insert_timeline'; +import { usePostCase } from '../../../../containers/case/use_post_case'; +jest.mock('../../../../components/timeline/insert_timeline_popover/use_insert_timeline'); +jest.mock('../../../../containers/case/use_post_case'); +import { useForm } from '../../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks'; +import { wait } from '../../../../lib/helpers'; +import { SiemPageName } from '../../../home/types'; +jest.mock( + '../../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form' +); + +export const useFormMock = useForm as jest.Mock; + +const useInsertTimelineMock = useInsertTimeline as jest.Mock; +const usePostCaseMock = usePostCase as jest.Mock; + +const postCase = jest.fn(); +const handleCursorChange = jest.fn(); +const handleOnTimelineChange = jest.fn(); + +const defaultInsertTimeline = { + cursorPosition: { + start: 0, + end: 0, + }, + handleCursorChange, + handleOnTimelineChange, +}; +const sampleData = { + description: 'what a great description', + tags: ['coke', 'pepsi'], + title: 'what a cool title', +}; +const defaultPostCase = { + isLoading: false, + isError: false, + caseData: null, + postCase, +}; +describe('Create case', () => { + const formHookMock = getFormMock(sampleData); + + beforeEach(() => { + jest.resetAllMocks(); + useInsertTimelineMock.mockImplementation(() => defaultInsertTimeline); + usePostCaseMock.mockImplementation(() => defaultPostCase); + useFormMock.mockImplementation(() => ({ form: formHookMock })); + jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); + }); + + it('should post case on submit click', async () => { + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <Create /> + </Router> + </TestProviders> + ); + wrapper + .find(`[data-test-subj="create-case-submit"]`) + .first() + .simulate('click'); + await wait(); + expect(postCase).toBeCalledWith(sampleData); + }); + + it('should redirect to all cases on cancel click', () => { + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <Create /> + </Router> + </TestProviders> + ); + wrapper + .find(`[data-test-subj="create-case-cancel"]`) + .first() + .simulate('click'); + expect(mockHistory.replace.mock.calls[0][0].pathname).toEqual(`/${SiemPageName.case}`); + }); + it('should redirect to new case when caseData is there', () => { + const sampleId = '777777'; + usePostCaseMock.mockImplementation(() => ({ ...defaultPostCase, caseData: { id: sampleId } })); + mount( + <TestProviders> + <Router history={mockHistory}> + <Create /> + </Router> + </TestProviders> + ); + expect(mockHistory.replace.mock.calls[0][0].pathname).toEqual( + `/${SiemPageName.case}/${sampleId}` + ); + }); + + it('should render spinner when loading', () => { + usePostCaseMock.mockImplementation(() => ({ ...defaultPostCase, isLoading: true })); + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <Create /> + </Router> + </TestProviders> + ); + expect(wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx index 740909db408ec..53b792bb9b5eb 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx @@ -73,7 +73,7 @@ export const Create = React.memo(() => { const handleSetIsCancel = useCallback(() => { setIsCancel(true); - }, [isCancel]); + }, []); if (caseData != null && caseData.id) { return <Redirect to={`/${SiemPageName.case}/${caseData.id}`} />; @@ -85,7 +85,7 @@ export const Create = React.memo(() => { return ( <EuiPanel> - {isLoading && <MySpinner size="xl" />} + {isLoading && <MySpinner data-test-subj="create-case-loading-spinner" size="xl" />} <Form form={form}> <CommonUseField path="title" @@ -107,7 +107,7 @@ export const Create = React.memo(() => { euiFieldProps: { fullWidth: true, placeholder: '', - isDisabled: isLoading, + disabled: isLoading, }, }} /> @@ -151,6 +151,7 @@ export const Create = React.memo(() => { </EuiFlexItem> <EuiFlexItem grow={false}> <EuiButton + data-test-subj="create-case-submit" fill iconType="plusInCircle" isDisabled={isLoading} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.test.tsx new file mode 100644 index 0000000000000..8ad2f8f8cb737 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.test.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { TagList } from './'; +import { getFormMock } from '../__mock__/form'; +import { TestProviders } from '../../../../mock'; +import { wait } from '../../../../lib/helpers'; +import { useForm } from '../../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks'; +import { act } from 'react-dom/test-utils'; + +jest.mock( + '../../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form' +); +const onSubmit = jest.fn(); +const defaultProps = { + disabled: false, + isLoading: false, + onSubmit, + tags: [], +}; + +describe('TagList ', () => { + const sampleTags = ['coke', 'pepsi']; + const formHookMock = getFormMock({ tags: sampleTags }); + beforeEach(() => { + jest.resetAllMocks(); + (useForm as jest.Mock).mockImplementation(() => ({ form: formHookMock })); + }); + it('Renders no tags, and then edit', () => { + const wrapper = mount( + <TestProviders> + <TagList {...defaultProps} /> + </TestProviders> + ); + expect( + wrapper + .find(`[data-test-subj="no-tags"]`) + .last() + .exists() + ).toBeTruthy(); + wrapper + .find(`[data-test-subj="tag-list-edit-button"]`) + .last() + .simulate('click'); + expect( + wrapper + .find(`[data-test-subj="no-tags"]`) + .last() + .exists() + ).toBeFalsy(); + expect( + wrapper + .find(`[data-test-subj="edit-tags"]`) + .last() + .exists() + ).toBeTruthy(); + }); + it('Edit tag on submit', async () => { + const wrapper = mount( + <TestProviders> + <TagList {...defaultProps} /> + </TestProviders> + ); + wrapper + .find(`[data-test-subj="tag-list-edit-button"]`) + .last() + .simulate('click'); + await act(async () => { + wrapper + .find(`[data-test-subj="edit-tags-submit"]`) + .last() + .simulate('click'); + await wait(); + expect(onSubmit).toBeCalledWith(sampleTags); + }); + }); + it('Cancels on cancel', async () => { + const props = { + ...defaultProps, + tags: ['pepsi'], + }; + const wrapper = mount( + <TestProviders> + <TagList {...props} /> + </TestProviders> + ); + expect( + wrapper + .find(`[data-test-subj="case-tag"]`) + .last() + .exists() + ).toBeTruthy(); + wrapper + .find(`[data-test-subj="tag-list-edit-button"]`) + .last() + .simulate('click'); + await act(async () => { + expect( + wrapper + .find(`[data-test-subj="case-tag"]`) + .last() + .exists() + ).toBeFalsy(); + wrapper + .find(`[data-test-subj="edit-tags-cancel"]`) + .last() + .simulate('click'); + await wait(); + wrapper.update(); + expect( + wrapper + .find(`[data-test-subj="case-tag"]`) + .last() + .exists() + ).toBeTruthy(); + }); + }); + it('Renders disabled button', () => { + const props = { ...defaultProps, disabled: true }; + const wrapper = mount( + <TestProviders> + <TagList {...props} /> + </TestProviders> + ); + expect( + wrapper + .find(`[data-test-subj="tag-list-edit-button"]`) + .last() + .prop('disabled') + ).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.tsx index f7d890ca60b16..9bac000b93235 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.tsx @@ -61,10 +61,11 @@ export const TagList = React.memo( <EuiFlexItem grow={false}> <h4>{i18n.TAGS}</h4> </EuiFlexItem> - {isLoading && <EuiLoadingSpinner />} + {isLoading && <EuiLoadingSpinner data-test-subj="tag-list-loading" />} {!isLoading && ( - <EuiFlexItem grow={false}> + <EuiFlexItem data-test-subj="tag-list-edit" grow={false}> <EuiButtonIcon + data-test-subj="tag-list-edit-button" isDisabled={disabled} aria-label={i18n.EDIT_TAGS_ARIA} iconType={'pencil'} @@ -74,17 +75,19 @@ export const TagList = React.memo( )} </EuiFlexGroup> <EuiHorizontalRule margin="xs" /> - <MyFlexGroup gutterSize="xs"> - {tags.length === 0 && !isEditTags && <p>{i18n.NO_TAGS}</p>} + <MyFlexGroup gutterSize="xs" data-test-subj="grr"> + {tags.length === 0 && !isEditTags && <p data-test-subj="no-tags">{i18n.NO_TAGS}</p>} {tags.length > 0 && !isEditTags && tags.map((tag, key) => ( <EuiFlexItem grow={false} key={`${tag}${key}`}> - <EuiBadge color="hollow">{tag}</EuiBadge> + <EuiBadge data-test-subj="case-tag" color="hollow"> + {tag} + </EuiBadge> </EuiFlexItem> ))} {isEditTags && ( - <EuiFlexGroup direction="column"> + <EuiFlexGroup data-test-subj="edit-tags" direction="column"> <EuiFlexItem> <Form form={form}> <CommonUseField @@ -105,6 +108,7 @@ export const TagList = React.memo( <EuiFlexItem grow={false}> <EuiButton color="secondary" + data-test-subj="edit-tags-submit" fill iconType="save" onClick={onSubmitTags} @@ -115,6 +119,7 @@ export const TagList = React.memo( </EuiFlexItem> <EuiFlexItem grow={false}> <EuiButtonEmpty + data-test-subj="edit-tags-cancel" iconType="cross" onClick={setIsEditTags.bind(null, false)} size="s" diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/use_push_to_service/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/use_push_to_service/index.test.tsx new file mode 100644 index 0000000000000..77215e2318ded --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/use_push_to_service/index.test.tsx @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable react/display-name */ +import React from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { usePushToService, ReturnUsePushToService, UsePushToService } from './'; +import { TestProviders } from '../../../../mock'; +import { usePostPushToService } from '../../../../containers/case/use_post_push_to_service'; +import { ClosureType } from '../../../../../../../../plugins/case/common/api/cases'; +import * as i18n from './translations'; +import { useGetActionLicense } from '../../../../containers/case/use_get_action_license'; +import { getKibanaConfigError, getLicenseError } from './helpers'; +import * as api from '../../../../containers/case/configure/api'; +jest.mock('../../../../containers/case/use_get_action_license'); +jest.mock('../../../../containers/case/use_post_push_to_service'); +jest.mock('../../../../containers/case/configure/api'); + +describe('usePushToService', () => { + const caseId = '12345'; + const updateCase = jest.fn(); + const postPushToService = jest.fn(); + const mockPostPush = { + isLoading: false, + postPushToService, + }; + const closureType: ClosureType = 'close-by-user'; + const mockConnector = { + connectorId: 'c00l', + connectorName: 'name', + }; + const mockCaseConfigure = { + ...mockConnector, + createdAt: 'string', + createdBy: {}, + closureType, + updatedAt: 'string', + updatedBy: {}, + version: 'string', + }; + const getConfigureMock = jest.spyOn(api, 'getCaseConfigure'); + const actionLicense = { + id: '.servicenow', + name: 'ServiceNow', + minimumLicenseRequired: 'platinum', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }; + beforeEach(() => { + jest.resetAllMocks(); + (usePostPushToService as jest.Mock).mockImplementation(() => mockPostPush); + (useGetActionLicense as jest.Mock).mockImplementation(() => ({ + isLoading: false, + actionLicense, + })); + getConfigureMock.mockImplementation(() => Promise.resolve(mockCaseConfigure)); + }); + it('push case button posts the push with correct args', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>( + () => + usePushToService({ + caseId, + caseStatus: 'open', + isNew: false, + updateCase, + userCanCrud: true, + }), + { + wrapper: ({ children }) => <TestProviders> {children}</TestProviders>, + } + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(getConfigureMock).toBeCalled(); + result.current.pushButton.props.children.props.onClick(); + expect(postPushToService).toBeCalledWith({ ...mockConnector, caseId, updateCase }); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + it('Displays message when user does not have premium license', async () => { + (useGetActionLicense as jest.Mock).mockImplementation(() => ({ + isLoading: false, + actionLicense: { + ...actionLicense, + enabledInLicense: false, + }, + })); + await act(async () => { + const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>( + () => + usePushToService({ + caseId, + caseStatus: 'open', + isNew: false, + updateCase, + userCanCrud: true, + }), + { + wrapper: ({ children }) => <TestProviders> {children}</TestProviders>, + } + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + const errorsMsg = result.current.pushCallouts?.props.messages; + expect(errorsMsg).toHaveLength(1); + expect(errorsMsg[0].title).toEqual(getLicenseError().title); + }); + }); + it('Displays message when user does not have case enabled in config', async () => { + (useGetActionLicense as jest.Mock).mockImplementation(() => ({ + isLoading: false, + actionLicense: { + ...actionLicense, + enabledInConfig: false, + }, + })); + await act(async () => { + const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>( + () => + usePushToService({ + caseId, + caseStatus: 'open', + isNew: false, + updateCase, + userCanCrud: true, + }), + { + wrapper: ({ children }) => <TestProviders> {children}</TestProviders>, + } + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + const errorsMsg = result.current.pushCallouts?.props.messages; + expect(errorsMsg).toHaveLength(1); + expect(errorsMsg[0].title).toEqual(getKibanaConfigError().title); + }); + }); + it('Displays message when user does not have a connector configured', async () => { + getConfigureMock.mockImplementation(() => + Promise.resolve({ + ...mockCaseConfigure, + connectorId: 'none', + }) + ); + await act(async () => { + const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>( + () => + usePushToService({ + caseId, + caseStatus: 'open', + isNew: false, + updateCase, + userCanCrud: true, + }), + { + wrapper: ({ children }) => <TestProviders> {children}</TestProviders>, + } + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + const errorsMsg = result.current.pushCallouts?.props.messages; + expect(errorsMsg).toHaveLength(1); + expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE); + }); + }); + it('Displays message when case is closed', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>( + () => + usePushToService({ + caseId, + caseStatus: 'closed', + isNew: false, + updateCase, + userCanCrud: true, + }), + { + wrapper: ({ children }) => <TestProviders> {children}</TestProviders>, + } + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + const errorsMsg = result.current.pushCallouts?.props.messages; + expect(errorsMsg).toHaveLength(1); + expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx index 4f370ec978906..5092cba6872e3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx @@ -19,7 +19,7 @@ import { CaseCallOut } from '../callout'; import { getLicenseError, getKibanaConfigError } from './helpers'; import * as i18n from './translations'; -interface UsePushToService { +export interface UsePushToService { caseId: string; caseStatus: string; isNew: boolean; @@ -32,7 +32,7 @@ interface Connector { connectorName: string; } -interface ReturnUsePushToService { +export interface ReturnUsePushToService { pushButton: JSX.Element; pushCallouts: JSX.Element | null; } @@ -122,6 +122,7 @@ export const usePushToService = ({ const pushToServiceButton = useMemo( () => ( <EuiButton + data-test-subj="push-to-service-now" fill iconType="importAction" onClick={handlePushToService} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.test.tsx new file mode 100644 index 0000000000000..e34981286bc81 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.test.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { getUserAction } from '../../../../containers/case/mock'; +import { getLabelTitle } from './helpers'; +import * as i18n from '../case_view/translations'; +import { mount } from 'enzyme'; + +describe('User action tree helpers', () => { + it('label title generated for update tags', () => { + const action = getUserAction(['title'], 'update'); + const result: string | JSX.Element = getLabelTitle({ + action, + field: 'tags', + firstIndexPushToService: 0, + index: 0, + }); + + const wrapper = mount(<>{result}</>); + expect( + wrapper + .find(`[data-test-subj="ua-tags-label"]`) + .first() + .text() + ).toEqual(` ${i18n.TAGS.toLowerCase()}`); + + expect( + wrapper + .find(`[data-test-subj="ua-tag"]`) + .first() + .text() + ).toEqual(action.newValue); + }); + it('label title generated for update title', () => { + const action = getUserAction(['title'], 'update'); + const result: string | JSX.Element = getLabelTitle({ + action, + field: 'title', + firstIndexPushToService: 0, + index: 0, + }); + + expect(result).toEqual( + `${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${ + action.newValue + }"` + ); + }); + it('label title generated for update description', () => { + const action = getUserAction(['description'], 'update'); + const result: string | JSX.Element = getLabelTitle({ + action, + field: 'description', + firstIndexPushToService: 0, + index: 0, + }); + + expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`); + }); + it('label title generated for update status to open', () => { + const action = { ...getUserAction(['status'], 'update'), newValue: 'open' }; + const result: string | JSX.Element = getLabelTitle({ + action, + field: 'status', + firstIndexPushToService: 0, + index: 0, + }); + + expect(result).toEqual(`${i18n.REOPENED_CASE.toLowerCase()} ${i18n.CASE}`); + }); + it('label title generated for update status to closed', () => { + const action = { ...getUserAction(['status'], 'update'), newValue: 'closed' }; + const result: string | JSX.Element = getLabelTitle({ + action, + field: 'status', + firstIndexPushToService: 0, + index: 0, + }); + + expect(result).toEqual(`${i18n.CLOSED_CASE.toLowerCase()} ${i18n.CASE}`); + }); + it('label title generated for update comment', () => { + const action = getUserAction(['comment'], 'update'); + const result: string | JSX.Element = getLabelTitle({ + action, + field: 'comment', + firstIndexPushToService: 0, + index: 0, + }); + + expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`); + }); + it('label title generated for pushed incident', () => { + const action = getUserAction(['pushed'], 'push-to-service'); + const result: string | JSX.Element = getLabelTitle({ + action, + field: 'pushed', + firstIndexPushToService: 0, + index: 0, + }); + + const wrapper = mount(<>{result}</>); + expect( + wrapper + .find(`[data-test-subj="pushed-label"]`) + .first() + .text() + ).toEqual(i18n.PUSHED_NEW_INCIDENT); + expect( + wrapper + .find(`[data-test-subj="pushed-value"]`) + .first() + .prop('href') + ).toEqual(JSON.parse(action.newValue).external_url); + }); + it('label title generated for needs update incident', () => { + const action = getUserAction(['pushed'], 'push-to-service'); + const result: string | JSX.Element = getLabelTitle({ + action, + field: 'pushed', + firstIndexPushToService: 0, + index: 1, + }); + + const wrapper = mount(<>{result}</>); + expect( + wrapper + .find(`[data-test-subj="pushed-label"]`) + .first() + .text() + ).toEqual(i18n.UPDATE_INCIDENT); + expect( + wrapper + .find(`[data-test-subj="pushed-value"]`) + .first() + .prop('href') + ).toEqual(JSON.parse(action.newValue).external_url); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx index 008f4d7048f56..d6016e540bdc0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx @@ -41,14 +41,16 @@ export const getLabelTitle = ({ action, field, firstIndexPushToService, index }: const getTagsLabelTitle = (action: CaseUserActions) => ( <EuiFlexGroup alignItems="baseline" gutterSize="xs" component="span"> - <EuiFlexItem> + <EuiFlexItem data-test-subj="ua-tags-label"> {action.action === 'add' && i18n.ADDED_FIELD} {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} </EuiFlexItem> {action.newValue != null && action.newValue.split(',').map(tag => ( <EuiFlexItem grow={false} key={tag}> - <EuiBadge color="default">{tag}</EuiBadge> + <EuiBadge data-test-subj={`ua-tag`} color="default"> + {tag} + </EuiBadge> </EuiFlexItem> ))} </EuiFlexGroup> @@ -61,12 +63,12 @@ const getPushedServiceLabelTitle = ( ) => { const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService; return ( - <EuiFlexGroup alignItems="baseline" gutterSize="xs"> - <EuiFlexItem> + <EuiFlexGroup alignItems="baseline" gutterSize="xs" data-test-subj="pushed-service-label-title"> + <EuiFlexItem data-test-subj="pushed-label"> {firstIndexPushToService === index ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} </EuiFlexItem> <EuiFlexItem grow={false}> - <EuiLink href={pushedVal?.external_url} target="_blank"> + <EuiLink data-test-subj="pushed-value" href={pushedVal?.external_url} target="_blank"> {pushedVal?.connector_name} {pushedVal?.external_title} </EuiLink> </EuiFlexItem> diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.test.tsx new file mode 100644 index 0000000000000..1c71260422d4b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.test.tsx @@ -0,0 +1,331 @@ +/* + * 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 { mount } from 'enzyme'; + +import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; +import { getFormMock } from '../__mock__/form'; +import { useUpdateComment } from '../../../../containers/case/use_update_comment'; +import { basicCase, getUserAction } from '../../../../containers/case/mock'; +import { UserActionTree } from './'; +import { TestProviders } from '../../../../mock'; +import { useFormMock } from '../create/index.test'; +import { wait } from '../../../../lib/helpers'; +import { act } from 'react-dom/test-utils'; +jest.mock( + '../../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form' +); + +const fetchUserActions = jest.fn(); +const onUpdateField = jest.fn(); +const updateCase = jest.fn(); +const defaultProps = { + data: basicCase, + caseUserActions: [], + firstIndexPushToService: -1, + isLoadingDescription: false, + isLoadingUserActions: false, + lastIndexPushToService: -1, + userCanCrud: true, + fetchUserActions, + onUpdateField, + updateCase, +}; +const useUpdateCommentMock = useUpdateComment as jest.Mock; +jest.mock('../../../../containers/case/use_update_comment'); + +const patchComment = jest.fn(); +describe('UserActionTree ', () => { + const sampleData = { + content: 'what a great comment update', + }; + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + useUpdateCommentMock.mockImplementation(() => ({ + isLoadingIds: [], + patchComment, + })); + const formHookMock = getFormMock(sampleData); + useFormMock.mockImplementation(() => ({ form: formHookMock })); + jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); + }); + + it('Loading spinner when user actions loading and displays fullName/username', () => { + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <UserActionTree {...{ ...defaultProps, isLoadingUserActions: true }} /> + </Router> + </TestProviders> + ); + expect(wrapper.find(`[data-test-subj="user-actions-loading"]`).exists()).toBeTruthy(); + + expect( + wrapper + .find(`[data-test-subj="user-action-avatar"]`) + .first() + .prop('name') + ).toEqual(defaultProps.data.createdBy.fullName); + expect( + wrapper + .find(`[data-test-subj="user-action-title"] strong`) + .first() + .text() + ).toEqual(defaultProps.data.createdBy.username); + }); + it('Renders service now update line with top and bottom when push is required', () => { + const ourActions = [ + getUserAction(['comment'], 'push-to-service'), + getUserAction(['comment'], 'update'), + ]; + const props = { + ...defaultProps, + caseUserActions: ourActions, + lastIndexPushToService: 0, + }; + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <UserActionTree {...props} /> + </Router> + </TestProviders> + ); + expect(wrapper.find(`[data-test-subj="show-top-footer"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="show-bottom-footer"]`).exists()).toBeTruthy(); + }); + it('Renders service now update line with top only when push is up to date', () => { + const ourActions = [getUserAction(['comment'], 'push-to-service')]; + const props = { + ...defaultProps, + caseUserActions: ourActions, + lastIndexPushToService: 0, + }; + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <UserActionTree {...props} /> + </Router> + </TestProviders> + ); + expect(wrapper.find(`[data-test-subj="show-top-footer"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="show-bottom-footer"]`).exists()).toBeFalsy(); + }); + + it('Outlines comment when update move to link is clicked', () => { + const ourActions = [getUserAction(['comment'], 'create'), getUserAction(['comment'], 'update')]; + const props = { + ...defaultProps, + caseUserActions: ourActions, + }; + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <UserActionTree {...props} /> + </Router> + </TestProviders> + ); + expect( + wrapper + .find(`[data-test-subj="comment-create-action"]`) + .first() + .prop('idToOutline') + ).toEqual(''); + wrapper + .find(`[data-test-subj="comment-update-action"] [data-test-subj="move-to-link"]`) + .first() + .simulate('click'); + expect( + wrapper + .find(`[data-test-subj="comment-create-action"]`) + .first() + .prop('idToOutline') + ).toEqual(ourActions[0].commentId); + }); + + it('Switches to markdown when edit is clicked and back to panel when canceled', () => { + const ourActions = [getUserAction(['comment'], 'create')]; + const props = { + ...defaultProps, + caseUserActions: ourActions, + }; + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <UserActionTree {...props} /> + </Router> + </TestProviders> + ); + expect( + wrapper + .find( + `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(false); + wrapper + .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-ellipses"]`) + .first() + .simulate('click'); + wrapper + .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-pencil"]`) + .first() + .simulate('click'); + expect( + wrapper + .find( + `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(true); + wrapper + .find( + `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-cancel-markdown"]` + ) + .first() + .simulate('click'); + expect( + wrapper + .find( + `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(false); + }); + + it('calls update comment when comment markdown is saved', async () => { + const ourActions = [getUserAction(['comment'], 'create')]; + const props = { + ...defaultProps, + caseUserActions: ourActions, + }; + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <UserActionTree {...props} /> + </Router> + </TestProviders> + ); + wrapper + .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-ellipses"]`) + .first() + .simulate('click'); + wrapper + .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-pencil"]`) + .first() + .simulate('click'); + wrapper + .find( + `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-save-markdown"]` + ) + .first() + .simulate('click'); + await act(async () => { + await wait(); + wrapper.update(); + expect( + wrapper + .find( + `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(false); + expect(patchComment).toBeCalledWith({ + commentUpdate: sampleData.content, + caseId: props.data.id, + commentId: props.data.comments[0].id, + fetchUserActions, + updateCase, + version: props.data.comments[0].version, + }); + }); + }); + + it('calls update description when description markdown is saved', async () => { + const props = defaultProps; + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <UserActionTree {...props} /> + </Router> + </TestProviders> + ); + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`) + .first() + .simulate('click'); + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-pencil"]`) + .first() + .simulate('click'); + wrapper + .find( + `[data-test-subj="user-action-description"] [data-test-subj="user-action-save-markdown"]` + ) + .first() + .simulate('click'); + await act(async () => { + await wait(); + expect( + wrapper + .find( + `[data-test-subj="user-action-${props.data.id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(false); + expect(onUpdateField).toBeCalledWith('description', sampleData.content); + }); + }); + + it('quotes', async () => { + const commentData = { + comment: '', + }; + const formHookMock = getFormMock(commentData); + const setFieldValue = jest.fn(); + useFormMock.mockImplementation(() => ({ form: { ...formHookMock, setFieldValue } })); + const props = defaultProps; + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <UserActionTree {...props} /> + </Router> + </TestProviders> + ); + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`) + .first() + .simulate('click'); + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-quote"]`) + .first() + .simulate('click'); + expect(setFieldValue).toBeCalledWith('comment', `> ${props.data.description} \n`); + }); + it('Outlines comment when url param is provided', () => { + const commentId = 'neat-comment-id'; + const ourActions = [getUserAction(['comment'], 'create')]; + const props = { + ...defaultProps, + caseUserActions: ourActions, + }; + jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId }); + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <UserActionTree {...props} /> + </Router> + </TestProviders> + ); + expect( + wrapper + .find(`[data-test-subj="comment-create-action"]`) + .first() + .prop('idToOutline') + ).toEqual(commentId); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 0892d5dcb3ee7..d1e8eb3f6306b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -60,7 +60,6 @@ export const UserActionTree = React.memo( const currentUser = useCurrentUser(); const [manageMarkdownEditIds, setManangeMardownEditIds] = useState<string[]>([]); const [insertQuote, setInsertQuote] = useState<string | null>(null); - const handleManageMarkdownEditId = useCallback( (id: string) => { if (!manageMarkdownEditIds.includes(id)) { @@ -74,7 +73,6 @@ export const UserActionTree = React.memo( const handleSaveComment = useCallback( ({ id, version }: { id: string; version: string }, content: string) => { - handleManageMarkdownEditId(id); patchComment({ caseId: caseData.id, commentId: id, @@ -135,7 +133,6 @@ export const UserActionTree = React.memo( content={caseData.description} isEditable={manageMarkdownEditIds.includes(DESCRIPTION_ID)} onSaveContent={(content: string) => { - handleManageMarkdownEditId(DESCRIPTION_ID); onUpdateField(DESCRIPTION_ID, content); }} onChangeEditable={handleManageMarkdownEditId} @@ -166,11 +163,11 @@ export const UserActionTree = React.memo( } } }, [commentId, initLoading, isLoadingUserActions, isLoadingIds]); - return ( <> <UserActionItem createdAt={caseData.createdAt} + data-test-subj="description-action" disabled={!userCanCrud} id={DESCRIPTION_ID} isEditable={manageMarkdownEditIds.includes(DESCRIPTION_ID)} @@ -182,7 +179,7 @@ export const UserActionTree = React.memo( markdown={MarkdownDescription} onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)} onQuote={handleManageQuote.bind(null, caseData.description)} - username={caseData.createdBy.username ?? 'Unknown'} + username={caseData.createdBy.username ?? i18n.UNKNOWN} /> {caseUserActions.map((action, index) => { @@ -193,6 +190,7 @@ export const UserActionTree = React.memo( <UserActionItem key={action.actionId} createdAt={comment.createdAt} + data-test-subj={`comment-create-action`} disabled={!userCanCrud} id={comment.id} idToOutline={selectedOutlineCommentId} @@ -236,6 +234,7 @@ export const UserActionTree = React.memo( <UserActionItem key={action.actionId} createdAt={action.actionAt} + data-test-subj={`${action.actionField[0]}-${action.action}-action`} disabled={!userCanCrud} id={action.actionId} isEditable={false} @@ -263,11 +262,12 @@ export const UserActionTree = React.memo( {(isLoadingUserActions || isLoadingIds.includes(NEW_ID)) && ( <MyEuiFlexGroup justifyContent="center" alignItems="center"> <EuiFlexItem grow={false}> - <EuiLoadingSpinner size="l" /> + <EuiLoadingSpinner data-test-subj="user-actions-loading" size="l" /> </EuiFlexItem> </MyEuiFlexGroup> )} <UserActionItem + data-test-subj={`add-comment`} createdAt={new Date().toISOString()} disabled={!userCanCrud} id={NEW_ID} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index bcb4edd6129a6..0acd0623f9413 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -21,6 +21,7 @@ import * as i18n from './translations'; interface UserActionItemProps { createdAt: string; + 'data-test-subj'?: string; disabled: boolean; id: string; isEditable: boolean; @@ -112,6 +113,7 @@ const PushedInfoContainer = styled.div` export const UserActionItem = ({ createdAt, disabled, + 'data-test-subj': dataTestSubj, id, idToOutline, isEditable, @@ -130,7 +132,7 @@ export const UserActionItem = ({ username, updatedAt, }: UserActionItemProps) => ( - <UserActionItemContainer gutterSize={'none'} direction="column"> + <UserActionItemContainer data-test-subj={dataTestSubj} gutterSize={'none'} direction="column"> <EuiFlexItem> <EuiFlexGroup gutterSize={'none'}> <EuiFlexItem data-test-subj={`user-action-${id}-avatar`} grow={false}> @@ -145,24 +147,25 @@ export const UserActionItem = ({ {!isEditable && ( <MyEuiPanel className="userAction__panel" + data-test-subj={`user-action-panel`} paddingSize="none" showoutline={id === idToOutline ? 'true' : 'false'} > <UserActionTitle createdAt={createdAt} disabled={disabled} + fullName={fullName} id={id} isLoading={isLoading} labelEditAction={labelEditAction} labelQuoteAction={labelQuoteAction} labelTitle={labelTitle ?? <></>} linkId={linkId} - fullName={fullName} - username={username} - updatedAt={updatedAt} onEdit={onEdit} onQuote={onQuote} outlineComment={outlineComment} + updatedAt={updatedAt} + username={username} /> {markdown} </MyEuiPanel> @@ -171,7 +174,7 @@ export const UserActionItem = ({ </EuiFlexGroup> </EuiFlexItem> {showTopFooter && ( - <PushedContainer> + <PushedContainer data-test-subj="show-top-footer"> <PushedInfoContainer> <EuiText size="xs" color="subdued"> {i18n.ALREADY_PUSHED_TO_SERVICE} @@ -179,7 +182,7 @@ export const UserActionItem = ({ </PushedInfoContainer> <EuiHorizontalRule /> {showBottomFooter && ( - <PushedInfoContainer> + <PushedInfoContainer data-test-subj="show-bottom-footer"> <EuiText size="xs" color="subdued"> {i18n.REQUIRED_UPDATE_TO_SERVICE} </EuiText> diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_markdown.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_markdown.tsx index e8503bf43375c..827fe2df120ab 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_markdown.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_markdown.tsx @@ -62,12 +62,24 @@ export const UserActionMarkdown = ({ return ( <EuiFlexGroup gutterSize="s" alignItems="center"> <EuiFlexItem grow={false}> - <EuiButtonEmpty size="s" onClick={cancelAction} iconType="cross"> + <EuiButtonEmpty + data-test-subj="user-action-cancel-markdown" + size="s" + onClick={cancelAction} + iconType="cross" + > {i18n.CANCEL} </EuiButtonEmpty> </EuiFlexItem> <EuiFlexItem grow={false}> - <EuiButton color="secondary" fill iconType="save" onClick={saveAction} size="s"> + <EuiButton + data-test-subj="user-action-save-markdown" + color="secondary" + fill + iconType="save" + onClick={saveAction} + size="s" + > {i18n.SAVE} </EuiButton> </EuiFlexItem> @@ -77,7 +89,7 @@ export const UserActionMarkdown = ({ [handleCancelAction, handleSaveAction] ); return isEditable ? ( - <Form form={form}> + <Form form={form} data-test-subj="user-action-markdown-form"> <UseField path="content" component={MarkdownEditorForm} @@ -99,7 +111,7 @@ export const UserActionMarkdown = ({ </Form> ) : ( <ContentWrapper> - <Markdown raw={content} data-test-subj="case-view-description" /> + <Markdown raw={content} data-test-subj="user-action-markdown" /> </ContentWrapper> ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.test.tsx new file mode 100644 index 0000000000000..8a1e8a80f664d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.test.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 React from 'react'; +import { mount } from 'enzyme'; +import copy from 'copy-to-clipboard'; +import { Router, routeData, mockHistory } from '../__mock__/router'; +import { caseUserActions as basicUserActions } from '../../../../containers/case/mock'; +import { UserActionTitle } from './user_action_title'; +import { TestProviders } from '../../../../mock'; + +const outlineComment = jest.fn(); +const onEdit = jest.fn(); +const onQuote = jest.fn(); + +jest.mock('copy-to-clipboard'); +const defaultProps = { + createdAt: basicUserActions[0].actionAt, + disabled: false, + fullName: basicUserActions[0].actionBy.fullName, + id: basicUserActions[0].actionId, + isLoading: false, + labelEditAction: 'labelEditAction', + labelQuoteAction: 'labelQuoteAction', + labelTitle: <>{'cool'}</>, + linkId: basicUserActions[0].commentId, + onEdit, + onQuote, + outlineComment, + updatedAt: basicUserActions[0].actionAt, + username: basicUserActions[0].actionBy.username, +}; + +describe('UserActionTitle ', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId: '123' }); + }); + + it('Calls copy when copy link is clicked', async () => { + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <UserActionTitle {...defaultProps} /> + </Router> + </TestProviders> + ); + wrapper + .find(`[data-test-subj="copy-link"]`) + .first() + .simulate('click'); + expect(copy).toBeCalledTimes(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx index 9ccf921c87602..fc2a74466dedc 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx @@ -43,7 +43,7 @@ interface UserActionTitleProps { linkId?: string | null; fullName?: string | null; updatedAt?: string | null; - username: string; + username?: string | null; onEdit?: (id: string) => void; onQuote?: (id: string) => void; outlineComment?: (id: string) => void; @@ -52,18 +52,18 @@ interface UserActionTitleProps { export const UserActionTitle = ({ createdAt, disabled, + fullName, id, isLoading, labelEditAction, labelQuoteAction, labelTitle, linkId, - fullName, - username, - updatedAt, onEdit, onQuote, outlineComment, + updatedAt, + username = i18n.UNKNOWN, }: UserActionTitleProps) => { const { detailName: caseId } = useParams(); const urlSearch = useGetUrlSearch(navTabs.case); @@ -94,10 +94,7 @@ export const UserActionTitle = ({ const handleAnchorLink = useCallback(() => { copy( - `${window.location.origin}${window.location.pathname}#${SiemPageName.case}/${caseId}/${id}${urlSearch}`, - { - debug: true, - } + `${window.location.origin}${window.location.pathname}#${SiemPageName.case}/${caseId}/${id}${urlSearch}` ); }, [caseId, id, urlSearch]); @@ -106,7 +103,6 @@ export const UserActionTitle = ({ outlineComment(linkId); } }, [linkId, outlineComment]); - return ( <EuiText size="s" className="userAction__title" data-test-subj={`user-action-title`}> <EuiFlexGroup @@ -155,6 +151,7 @@ export const UserActionTitle = ({ <EuiToolTip position="top" content={<p>{i18n.MOVE_TO_ORIGINAL_COMMENT}</p>}> <EuiButtonIcon aria-label={i18n.MOVE_TO_ORIGINAL_COMMENT} + data-test-subj={`move-to-link`} onClick={handleMoveToLink} iconType="arrowUp" /> @@ -165,6 +162,7 @@ export const UserActionTitle = ({ <EuiToolTip position="top" content={<p>{i18n.COPY_REFERENCE_LINK}</p>}> <EuiButtonIcon aria-label={i18n.COPY_REFERENCE_LINK} + data-test-subj={`copy-link`} onClick={handleAnchorLink} iconType="link" id={`${id}-permLink`} @@ -173,7 +171,7 @@ export const UserActionTitle = ({ </EuiFlexItem> {propertyActions.length > 0 && ( <EuiFlexItem grow={false}> - {isLoading && <MySpinner />} + {isLoading && <MySpinner data-test-subj="user-action-title-loading" />} {!isLoading && <PropertyActions propertyActions={propertyActions} />} </EuiFlexItem> )} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index 0d1e6d1435ca3..097b8220156e2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -131,6 +131,10 @@ export const TAGS = i18n.translate('xpack.siem.case.caseView.tags', { defaultMessage: 'Tags', }); +export const ACTIONS = i18n.translate('xpack.siem.case.allCases.actions', { + defaultMessage: 'Actions', +}); + export const NO_TAGS_AVAILABLE = i18n.translate('xpack.siem.case.allCases.noTagsAvailable', { defaultMessage: 'No tags available', }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/activity_monitor/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/activity_monitor/index.test.tsx new file mode 100644 index 0000000000000..c5a4057b64ea7 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/activity_monitor/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { ActivityMonitor } from './index'; + +describe('activity_monitor', () => { + it('renders correctly', () => { + const wrapper = shallow(<ActivityMonitor />); + + expect(wrapper.find('[title="Activity monitor"]')).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/detection_engine_header_page/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/detection_engine_header_page/index.test.tsx new file mode 100644 index 0000000000000..a2685017f86d6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/detection_engine_header_page/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { DetectionEngineHeaderPage } from './index'; + +describe('detection_engine_header_page', () => { + it('renders correctly', () => { + const wrapper = shallow(<DetectionEngineHeaderPage title="Title" />); + + expect(wrapper.find('[title="Title"]')).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_api_integration_callout/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_api_integration_callout/index.test.tsx new file mode 100644 index 0000000000000..0e2589150e858 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_api_integration_callout/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { NoApiIntegrationKeyCallOut } from './index'; + +describe('no_api_integration_callout', () => { + it('renders correctly', () => { + const wrapper = shallow(<NoApiIntegrationKeyCallOut />); + + expect(wrapper.find('EuiCallOut')).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.test.tsx new file mode 100644 index 0000000000000..2e6890e60fc61 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { NoWriteSignalsCallOut } from './index'; + +describe('no_write_signals_callout', () => { + it('renders correctly', () => { + const wrapper = shallow(<NoWriteSignalsCallOut />); + + expect(wrapper.find('EuiCallOut')).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.test.tsx new file mode 100644 index 0000000000000..b66a9fc881045 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { SignalsTableComponent } from './index'; + +describe('SignalsTableComponent', () => { + it('renders correctly', () => { + const wrapper = shallow( + <SignalsTableComponent + canUserCRUD + hasIndexWrite + from={0} + loading + signalsIndex="index" + to={1} + globalQuery={{ + query: 'query', + language: 'language', + }} + globalFilters={[]} + deletedEventIds={[]} + loadingEventIds={[]} + selectedEventIds={{}} + isSelectAllChecked={false} + clearSelected={jest.fn()} + setEventsLoading={jest.fn()} + clearEventsLoading={jest.fn()} + setEventsDeleted={jest.fn()} + clearEventsDeleted={jest.fn()} + updateTimelineIsLoading={jest.fn()} + updateTimeline={jest.fn()} + /> + ); + + expect(wrapper.find('[title="Signals"]')).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx index 6cdb2f326901e..ce8ae2054b2c7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx @@ -61,7 +61,7 @@ interface OwnProps { type SignalsTableComponentProps = OwnProps & PropsFromRedux; -const SignalsTableComponent: React.FC<SignalsTableComponentProps> = ({ +export const SignalsTableComponent: React.FC<SignalsTableComponentProps> = ({ canUserCRUD, clearEventsDeleted, clearEventsLoading, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_filter_group/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_filter_group/index.test.tsx new file mode 100644 index 0000000000000..dd30bb1b0a74d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_filter_group/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { SignalsTableFilterGroup } from './index'; + +describe('SignalsTableFilterGroup', () => { + it('renders correctly', () => { + const wrapper = shallow(<SignalsTableFilterGroup onFilterGroupChanged={jest.fn()} />); + + expect(wrapper.find('EuiFilterButton')).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/batch_actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/batch_actions.tsx deleted file mode 100644 index bb45ff68cb01d..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/batch_actions.tsx +++ /dev/null @@ -1,92 +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 { EuiContextMenuItem } from '@elastic/eui'; -import React from 'react'; -import * as i18n from './translations'; -import { TimelineNonEcsData } from '../../../../../graphql/types'; -import { SendSignalsToTimeline, UpdateSignalsStatus } from '../types'; -import { FILTER_CLOSED, FILTER_OPEN } from '../signals_filter_group'; - -interface GetBatchItems { - areEventsLoading: boolean; - allEventsSelected: boolean; - selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>; - updateSignalsStatus: UpdateSignalsStatus; - sendSignalsToTimeline: SendSignalsToTimeline; - closePopover: () => void; - isFilteredToOpen: boolean; -} -/** - * Returns ViewInTimeline / UpdateSignalStatus actions to be display within an EuiContextMenuPanel - * - * @param areEventsLoading are any events loading - * @param allEventsSelected are all events on all pages selected - * @param selectedEventIds - * @param updateSignalsStatus function for updating signal status - * @param sendSignalsToTimeline function for sending signals to timeline - * @param closePopover - * @param isFilteredToOpen currently selected filter options - */ -export const getBatchItems = ({ - areEventsLoading, - allEventsSelected, - selectedEventIds, - updateSignalsStatus, - sendSignalsToTimeline, - closePopover, - isFilteredToOpen, -}: GetBatchItems) => { - const allDisabled = areEventsLoading || Object.keys(selectedEventIds).length === 0; - const sendToTimelineDisabled = allEventsSelected || uniqueRuleCount(selectedEventIds) > 1; - const filterString = isFilteredToOpen - ? i18n.BATCH_ACTION_CLOSE_SELECTED - : i18n.BATCH_ACTION_OPEN_SELECTED; - - return [ - <EuiContextMenuItem - key={i18n.BATCH_ACTION_VIEW_SELECTED_IN_TIMELINE} - icon="editorUnorderedList" - disabled={allDisabled || sendToTimelineDisabled} - onClick={async () => { - closePopover(); - sendSignalsToTimeline(); - }} - > - {i18n.BATCH_ACTION_VIEW_SELECTED_IN_TIMELINE} - </EuiContextMenuItem>, - - <EuiContextMenuItem - key={filterString} - icon={isFilteredToOpen ? 'securitySignalResolved' : 'securitySignalDetected'} - disabled={allDisabled} - onClick={async () => { - closePopover(); - await updateSignalsStatus({ - signalIds: Object.keys(selectedEventIds), - status: isFilteredToOpen ? FILTER_CLOSED : FILTER_OPEN, - }); - }} - > - {filterString} - </EuiContextMenuItem>, - ]; -}; - -/** - * Returns the number of unique rules for a given list of signals - * - * @param signals - */ -export const uniqueRuleCount = ( - signals: Readonly<Record<string, TimelineNonEcsData[]>> -): number => { - const ruleIds = Object.values(signals).flatMap( - data => data.find(d => d.field === 'signal.rule.id')?.value - ); - - return Array.from(new Set(ruleIds)).length; -}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.test.tsx new file mode 100644 index 0000000000000..6cab43b5285b5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { SignalsUtilityBar } from './index'; + +jest.mock('../../../../../lib/kibana'); + +describe('SignalsUtilityBar', () => { + it('renders correctly', () => { + const wrapper = shallow( + <SignalsUtilityBar + canUserCRUD={true} + hasIndexWrite={true} + areEventsLoading={false} + clearSelection={jest.fn()} + totalCount={100} + selectedEventIds={{}} + isFilteredToOpen={false} + selectAll={jest.fn()} + showClearSelection={true} + updateSignalsStatus={jest.fn()} + /> + ); + + expect(wrapper.find('[dataTestSubj="openCloseSignal"]')).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.test.tsx new file mode 100644 index 0000000000000..6921c49d8a8b4 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.test.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 { shallow } from 'enzyme'; + +import { SignalsHistogramPanel } from './index'; + +jest.mock('../../../../lib/kibana'); +jest.mock('../../../../components/navigation/use_get_url_search'); + +describe('SignalsHistogramPanel', () => { + it('renders correctly', () => { + const wrapper = shallow( + <SignalsHistogramPanel + from={0} + signalIndexName="signalIndexName" + setQuery={jest.fn()} + to={1} + updateDateRange={jest.fn()} + /> + ); + + expect(wrapper.find('[id="detections-histogram"]')).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram.test.tsx new file mode 100644 index 0000000000000..5eb9beaaaf76a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { SignalsHistogram } from './signals_histogram'; + +jest.mock('../../../../lib/kibana'); + +describe('SignalsHistogram', () => { + it('renders correctly', () => { + const wrapper = shallow( + <SignalsHistogram loading={false} data={[]} from={0} to={1} updateDateRange={jest.fn()} /> + ); + + expect(wrapper.find('Chart')).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/user_info/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/user_info/index.test.tsx new file mode 100644 index 0000000000000..b3d710de5e94e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/user_info/index.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useUserInfo } from './index'; + +import { usePrivilegeUser } from '../../../../containers/detection_engine/signals/use_privilege_user'; +import { useSignalIndex } from '../../../../containers/detection_engine/signals/use_signal_index'; +import { useKibana } from '../../../../lib/kibana'; +jest.mock('../../../../containers/detection_engine/signals/use_privilege_user'); +jest.mock('../../../../containers/detection_engine/signals/use_signal_index'); +jest.mock('../../../../lib/kibana'); + +describe('useUserInfo', () => { + beforeAll(() => { + (usePrivilegeUser as jest.Mock).mockReturnValue({}); + (useSignalIndex as jest.Mock).mockReturnValue({}); + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + siem: { + crud: true, + }, + }, + }, + }, + }); + }); + it('returns default state', () => { + const { result } = renderHook(() => useUserInfo()); + + expect(result).toEqual({ + current: { + canUserCRUD: null, + hasEncryptionKey: null, + hasIndexManage: null, + hasIndexWrite: null, + isAuthenticated: null, + isSignalIndexExists: null, + loading: true, + signalIndexName: null, + }, + error: undefined, + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.test.tsx new file mode 100644 index 0000000000000..779e9a4557f2a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { useParams } from 'react-router-dom'; + +import '../../mock/match_media'; +import { setAbsoluteRangeDatePicker } from '../../store/inputs/actions'; +import { DetectionEnginePageComponent } from './detection_engine'; +import { useUserInfo } from './components/user_info'; + +jest.mock('./components/user_info'); +jest.mock('../../lib/kibana'); +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + + return { + ...originalModule, + useParams: jest.fn(), + }; +}); + +describe('DetectionEnginePageComponent', () => { + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({}); + (useUserInfo as jest.Mock).mockReturnValue({}); + }); + it('renders correctly', () => { + const wrapper = shallow( + <DetectionEnginePageComponent + query={{ query: 'query', language: 'language' }} + filters={[]} + setAbsoluteRangeDatePicker={setAbsoluteRangeDatePicker} + /> + ); + + expect(wrapper.find('WithSource')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx index 1bd7ab2c4f1ae..a26d7f5672106 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx @@ -59,7 +59,7 @@ const detectionsTabs: Record<string, NavTab> = { }, }; -const DetectionEnginePageComponent: React.FC<PropsFromRedux> = ({ +export const DetectionEnginePageComponent: React.FC<PropsFromRedux> = ({ filters, query, setAbsoluteRangeDatePicker, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine_empty_page.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine_empty_page.test.tsx new file mode 100644 index 0000000000000..f64526fd2f7c4 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine_empty_page.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { DetectionEngineEmptyPage } from './detection_engine_empty_page'; +jest.mock('../../lib/kibana'); + +describe('DetectionEngineEmptyPage', () => { + it('renders correctly', () => { + const wrapper = shallow(<DetectionEngineEmptyPage />); + + expect(wrapper.find('EmptyPage')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine_no_signal_index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine_no_signal_index.test.tsx new file mode 100644 index 0000000000000..e9f05f7aafe3c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine_no_signal_index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { DetectionEngineNoIndex } from './detection_engine_no_signal_index'; +jest.mock('../../lib/kibana'); + +describe('DetectionEngineNoIndex', () => { + it('renders correctly', () => { + const wrapper = shallow(<DetectionEngineNoIndex />); + + expect(wrapper.find('EmptyPage')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine_user_unauthenticated.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine_user_unauthenticated.test.tsx new file mode 100644 index 0000000000000..e71f4de2b010b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine_user_unauthenticated.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { DetectionEngineUserUnauthenticated } from './detection_engine_user_unauthenticated'; +jest.mock('../../lib/kibana'); + +describe('DetectionEngineUserUnauthenticated', () => { + it('renders correctly', () => { + const wrapper = shallow(<DetectionEngineUserUnauthenticated />); + + expect(wrapper.find('EmptyPage')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/helpers.ts deleted file mode 100644 index 1399df0fcf6d1..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/helpers.ts +++ /dev/null @@ -1,18 +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 const sampleChartOptions = [ - { text: 'Risk scores', value: 'risk_scores' }, - { text: 'Severities', value: 'severities' }, - { text: 'Top destination IPs', value: 'destination_ips' }, - { text: 'Top event actions', value: 'event_actions' }, - { text: 'Top event categories', value: 'event_categories' }, - { text: 'Top host names', value: 'host_names' }, - { text: 'Top rule types', value: 'rule_types' }, - { text: 'Top rules', value: 'rules' }, - { text: 'Top source IPs', value: 'source_ips' }, - { text: 'Top users', value: 'users' }, -]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.test.tsx new file mode 100644 index 0000000000000..6c4980f1d1500 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import '../../mock/match_media'; +import { DetectionEngineContainer } from './index'; + +describe('DetectionEngineContainer', () => { + it('renders correctly', () => { + const wrapper = shallow(<DetectionEngineContainer url="url" />); + + expect(wrapper.find('ManageUserInfo')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.test.tsx new file mode 100644 index 0000000000000..f4955c2a93b8d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { AllRules } from './index'; + +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + + return { + ...originalModule, + useHistory: jest.fn(), + }; +}); + +describe('AllRules', () => { + it('renders correctly', () => { + const wrapper = shallow( + <AllRules + createPrePackagedRules={jest.fn()} + hasNoPermissions={false} + loading={false} + loadingCreatePrePackagedRules={false} + refetchPrePackagedRulesStatus={jest.fn()} + rulesCustomInstalled={0} + rulesInstalled={0} + rulesNotInstalled={0} + rulesNotUpdated={0} + setRefreshRulesData={jest.fn()} + /> + ); + + expect(wrapper.find('[title="All rules"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx new file mode 100644 index 0000000000000..92f69d79110d2 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { RulesTableFilters } from './rules_table_filters'; + +describe('RulesTableFilters', () => { + it('renders correctly', () => { + const wrapper = shallow( + <RulesTableFilters onFilterChanged={jest.fn()} rulesCustomInstalled={0} rulesInstalled={0} /> + ); + + expect(wrapper.find('[data-test-subj="show-elastic-rules-filter-button"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.test.tsx new file mode 100644 index 0000000000000..e31b8394e07d6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { TagsFilterPopover } from './tags_filter_popover'; + +describe('TagsFilterPopover', () => { + it('renders correctly', () => { + const wrapper = shallow( + <TagsFilterPopover + tags={[]} + selectedTags={[]} + onSelectedTagsChanged={jest.fn()} + isLoading={false} + /> + ); + + expect(wrapper.find('EuiPopover')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/accordion_title/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/accordion_title/index.test.tsx new file mode 100644 index 0000000000000..9202da3336565 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/accordion_title/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { AccordionTitle } from './index'; + +describe('AccordionTitle', () => { + it('renders correctly', () => { + const wrapper = shallow(<AccordionTitle name="name" title="title" type="valid" />); + + expect(wrapper.find('h6').text()).toContain('title'); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.test.tsx new file mode 100644 index 0000000000000..eafa89a33f596 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { AddItem } from './index'; +import { useFormFieldMock } from '../../../../../../public/mock/test_providers'; + +describe('AddItem', () => { + it('renders correctly', () => { + const Component = () => { + const field = useFormFieldMock(); + + return ( + <AddItem + addText="text" + field={field} + dataTestSubj="dataTestSubj" + idAria="idAria" + isDisabled={false} + /> + ); + }; + const wrapper = shallow(<Component />); + + expect(wrapper.dive().find('[iconType="plusInCircle"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/all_rules_tables/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/all_rules_tables/index.test.tsx new file mode 100644 index 0000000000000..3dab83bca2946 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/all_rules_tables/index.test.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 React, { useRef } from 'react'; +import { shallow } from 'enzyme'; + +import { AllRulesTables } from './index'; + +describe('AllRulesTables', () => { + it('renders correctly', () => { + const Component = () => { + const ref = useRef(); + + return ( + <AllRulesTables + euiBasicTableSelectionProps={{}} + hasNoPermissions={false} + monitoringColumns={[]} + rules={[]} + rulesColumns={[]} + rulesStatuses={[]} + tableOnChangeCallback={jest.fn()} + tableRef={ref} + pagination={{ + pageIndex: 0, + pageSize: 0, + totalItemCount: 0, + pageSizeOptions: [0], + }} + sorting={{ + sort: { + field: 'enabled', + direction: 'asc', + }, + }} + /> + ); + }; + const wrapper = shallow(<Component />); + + expect(wrapper.dive().find('[data-test-subj="rules-table"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.test.tsx new file mode 100644 index 0000000000000..c0e957d94261f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.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 React from 'react'; +import { shallow } from 'enzyme'; + +import { AnomalyThresholdSlider } from './index'; +import { useFormFieldMock } from '../../../../../mock'; + +describe('AnomalyThresholdSlider', () => { + it('renders correctly', () => { + const Component = () => { + const field = useFormFieldMock(); + + return <AnomalyThresholdSlider describedByIds={[]} field={field} />; + }; + const wrapper = shallow(<Component />); + + expect(wrapper.dive().find('EuiRange')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx index 19d1c698cbd9b..01fddf98b97d8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx @@ -16,10 +16,10 @@ interface AnomalyThresholdSliderProps { type Event = React.ChangeEvent<HTMLInputElement>; type EventArg = Event | React.MouseEvent<HTMLButtonElement>; -export const AnomalyThresholdSlider: React.FC<AnomalyThresholdSliderProps> = ({ +export const AnomalyThresholdSlider = ({ describedByIds = [], field, -}) => { +}: AnomalyThresholdSliderProps) => { const threshold = field.value as number; const onThresholdChange = useCallback( (event: EventArg) => { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.test.tsx new file mode 100644 index 0000000000000..59231c31d15bb --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { MlJobDescription, AuditIcon, JobStatusBadge } from './ml_job_description'; +jest.mock('../../../../../lib/kibana'); + +const job = { + moduleId: 'moduleId', + defaultIndexPattern: 'defaultIndexPattern', + isCompatible: true, + isInstalled: true, + isElasticJob: true, + datafeedId: 'datafeedId', + datafeedIndices: [], + datafeedState: 'datafeedState', + description: 'description', + groups: [], + hasDatafeed: true, + id: 'id', + isSingleMetricViewerJob: false, + jobState: 'jobState', + memory_status: 'memory_status', + processed_record_count: 0, +}; + +describe('MlJobDescription', () => { + it('renders correctly', () => { + const wrapper = shallow(<MlJobDescription job={job} />); + + expect(wrapper.find('[data-test-subj="machineLearningJobId"]')).toHaveLength(1); + }); +}); + +describe('AuditIcon', () => { + it('renders correctly', () => { + const wrapper = shallow(<AuditIcon message={undefined} />); + + expect(wrapper.find('EuiToolTip')).toHaveLength(0); + }); +}); + +describe('JobStatusBadge', () => { + it('renders correctly', () => { + const wrapper = shallow(<JobStatusBadge job={job} />); + + expect(wrapper.find('EuiBadge')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx index 5a9593f1a6de2..1664ea320bc1e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx @@ -20,7 +20,7 @@ enum MessageLevels { error = 'error', } -const AuditIcon: React.FC<{ +const AuditIconComponent: React.FC<{ message: SiemJob['auditMessage']; }> = ({ message }) => { if (!message) { @@ -45,7 +45,9 @@ const AuditIcon: React.FC<{ ); }; -export const JobStatusBadge: React.FC<{ job: SiemJob }> = ({ job }) => { +export const AuditIcon = React.memo(AuditIconComponent); + +const JobStatusBadgeComponent: React.FC<{ job: SiemJob }> = ({ job }) => { const isStarted = isJobStarted(job.jobState, job.datafeedState); const color = isStarted ? 'secondary' : 'danger'; const text = isStarted ? ML_JOB_STARTED : ML_JOB_STOPPED; @@ -57,6 +59,8 @@ export const JobStatusBadge: React.FC<{ job: SiemJob }> = ({ job }) => { ); }; +export const JobStatusBadge = React.memo(JobStatusBadgeComponent); + const JobLink = styled(EuiLink)` margin-right: ${({ theme }) => theme.eui.euiSizeS}; `; @@ -65,7 +69,7 @@ const Wrapper = styled.div` overflow: hidden; `; -export const MlJobDescription: React.FC<{ job: SiemJob }> = ({ job }) => { +const MlJobDescriptionComponent: React.FC<{ job: SiemJob }> = ({ job }) => { const jobUrl = useKibana().services.application.getUrlForApp( `ml#/jobs?mlManagement=(jobId:${encodeURI(job.id)})` ); @@ -83,6 +87,8 @@ export const MlJobDescription: React.FC<{ job: SiemJob }> = ({ job }) => { ); }; +export const MlJobDescription = React.memo(MlJobDescriptionComponent); + export const buildMlJobDescription = ( jobId: string, label: string, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/helpers.test.tsx new file mode 100644 index 0000000000000..dc201eb21c911 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/helpers.test.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 { isMitreAttackInvalid } from './helpers'; + +describe('isMitreAttackInvalid', () => { + it('returns true if tacticName is empty', () => { + expect(isMitreAttackInvalid('', undefined)).toBe(true); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.test.tsx new file mode 100644 index 0000000000000..3e8d542682456 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { AddMitreThreat } from './index'; +import { useFormFieldMock } from '../../../../../mock'; + +describe('AddMitreThreat', () => { + it('renders correctly', () => { + const Component = () => { + const field = useFormFieldMock(); + + return ( + <AddMitreThreat + dataTestSubj="dataTestSubj" + idAria="idAria" + isDisabled={false} + field={field} + /> + ); + }; + const wrapper = shallow(<Component />); + + expect(wrapper.dive().find('[data-test-subj="addMitre"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.test.tsx new file mode 100644 index 0000000000000..dea27d8d04536 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { MlJobSelect } from './index'; +import { useSiemJobs } from '../../../../../components/ml_popover/hooks/use_siem_jobs'; +import { useFormFieldMock } from '../../../../../mock'; +jest.mock('../../../../../components/ml_popover/hooks/use_siem_jobs'); +jest.mock('../../../../../lib/kibana'); + +describe('MlJobSelect', () => { + beforeAll(() => { + (useSiemJobs as jest.Mock).mockReturnValue([false, []]); + }); + + it('renders correctly', () => { + const Component = () => { + const field = useFormFieldMock(); + + return <MlJobSelect describedByIds={[]} field={field} />; + }; + const wrapper = shallow(<Component />); + + expect(wrapper.dive().find('[data-test-subj="mlJobSelect"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/optional_field_label/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/optional_field_label/index.test.tsx new file mode 100644 index 0000000000000..789f12f290a34 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/optional_field_label/index.test.tsx @@ -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 { shallow } from 'enzyme'; + +import { OptionalFieldLabel } from './index'; + +describe('OptionalFieldLabel', () => { + it('renders correctly', () => { + const wrapper = shallow(OptionalFieldLabel); + + expect(wrapper.find('EuiTextColor')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.test.tsx new file mode 100644 index 0000000000000..fefc9697176c4 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { PickTimeline } from './index'; +import { useFormFieldMock } from '../../../../../mock'; + +describe('PickTimeline', () => { + it('renders correctly', () => { + const Component = () => { + const field = useFormFieldMock(); + + return ( + <PickTimeline + dataTestSubj="pick-timeline" + idAria="idAria" + isDisabled={false} + field={field} + /> + ); + }; + const wrapper = shallow(<Component />); + + expect(wrapper.dive().find('[data-test-subj="pick-timeline"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pre_packaged_rules/load_empty_prompt.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pre_packaged_rules/load_empty_prompt.test.tsx new file mode 100644 index 0000000000000..8ace42fc5c3f9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pre_packaged_rules/load_empty_prompt.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 React from 'react'; +import { shallow } from 'enzyme'; + +import { PrePackagedRulesPrompt } from './load_empty_prompt'; + +describe('PrePackagedRulesPrompt', () => { + it('renders correctly', () => { + const wrapper = shallow( + <PrePackagedRulesPrompt + createPrePackagedRules={jest.fn()} + loading={false} + userHasNoPermissions={false} + /> + ); + + expect(wrapper.find('EmptyPrompt')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pre_packaged_rules/load_empty_prompt.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pre_packaged_rules/load_empty_prompt.tsx index 1cff4751e8188..5d136265ef1f2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pre_packaged_rules/load_empty_prompt.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pre_packaged_rules/load_empty_prompt.tsx @@ -15,6 +15,8 @@ const EmptyPrompt = styled(EuiEmptyPrompt)` align-self: center; /* Corrects horizontal centering in IE11 */ `; +EmptyPrompt.displayName = 'EmptyPrompt'; + interface PrePackagedRulesPromptProps { createPrePackagedRules: () => void; loading: boolean; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pre_packaged_rules/update_callout.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pre_packaged_rules/update_callout.test.tsx new file mode 100644 index 0000000000000..807da79fb7a1a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pre_packaged_rules/update_callout.test.tsx @@ -0,0 +1,36 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { UpdatePrePackagedRulesCallOut } from './update_callout'; +import { useKibana } from '../../../../../lib/kibana'; +jest.mock('../../../../../lib/kibana'); + +describe('UpdatePrePackagedRulesCallOut', () => { + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + docLinks: { + ELASTIC_WEBSITE_URL: '', + DOC_LINK_VERSION: '', + }, + }, + }); + }); + it('renders correctly', () => { + const wrapper = shallow( + <UpdatePrePackagedRulesCallOut + loading={false} + numberOfUpdatedRules={0} + updateRules={jest.fn()} + /> + ); + + expect(wrapper.find('EuiCallOut')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.test.tsx new file mode 100644 index 0000000000000..cdd06ad58bb4b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { QueryBarDefineRule } from './index'; +import { useFormFieldMock } from '../../../../../mock'; + +jest.mock('../../../../../lib/kibana'); + +describe('QueryBarDefineRule', () => { + it('renders correctly', () => { + const Component = () => { + const field = useFormFieldMock(); + + return ( + <QueryBarDefineRule + browserFields={{}} + isLoading={false} + indexPattern={{ fields: [], title: 'title' }} + onCloseTimelineSearch={jest.fn()} + openTimelineSearch={true} + dataTestSubj="query-bar-define-rule" + idAria="idAria" + field={field} + /> + ); + }; + const wrapper = shallow(<Component />); + + expect(wrapper.dive().find('[data-test-subj="query-bar-define-rule"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/index.test.tsx new file mode 100644 index 0000000000000..e761cb3323b2c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { ReadOnlyCallOut } from './index'; + +describe('ReadOnlyCallOut', () => { + it('renders correctly', () => { + const wrapper = shallow(<ReadOnlyCallOut />); + + expect(wrapper.find('EuiCallOut')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.test.tsx new file mode 100644 index 0000000000000..4cfad36b2933f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { RuleActionsField } from './index'; +import { useKibana } from '../../../../../lib/kibana'; +import { useFormFieldMock } from '../../../../../mock'; +jest.mock('../../../../../lib/kibana'); + +describe('RuleActionsField', () => { + it('should not render ActionForm is no actions are supported', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + triggers_actions_ui: { + actionTypeRegistry: {}, + }, + }, + }); + const Component = () => { + const field = useFormFieldMock(); + + return <RuleActionsField euiFieldProps={{ options: [] }} field={field} />; + }; + const wrapper = shallow(<Component />); + + expect(wrapper.dive().find('ActionForm')).toHaveLength(0); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_status/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_status/helpers.test.tsx new file mode 100644 index 0000000000000..aba30e4b7f3ca --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_status/helpers.test.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 { getStatusColor } from './helpers'; + +describe('rule_status helpers', () => { + it('getStatusColor returns subdued if null was provided', () => { + expect(getStatusColor(null)).toBe('subdued'); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_status/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_status/index.test.tsx new file mode 100644 index 0000000000000..6e230de11c4f3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_status/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { RuleStatus } from './index'; + +describe('RuleStatus', () => { + it('renders loader correctly', () => { + const wrapper = shallow(<RuleStatus ruleId="ruleId" ruleEnabled={true} />); + + expect(wrapper.dive().find('[data-test-subj="rule-status-loader"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.test.tsx new file mode 100644 index 0000000000000..3829af02ca4f1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { ScheduleItem } from './index'; +import { useFormFieldMock } from '../../../../../mock'; + +describe('ScheduleItem', () => { + it('renders correctly', () => { + const Component = () => { + const field = useFormFieldMock(); + + return ( + <ScheduleItem + dataTestSubj="schedule-item" + idAria="idAria" + isDisabled={false} + field={field} + /> + ); + }; + const wrapper = shallow(<Component />); + + expect(wrapper.dive().find('[data-test-subj="schedule-item"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.test.tsx new file mode 100644 index 0000000000000..3d832d61abb28 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { SelectRuleType } from './index'; +import { useFormFieldMock } from '../../../../../mock'; +jest.mock('../../../../../lib/kibana'); + +describe('SelectRuleType', () => { + it('renders correctly', () => { + const Component = () => { + const field = useFormFieldMock(); + + return <SelectRuleType field={field} />; + }; + const wrapper = shallow(<Component />); + + expect(wrapper.dive().find('[data-test-subj="selectRuleType"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx index 9d3b37f1788fa..6f3d299da8d45 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx @@ -19,9 +19,16 @@ import { import { isMlRule } from '../../../../../../common/detection_engine/ml_helpers'; import { RuleType } from '../../../../../../common/detection_engine/types'; import { FieldHook } from '../../../../../shared_imports'; +import { useKibana } from '../../../../../lib/kibana'; import * as i18n from './translations'; -const MlCardDescription = ({ hasValidLicense = false }: { hasValidLicense?: boolean }) => ( +const MlCardDescription = ({ + subscriptionUrl, + hasValidLicense = false, +}: { + subscriptionUrl: string; + hasValidLicense?: boolean; +}) => ( <EuiText size="s"> {hasValidLicense ? ( i18n.ML_TYPE_DESCRIPTION @@ -31,7 +38,7 @@ const MlCardDescription = ({ hasValidLicense = false }: { hasValidLicense?: bool defaultMessage="Access to ML requires a {subscriptionsLink}." values={{ subscriptionsLink: ( - <EuiLink href="https://www.elastic.co/subscriptions" target="_blank"> + <EuiLink href={subscriptionUrl} target="_blank"> <FormattedMessage id="xpack.siem.components.stepDefineRule.ruleTypeField.subscriptionsLink" defaultMessage="Platinum subscription" @@ -69,6 +76,9 @@ export const SelectRuleType: React.FC<SelectRuleTypeProps> = ({ const setMl = useCallback(() => setType('machine_learning'), [setType]); const setQuery = useCallback(() => setType('query'), [setType]); const mlCardDisabled = isReadOnly || !hasValidLicense || !isMlAdmin; + const licensingUrl = useKibana().services.application.getUrlForApp('kibana', { + path: '#/management/elasticsearch/license_management', + }); return ( <EuiFormRow @@ -95,7 +105,9 @@ export const SelectRuleType: React.FC<SelectRuleTypeProps> = ({ <EuiCard data-test-subj="machineLearningRuleType" title={i18n.ML_TYPE_TITLE} - description={<MlCardDescription hasValidLicense={hasValidLicense} />} + description={ + <MlCardDescription subscriptionUrl={licensingUrl} hasValidLicense={hasValidLicense} /> + } icon={<EuiIcon size="l" type="machineLearningApp" />} isDisabled={mlCardDisabled} selectable={{ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/severity_badge/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/severity_badge/index.test.tsx new file mode 100644 index 0000000000000..a9dddfedc2bab --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/severity_badge/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { SeverityBadge } from './index'; + +describe('SeverityBadge', () => { + it('renders correctly', () => { + const wrapper = shallow(<SeverityBadge value="low" />); + + expect(wrapper.find('EuiHealth')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/status_icon/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/status_icon/index.test.tsx new file mode 100644 index 0000000000000..89b8a56e79054 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/status_icon/index.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { TestProviders } from '../../../../../mock'; +import { RuleStatusIcon } from './index'; +jest.mock('../../../../../lib/kibana'); + +describe('RuleStatusIcon', () => { + it('renders correctly', () => { + const wrapper = shallow(<RuleStatusIcon name="name" type="active" />, { + wrappingComponent: TestProviders, + }); + + expect(wrapper.find('EuiAvatar')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.test.tsx new file mode 100644 index 0000000000000..af0547ea03261 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { StepContentWrapper } from './index'; + +describe('StepContentWrapper', () => { + it('renders correctly', () => { + const wrapper = shallow(<StepContentWrapper />); + + expect(wrapper.find('div')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.tsx index b04a321dab05b..a7343a87a7ef8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.tsx @@ -16,3 +16,5 @@ StyledDiv.defaultProps = { }; export const StepContentWrapper = React.memo(StyledDiv); + +StepContentWrapper.displayName = 'StepContentWrapper'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.test.tsx new file mode 100644 index 0000000000000..ebef6348d477e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.test.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { StepDefineRule } from './index'; + +jest.mock('../../../../../lib/kibana'); + +describe('StepDefineRule', () => { + it('renders correctly', () => { + const wrapper = shallow(<StepDefineRule isReadOnlyView={false} isLoading={false} />); + + expect(wrapper.find('Form[data-test-subj="stepDefineRule"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_panel/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_panel/index.test.tsx new file mode 100644 index 0000000000000..ce01d6995a2d8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_panel/index.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { StepPanel } from './index'; + +describe('StepPanel', () => { + it('renders correctly', () => { + const wrapper = shallow( + <StepPanel loading={false} title="Title"> + <div /> + </StepPanel> + ); + + expect(wrapper.find('MyPanel')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_panel/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_panel/index.tsx index 88cecadb8b137..1923ed09252dd 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_panel/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_panel/index.tsx @@ -20,6 +20,8 @@ const MyPanel = styled(EuiPanel)` position: relative; `; +MyPanel.displayName = 'MyPanel'; + const StepPanelComponent: React.FC<StepPanelProps> = ({ children, loading, title }) => ( <MyPanel> {loading && <EuiProgress size="xs" color="accent" position="absolute" />} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/index.test.tsx new file mode 100644 index 0000000000000..69d118ba9f28e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/index.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { StepRuleActions } from './index'; + +jest.mock('../../../../../lib/kibana'); + +describe('StepRuleActions', () => { + it('renders correctly', () => { + const wrapper = shallow( + <StepRuleActions actionMessageParams={[]} isReadOnlyView={false} isLoading={false} /> + ); + + expect(wrapper.find('[data-test-subj="stepRuleActions"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/schema.tsx index bc3b0dfe720bc..1b27d0e0fcc0e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/schema.tsx @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +/* istanbul ignore file */ + import { i18n } from '@kbn/i18n'; import { FormSchema } from '../../../../../shared_imports'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.test.tsx new file mode 100644 index 0000000000000..98de933590d60 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.test.tsx @@ -0,0 +1,27 @@ +/* + * 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 { shallow, mount } from 'enzyme'; + +import { TestProviders } from '../../../../../mock'; +import { StepScheduleRule } from './index'; + +describe('StepScheduleRule', () => { + it('renders correctly', () => { + const wrapper = mount(<StepScheduleRule isReadOnlyView={false} isLoading={false} />, { + wrappingComponent: TestProviders, + }); + + expect(wrapper.find('Form[data-test-subj="stepScheduleRule"]')).toHaveLength(1); + }); + + it('renders correctly if isReadOnlyView', () => { + const wrapper = shallow(<StepScheduleRule isReadOnlyView={true} isLoading={false} />); + + expect(wrapper.find('StepContentWrapper')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx index 8fbfdf5f25a51..e79aec2be6e15 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +/* istanbul ignore file */ + import { i18n } from '@kbn/i18n'; import { OptionalFieldLabel } from '../optional_field_label'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/throttle_select_field/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/throttle_select_field/index.test.tsx new file mode 100644 index 0000000000000..0ab19b671494e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/throttle_select_field/index.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 React from 'react'; +import { shallow } from 'enzyme'; + +import { ThrottleSelectField } from './index'; +import { useFormFieldMock } from '../../../../../mock'; + +describe('ThrottleSelectField', () => { + it('renders correctly', () => { + const Component = () => { + const field = useFormFieldMock(); + + return <ThrottleSelectField field={field} euiFieldProps={{ options: [] }} />; + }; + const wrapper = shallow(<Component />); + + expect(wrapper.dive().find('SelectField')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.test.tsx new file mode 100644 index 0000000000000..db32be652d0f7 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.test.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { TestProviders } from '../../../../mock'; +import { CreateRulePage } from './index'; +import { useUserInfo } from '../../components/user_info'; + +jest.mock('../../components/user_info'); + +describe('CreateRulePage', () => { + it('renders correctly', () => { + (useUserInfo as jest.Mock).mockReturnValue({}); + const wrapper = shallow(<CreateRulePage />, { wrappingComponent: TestProviders }); + + expect(wrapper.find('[title="Create new rule"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/failure_history.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/failure_history.test.tsx new file mode 100644 index 0000000000000..a83ff4c54b076 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/failure_history.test.tsx @@ -0,0 +1,27 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { TestProviders } from '../../../../mock'; +import { FailureHistory } from './failure_history'; +import { useRuleStatus } from '../../../../containers/detection_engine/rules'; +jest.mock('../../../../containers/detection_engine/rules'); + +describe('FailureHistory', () => { + beforeAll(() => { + (useRuleStatus as jest.Mock).mockReturnValue([false, null]); + }); + + it('renders correctly', () => { + const wrapper = shallow(<FailureHistory id="id" />, { + wrappingComponent: TestProviders, + }); + + expect(wrapper.find('EuiBasicTable')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.test.tsx new file mode 100644 index 0000000000000..19c6f39a9bc7e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import '../../../../mock/match_media'; +import { TestProviders } from '../../../../mock'; +import { RuleDetailsPageComponent } from './index'; +import { setAbsoluteRangeDatePicker } from '../../../../store/inputs/actions'; +import { useUserInfo } from '../../components/user_info'; +import { useParams } from 'react-router-dom'; + +jest.mock('../../components/user_info'); +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + + return { + ...originalModule, + useParams: jest.fn(), + }; +}); + +describe('RuleDetailsPageComponent', () => { + beforeAll(() => { + (useUserInfo as jest.Mock).mockReturnValue({}); + (useParams as jest.Mock).mockReturnValue({}); + }); + + it('renders correctly', () => { + const wrapper = shallow( + <RuleDetailsPageComponent + query={{ query: '', language: 'language' }} + filters={[]} + setAbsoluteRangeDatePicker={setAbsoluteRangeDatePicker} + />, + { + wrappingComponent: TestProviders, + } + ); + + expect(wrapper.find('WithSource')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx index 2b648a3b3f825..14e5f2b90882e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx @@ -88,7 +88,7 @@ const ruleDetailTabs = [ }, ]; -const RuleDetailsPageComponent: FC<PropsFromRedux> = ({ +export const RuleDetailsPageComponent: FC<PropsFromRedux> = ({ filters, query, setAbsoluteRangeDatePicker, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/status_failed_callout.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/status_failed_callout.test.tsx new file mode 100644 index 0000000000000..3394b0fc8c5c0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/status_failed_callout.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { RuleStatusFailedCallOut } from './status_failed_callout'; + +describe('RuleStatusFailedCallOut', () => { + it('renders correctly', () => { + const wrapper = shallow(<RuleStatusFailedCallOut date="date" message="message" />); + + expect(wrapper.find('EuiCallOut')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.test.tsx new file mode 100644 index 0000000000000..d22bc12abf9fa --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; + +import { TestProviders } from '../../../../mock'; +import { EditRulePage } from './index'; +import { useUserInfo } from '../../components/user_info'; +import { useParams } from 'react-router-dom'; + +jest.mock('../../components/user_info'); +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + + return { + ...originalModule, + useParams: jest.fn(), + }; +}); + +describe('EditRulePage', () => { + it('renders correctly', () => { + (useUserInfo as jest.Mock).mockReturnValue({}); + (useParams as jest.Mock).mockReturnValue({}); + const wrapper = shallow(<EditRulePage />, { wrappingComponent: TestProviders }); + + expect(wrapper.find('[title="Edit rule settings"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx index 443dbd2c93a35..1c01a19573cd6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx @@ -302,11 +302,25 @@ describe('rule helpers', () => { test('returns expected ActionsStepRule rule object', () => { const mockedRule = { ...mockRule('test-id'), - actions: [], + actions: [ + { + id: 'id', + group: 'group', + params: {}, + action_type_id: 'action_type_id', + }, + ], }; const result: ActionsStepRule = getActionsStepsData(mockedRule); const expected = { - actions: [], + actions: [ + { + id: 'id', + group: 'group', + params: {}, + actionTypeId: 'action_type_id', + }, + ], enabled: mockedRule.enabled, isNew: false, throttle: 'no_actions', diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.test.tsx new file mode 100644 index 0000000000000..3fa81ca3ced08 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.test.tsx @@ -0,0 +1,27 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { RulesPage } from './index'; +import { useUserInfo } from '../components/user_info'; +import { usePrePackagedRules } from '../../../containers/detection_engine/rules'; + +jest.mock('../components/user_info'); +jest.mock('../../../containers/detection_engine/rules'); + +describe('RulesPage', () => { + beforeAll(() => { + (useUserInfo as jest.Mock).mockReturnValue({}); + (usePrePackagedRules as jest.Mock).mockReturnValue({}); + }); + it('renders correctly', () => { + const wrapper = shallow(<RulesPage />); + + expect(wrapper.find('AllRules')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/utils.test.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/utils.test.ts new file mode 100644 index 0000000000000..34a521ed32b12 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/utils.test.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 { getBreadcrumbs } from './utils'; + +describe('getBreadcrumbs', () => { + it('returns default value for incorrect params', () => { + expect( + getBreadcrumbs( + { + pageName: 'pageName', + detailName: 'detailName', + tabName: undefined, + search: '', + pathName: 'pathName', + }, + [] + ) + ).toEqual([{ href: '#/link-to/detections', text: 'Detections' }]); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts index 50ac10347e062..f537b22bac1eb 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts @@ -4,42 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import { savedObjectsClientMock } from 'src/core/server/mocks'; import { loggerMock } from 'src/core/server/logging/logger.mock'; import { getResult } from '../routes/__mocks__/request_responses'; import { rulesNotificationAlertType } from './rules_notification_alert_type'; import { buildSignalsSearchQuery } from './build_signals_query'; -import { AlertInstance } from '../../../../../../../plugins/alerting/server'; +import { alertsMock, AlertServicesMock } from '../../../../../../../plugins/alerting/server/mocks'; import { NotificationExecutorOptions } from './types'; jest.mock('./build_signals_query'); describe('rules_notification_alert_type', () => { let payload: NotificationExecutorOptions; let alert: ReturnType<typeof rulesNotificationAlertType>; - let alertInstanceMock: Record<string, jest.Mock>; - let alertInstanceFactoryMock: () => AlertInstance; - let savedObjectsClient: ReturnType<typeof savedObjectsClientMock.create>; let logger: ReturnType<typeof loggerMock.create>; - let callClusterMock: jest.Mock; + let alertServices: AlertServicesMock; beforeEach(() => { - alertInstanceMock = { - scheduleActions: jest.fn(), - replaceState: jest.fn(), - }; - alertInstanceMock.replaceState.mockReturnValue(alertInstanceMock); - alertInstanceFactoryMock = jest.fn().mockReturnValue(alertInstanceMock); - callClusterMock = jest.fn(); - savedObjectsClient = savedObjectsClientMock.create(); + alertServices = alertsMock.createAlertServices(); logger = loggerMock.create(); payload = { alertId: '1111', - services: { - savedObjectsClient, - alertInstanceFactory: alertInstanceFactoryMock, - callCluster: callClusterMock, - }, + services: alertServices, params: { ruleAlertId: '2222' }, state: {}, spaceId: '', @@ -58,7 +43,7 @@ describe('rules_notification_alert_type', () => { describe('executor', () => { it('throws an error if rule alert was not found', async () => { - savedObjectsClient.get.mockResolvedValue({ + alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', attributes: {}, type: 'type', @@ -72,13 +57,13 @@ describe('rules_notification_alert_type', () => { it('should call buildSignalsSearchQuery with proper params', async () => { const ruleAlert = getResult(); - savedObjectsClient.get.mockResolvedValue({ + alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', references: [], attributes: ruleAlert, }); - callClusterMock.mockResolvedValue({ + alertServices.callCluster.mockResolvedValue({ count: 0, }); @@ -96,36 +81,38 @@ describe('rules_notification_alert_type', () => { it('should not call alertInstanceFactory if signalsCount was 0', async () => { const ruleAlert = getResult(); - savedObjectsClient.get.mockResolvedValue({ + alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', references: [], attributes: ruleAlert, }); - callClusterMock.mockResolvedValue({ + alertServices.callCluster.mockResolvedValue({ count: 0, }); await alert.executor(payload); - expect(alertInstanceFactoryMock).not.toHaveBeenCalled(); + expect(alertServices.alertInstanceFactory).not.toHaveBeenCalled(); }); it('should call scheduleActions if signalsCount was greater than 0', async () => { const ruleAlert = getResult(); - savedObjectsClient.get.mockResolvedValue({ + alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', references: [], attributes: ruleAlert, }); - callClusterMock.mockResolvedValue({ + alertServices.callCluster.mockResolvedValue({ count: 10, }); await alert.executor(payload); - expect(alertInstanceFactoryMock).toHaveBeenCalled(); + expect(alertServices.alertInstanceFactory).toHaveBeenCalled(); + + const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; expect(alertInstanceMock.replaceState).toHaveBeenCalledWith( expect.objectContaining({ signals_count: 10 }) ); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index e400360a5a5b2..94097df48949f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -254,9 +254,9 @@ export const getFindResultWithMultiHits = ({ export const ruleStatusRequest = () => requestMock.create({ - method: 'get', + method: 'post', path: `${DETECTION_ENGINE_RULES_URL}/_find_statuses`, - query: { ids: ['someId'] }, + body: { ids: ['someId'] }, }); export const getImportRulesRequest = (hapiStream?: HapiReadableStream) => diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts index 89c9f34027120..d7c6d317227fa 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts @@ -60,9 +60,9 @@ describe('find_statuses', () => { describe('request validation', () => { test('disallows singular id query param', async () => { const request = requestMock.create({ - method: 'get', + method: 'post', path: `${DETECTION_ENGINE_RULES_URL}/_find_statuses`, - query: { id: ['someId'] }, + body: { id: ['someId'] }, }); const result = server.validate(request); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.ts index 6fee4d71a904e..8eed146537718 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.ts @@ -22,18 +22,18 @@ import { } from '../utils'; export const findRulesStatusesRoute = (router: IRouter) => { - router.get( + router.post( { path: `${DETECTION_ENGINE_RULES_URL}/_find_statuses`, validate: { - query: buildRouteValidation<FindRulesStatusesRequestParams>(findRulesStatusesSchema), + body: buildRouteValidation<FindRulesStatusesRequestParams>(findRulesStatusesSchema), }, options: { tags: ['access:siem'], }, }, async (context, request, response) => { - const { query } = request; + const { body } = request; const siemResponse = buildSiemResponse(response); const alertsClient = context.alerting?.getAlertsClient(); const savedObjectsClient = context.core.savedObjects.client; @@ -50,7 +50,7 @@ export const findRulesStatusesRoute = (router: IRouter) => { } */ try { - const statuses = await query.ids.reduce<Promise<RuleStatusResponse | {}>>( + const statuses = await body.ids.reduce<Promise<RuleStatusResponse | {}>>( async (acc, id) => { const lastFiveErrorsForId = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts index 29ae5056a3ae8..57ccc7a7806ac 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -26,13 +26,13 @@ import { buildSiemResponse, validateLicenseForRuleType, } from '../utils'; -import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson'; import { ImportRuleAlertRest } from '../../types'; import { patchRules } from '../../rules/patch_rules'; import { importRulesQuerySchema, importRulesPayloadSchema } from '../schemas/import_rules_schema'; import { ImportRulesSchema, importRulesSchema } from '../schemas/response/import_rules_schema'; import { getTupleDuplicateErrorsAndUniqueRules } from './utils'; import { validate } from './validate'; +import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson'; type PromiseFromStreams = ImportRuleAlertRest | Error; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts index 31a0f37fe81c9..8b1b0cab3b2f2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts @@ -12,7 +12,6 @@ import { transformTags, getIdBulkError, transformOrBulkError, - transformDataToNdjson, transformAlertsToRules, transformOrImportError, getDuplicates, @@ -22,14 +21,13 @@ import { getResult } from '../__mocks__/request_responses'; import { INTERNAL_IDENTIFIER } from '../../../../../common/constants'; import { ImportRuleAlertRest, RuleAlertParamsRest, RuleTypeParams } from '../../types'; import { BulkError, ImportSuccessError } from '../utils'; -import { sampleRule } from '../../signals/__mocks__/es_results'; import { getSimpleRule, getOutputRuleAlertForRest } from '../__mocks__/utils'; -import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson'; import { createPromiseFromStreams } from '../../../../../../../../../src/legacy/utils/streams'; import { PartialAlert } from '../../../../../../../../plugins/alerting/server'; import { SanitizedAlert } from '../../../../../../../../plugins/alerting/server/types'; import { RuleAlertType } from '../../rules/types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; +import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson'; type PromiseFromStreams = ImportRuleAlertRest | Error; @@ -396,47 +394,6 @@ describe('utils', () => { }); }); - describe('transformDataToNdjson', () => { - test('if rules are empty it returns an empty string', () => { - const ruleNdjson = transformDataToNdjson([]); - expect(ruleNdjson).toEqual(''); - }); - - test('single rule will transform with new line ending character for ndjson', () => { - const rule = sampleRule(); - const ruleNdjson = transformDataToNdjson([rule]); - expect(ruleNdjson.endsWith('\n')).toBe(true); - }); - - test('multiple rules will transform with two new line ending characters for ndjson', () => { - const result1 = sampleRule(); - const result2 = sampleRule(); - result2.id = 'some other id'; - result2.rule_id = 'some other id'; - result2.name = 'Some other rule'; - - const ruleNdjson = transformDataToNdjson([result1, result2]); - // this is how we count characters in JavaScript :-) - const count = ruleNdjson.split('\n').length - 1; - expect(count).toBe(2); - }); - - test('you can parse two rules back out without errors', () => { - const result1 = sampleRule(); - const result2 = sampleRule(); - result2.id = 'some other id'; - result2.rule_id = 'some other id'; - result2.name = 'Some other rule'; - - const ruleNdjson = transformDataToNdjson([result1, result2]); - const ruleStrings = ruleNdjson.split('\n'); - const reParsed1 = JSON.parse(ruleStrings[0]); - const reParsed2 = JSON.parse(ruleStrings[1]); - expect(reParsed1).toEqual(result1); - expect(reParsed2).toEqual(result2); - }); - }); - describe('transformAlertsToRules', () => { test('given an empty array returns an empty array', () => { expect(transformAlertsToRules([])).toEqual([]); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index 4d13fa1b6ae50..790603fa8cfc1 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -152,15 +152,6 @@ export const transformAlertToRule = ( }); }; -export const transformDataToNdjson = (data: unknown[]): string => { - if (data.length !== 0) { - const dataString = data.map(rule => JSON.stringify(rule)).join('\n'); - return `${dataString}\n`; - } else { - return ''; - } -}; - export const transformAlertsToRules = ( alerts: RuleAlertType[] ): Array<Partial<OutputRuleAlertRest>> => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts index 3e22999528101..27008d17d2192 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts @@ -4,39 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ import { Transform } from 'stream'; -import { has, isString } from 'lodash/fp'; import { ImportRuleAlertRest } from '../types'; import { createSplitStream, createMapStream, - createFilterStream, createConcatStream, } from '../../../../../../../../src/legacy/utils/streams'; import { importRulesSchema } from '../routes/schemas/import_rules_schema'; import { BadRequestError } from '../errors/bad_request_error'; - -export interface RulesObjectsExportResultDetails { - /** number of successfully exported objects */ - exportedCount: number; -} - -export const parseNdjsonStrings = (): Transform => { - return createMapStream((ndJsonStr: string) => { - if (isString(ndJsonStr) && ndJsonStr.trim() !== '') { - try { - return JSON.parse(ndJsonStr); - } catch (err) { - return err; - } - } - }); -}; - -export const filterExportedCounts = (): Transform => { - return createFilterStream<ImportRuleAlertRest | RulesObjectsExportResultDetails>( - obj => obj != null && !has('exported_count', obj) - ); -}; +import { + parseNdjsonStrings, + filterExportedCounts, + createLimitStream, +} from '../../../utils/read_stream/create_stream_from_ndjson'; export const validateRules = (): Transform => { return createMapStream((obj: ImportRuleAlertRest) => { @@ -53,21 +33,6 @@ export const validateRules = (): Transform => { }); }; -// Adaptation from: saved_objects/import/create_limit_stream.ts -export const createLimitStream = (limit: number): Transform => { - let counter = 0; - return new Transform({ - objectMode: true, - async transform(obj, _, done) { - if (counter >= limit) { - return done(new Error(`Can't import more than ${limit} rules`)); - } - counter++; - done(undefined, obj); - }, - }); -}; - // TODO: Capture both the line number and the rule_id if you have that information for the error message // eventually and then pass it down so we can give error messages on the line number diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts index 6a27abb66ce85..40c07f28ea848 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts @@ -7,7 +7,8 @@ import { AlertsClient } from '../../../../../../../plugins/alerting/server'; import { getNonPackagedRules } from './get_existing_prepackaged_rules'; import { getExportDetailsNdjson } from './get_export_details_ndjson'; -import { transformAlertsToRules, transformDataToNdjson } from '../routes/rules/utils'; +import { transformAlertsToRules } from '../routes/rules/utils'; +import { transformDataToNdjson } from '../../../utils/read_stream/create_stream_from_ndjson'; export const getExportAll = async ( alertsClient: AlertsClient diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts index 6f642231ebbaf..048f09e95b062 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts @@ -8,8 +8,9 @@ import { AlertsClient } from '../../../../../../../plugins/alerting/server'; import { getExportDetailsNdjson } from './get_export_details_ndjson'; import { isAlertType } from '../rules/types'; import { readRules } from './read_rules'; -import { transformDataToNdjson, transformAlertToRule } from '../routes/rules/utils'; +import { transformAlertToRule } from '../routes/rules/utils'; import { OutputRuleAlertRest } from '../types'; +import { transformDataToNdjson } from '../../../utils/read_stream/create_stream_from_ndjson'; interface ExportSuccesRule { statusCode: 200; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_activity.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_activity.json index 1123c1161c4ce..41f38173dba33 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_activity.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_activity.json @@ -4,7 +4,7 @@ "false_positives": [ "A newly installed program or one that rarely uses the network could trigger this signal." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "linux_anomalous_network_activity_ecs", "name": "Unusual Linux Network Activity", @@ -20,5 +20,6 @@ "ML" ], "type": "machine_learning", + "note": "### Investigating Unusual Network Activity ###\nSignals from this rule indicate the presence of network activity from a Linux process for which network activity is rare and unusual. Here are some possible avenues of investigation:\n- Consider the IP addresses and ports. Are these used by normal but infrequent network workflows? Are they expected or unexpected? \n- If the destination IP address is remote or external, does it associate with an expected domain, organization or geography? Note: avoid interacting directly with suspected malicious IP addresses.\n- Consider the user as identified by the username field. Is this network activity part of an expected workflow for the user who ran the program?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business or maintenance process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_port_activity.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_port_activity.json index 19dd643945b17..d435d4c10f05c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_port_activity.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_port_activity.json @@ -4,7 +4,7 @@ "false_positives": [ "A newly installed program or one that rarely uses the network could trigger this signal." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "linux_anomalous_network_port_activity_ecs", "name": "Unusual Linux Network Port Activity", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_service.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_service.json index e2e5803618d06..0b82ce99d0b7f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_service.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_service.json @@ -4,7 +4,7 @@ "false_positives": [ "A newly installed program or one that rarely uses the network could trigger this signal." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "linux_anomalous_network_service", "name": "Unusual Linux Network Service", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_url_activity.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_url_activity.json index 40dd2e76c7214..26af34e18a4c8 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_url_activity.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_url_activity.json @@ -4,7 +4,7 @@ "false_positives": [ "A new and unusual program or artifact download in the course of software upgrades, debugging, or troubleshooting could trigger this signal." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "linux_anomalous_network_url_activity_ecs", "name": "Unusual Linux Web Activity", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_process_all_hosts.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_process_all_hosts.json index 6bac2f25fd7de..103171bcdfe50 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_process_all_hosts.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_process_all_hosts.json @@ -4,7 +4,7 @@ "false_positives": [ "A newly installed program or one that runs rarely as part of a monthly or quarterly workflow could trigger this signal." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "linux_anomalous_process_all_hosts_ecs", "name": "Anomalous Process For a Linux Population", @@ -20,5 +20,6 @@ "ML" ], "type": "machine_learning", + "note": "### Investigating an Unusual Linux Process ###\nSignals from this rule indicate the presence of a Linux process that is rare and unusual for all of the monitored Linux hosts for which Auditbeat data is available. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_user_name.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_user_name.json index 8b7e6c89482f7..6642bb5d73fbd 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_user_name.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_user_name.json @@ -4,7 +4,7 @@ "false_positives": [ "Uncommon user activity can be due to an engineer logging onto a server instance in order to perform manual troubleshooting or reconfiguration." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "linux_anomalous_user_name_ecs", "name": "Unusual Linux Username", @@ -20,5 +20,6 @@ "ML" ], "type": "machine_learning", + "note": "### Investigating an Unusual Linux User ###\nSignals from this rule indicate activity for a Linux user name that is rare and unusual. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host? Could this be related to troubleshooting or debugging activity by a developer or site reliability engineer?\n- Examine the history of user activity. If this user manifested only very recently, it might be a service account for a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks that the user is performing.", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_dns_tunneling.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_dns_tunneling.json index c70725dcb645a..765515ffda27c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_dns_tunneling.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_dns_tunneling.json @@ -4,7 +4,7 @@ "false_positives": [ "DNS domains that use large numbers of child domains, such as software or content distribution networks, can trigger this signal and such parent domains can be excluded." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "packetbeat_dns_tunneling", "name": "DNS Tunneling", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_dns_question.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_dns_question.json index 3ed40ddf27864..79c30c5b38378 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_dns_question.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_dns_question.json @@ -4,7 +4,7 @@ "false_positives": [ "A newly installed program or one that runs rarely as part of a monthly or quarterly workflow could trigger this signal. Network activity that occurs rarely, in small quantities, can trigger this signal. Possible examples are browsing technical support or vendor networks sparsely. A user who visits a new or unique web destination may trigger this signal." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "packetbeat_rare_dns_question", "name": "Unusual DNS Activity", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_server_domain.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_server_domain.json index c49bc95be75d2..7b14ad62f6c93 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_server_domain.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_server_domain.json @@ -4,7 +4,7 @@ "false_positives": [ "Web activity that occurs rarely in small quantities can trigger this signal. Possible examples are browsing technical support or vendor URLs that are used very sparsely. A user who visits a new and unique web destination may trigger this signal when the activity is sparse. Web applications that generate URLs unique to a transaction may trigger this when they are used sparsely. Web domains can be excluded in cases such as these." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "packetbeat_rare_server_domain", "name": "Unusual Network Destination Domain Name", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_urls.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_urls.json index 02a4a5f729a16..76767545e794a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_urls.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_urls.json @@ -4,7 +4,7 @@ "false_positives": [ "Web activity that occurs rarely in small quantities can trigger this signal. Possible examples are browsing technical support or vendor URLs that are used very sparsely. A user who visits a new and unique web destination may trigger this signal when the activity is sparse. Web applications that generate URLs unique to a transaction may trigger this when they are used sparsely. Web domains can be excluded in cases such as these." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "packetbeat_rare_urls", "name": "Unusual Web Request", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_user_agent.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_user_agent.json index 76ed6b263a704..1dc49203f31c1 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_user_agent.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_user_agent.json @@ -4,7 +4,7 @@ "false_positives": [ "Web activity that is uncommon, like security scans, may trigger this signal and may need to be excluded. A new or rarely used program that calls web services may trigger this signal." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "packetbeat_rare_user_agent", "name": "Unusual Web User Agent", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_linux.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_linux.json index 048f93e170656..8ae1b84aaf199 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_linux.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_linux.json @@ -4,7 +4,7 @@ "false_positives": [ "A newly installed program or one that runs rarely as part of a monthly or quarterly workflow could trigger this signal." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "rare_process_by_host_linux_ecs", "name": "Unusual Process For a Linux Host", @@ -20,5 +20,6 @@ "ML" ], "type": "machine_learning", + "note": "### Investigating an Unusual Linux Process ###\nSignals from this rule indicate the presence of a Linux process that is rare and unusual for the host it ran on. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_windows.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_windows.json index 7bc46cdc04dd2..879cee388f5dd 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_windows.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_windows.json @@ -4,7 +4,7 @@ "false_positives": [ "A newly installed program or one that runs rarely as part of a monthly or quarterly workflow could trigger this signal." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "rare_process_by_host_windows_ecs", "name": "Unusual Process For a Windows Host", @@ -20,5 +20,6 @@ "Windows" ], "type": "machine_learning", + "note": "### Investigating an Unusual Windows Process ###\nSignals from this rule indicate the presence of a Windows process that is rare and unusual for the host it ran on. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process metadata like the values of the Company, Description and Product fields which may indicate whether the program is associated with an expected software vendor or package. \n- Examine arguments and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.\n- Consider the same for the parent process. If the parent process is a legitimate system utility or service, this could be related to software updates or system management. If the parent process is something user-facing like an Office application, this process could be more suspicious.\n- If you have file hash values in the event data, and you suspect malware, you can optionally run a search for the file hash to see if the file is identified as malware by anti-malware tools. ", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/suspicious_login_activity.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/suspicious_login_activity.json index 915bc1bcfc051..4b94fdc6da147 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/suspicious_login_activity.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/suspicious_login_activity.json @@ -4,7 +4,7 @@ "false_positives": [ "Security audits may trigger this signal. Conditions that generate bursts of failed logins, such as misconfigured applications or account lockouts could trigger this signal." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "suspicious_login_activity_ecs", "name": "Unusual Login Activity", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_network_activity.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_network_activity.json index 72671760c9c8d..1092bcb20bcc3 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_network_activity.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_network_activity.json @@ -4,7 +4,7 @@ "false_positives": [ "A newly installed program or one that rarely uses the network could trigger this signal." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "windows_anomalous_network_activity_ecs", "name": "Unusual Windows Network Activity", @@ -20,5 +20,6 @@ "Windows" ], "type": "machine_learning", + "note": "### Investigating Unusual Network Activity ###\nSignals from this rule indicate the presence of network activity from a Windows process for which network activity is very unusual. Here are some possible avenues of investigation:\n- Consider the IP addresses, protocol and ports. Are these used by normal but infrequent network workflows? Are they expected or unexpected? \n- If the destination IP address is remote or external, does it associate with an expected domain, organization or geography? Note: avoid interacting directly with suspected malicious IP addresses.\n- Consider the user as identified by the username field. Is this network activity part of an expected workflow for the user who ran the program?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.\n- Consider the same for the parent process. If the parent process is a legitimate system utility or service, this could be related to software updates or system management. If the parent process is something user-facing like an Office application, this process could be more suspicious.\n- If you have file hash values in the event data, and you suspect malware, you can optionally run a search for the file hash to see if the file is identified as malware by anti-malware tools. ", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_path_activity.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_path_activity.json index 082fce438ca9e..8a88607b9d5c9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_path_activity.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_path_activity.json @@ -4,7 +4,7 @@ "false_positives": [ "A new and unusual program or artifact download in the course of software upgrades, debugging, or troubleshooting could trigger this signal. Users downloading and running programs from unusual locations, such as temporary directories, browser caches, or profile paths could trigger this signal." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "windows_anomalous_path_activity_ecs", "name": "Unusual Windows Path Activity", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_all_hosts.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_all_hosts.json index 93469b5a06223..f9adfeb830618 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_all_hosts.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_all_hosts.json @@ -4,7 +4,7 @@ "false_positives": [ "A newly installed program or one that runs rarely as part of a monthly or quarterly workflow could trigger this signal." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "windows_anomalous_process_all_hosts_ecs", "name": "Anomalous Process For a Windows Population", @@ -20,5 +20,6 @@ "Windows" ], "type": "machine_learning", + "note": "### Investigating an Unusual Windows Process ###\nSignals from this rule indicate the presence of a Windows process that is rare and unusual for all of the Windows hosts for which Winlogbeat data is available. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process metadata like the values of the Company, Description and Product fields which may indicate whether the program is associated with an expected software vendor or package. \n- Examine arguments and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.\n- Consider the same for the parent process. If the parent process is a legitimate system utility or service, this could be related to software updates or system management. If the parent process is something user-facing like an Office application, this process could be more suspicious.\n- If you have file hash values in the event data, and you suspect malware, you can optionally run a search for the file hash to see if the file is identified as malware by anti-malware tools. ", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_creation.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_creation.json index 1b80e443baae6..98a078ccea4a4 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_creation.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_creation.json @@ -4,7 +4,7 @@ "false_positives": [ "Users running scripts in the course of technical support operations of software upgrades could trigger this signal. A newly installed program or one that runs rarely as part of a monthly or quarterly workflow could trigger this signal." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "windows_anomalous_process_creation", "name": "Anomalous Windows Process Creation", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_script.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_script.json index 4de5443bcaf3f..564ca1782526f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_script.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_script.json @@ -4,7 +4,7 @@ "false_positives": [ "Certain kinds of security testing may trigger this signal. PowerShell scripts that use high levels of obfuscation or have unusual script block payloads may trigger this signal." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "windows_anomalous_script", "name": "Suspicious Powershell Script", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_service.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_service.json index 7e0641fee68c2..afef569f4ebb4 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_service.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_service.json @@ -4,7 +4,7 @@ "false_positives": [ "A newly installed program or one that runs rarely as part of a monthly or quarterly workflow could trigger this signal." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "windows_anomalous_service", "name": "Unusual Windows Service", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_user_name.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_user_name.json index 217404b6eb474..a0c6ff5c938f1 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_user_name.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_user_name.json @@ -4,7 +4,7 @@ "false_positives": [ "Uncommon user activity can be due to an administrator or help desk technician logging onto a workstation or server in order to perform manual troubleshooting or reconfiguration." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "windows_anomalous_user_name_ecs", "name": "Unusual Windows Username", @@ -20,5 +20,6 @@ "Windows" ], "type": "machine_learning", + "note": "### Investigating an Unusual Windows User ###\nSignals from this rule indicate activity for a Windows user name that is rare and unusual. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host? Could this be related to occasional troubleshooting or support activity?\n- Examine the history of user activity. If this user manifested only very recently, it might be a service account for a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks that the user is performing.\n- Consider the same for the parent process. If the parent process is a legitimate system utility or service, this could be related to software updates or system management. If the parent process is something user-facing like an Office application, this process could be more suspicious.", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_runas_event.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_runas_event.json index 3dca119b5a28e..febaa57443f76 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_runas_event.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_runas_event.json @@ -4,7 +4,7 @@ "false_positives": [ "Uncommon user privilege elevation activity can be due to an administrator, help desk technician, or a user performing manual troubleshooting or reconfiguration." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "windows_rare_user_runas_event", "name": "Unusual Windows User Privilege Elevation Activity", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_type10_remote_login.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_type10_remote_login.json index 09ff2a0cedf41..7318364c3aac2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_type10_remote_login.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_type10_remote_login.json @@ -4,7 +4,7 @@ "false_positives": [ "Uncommon username activity can be due to an engineer logging onto a server instance in order to perform manual troubleshooting or reconfiguration." ], - "from": "now-16m", + "from": "now-45m", "interval": "15m", "machine_learning_job_id": "windows_rare_user_type10_remote_login", "name": "Unusual Windows Remote User", @@ -20,5 +20,6 @@ "Windows" ], "type": "machine_learning", + "note": "### Investigating an Unusual Windows User ###\nSignals from this rule indicate activity for a rare and unusual Windows RDP (remote desktop) user. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is the user part of a group who normally logs into Windows hosts using RDP (remote desktop protocol)? Is this logon activity part of an expected workflow for the user? \n- Consider the source of the login. If the source is remote, could this be related to occasional troubleshooting or support activity by a vendor or an employee working remotely?", "version": 1 -} \ No newline at end of file +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_rules_statuses_by_ids.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_rules_statuses_by_ids.sh index 543c019067e8e..5ae4904e9e4ec 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_rules_statuses_by_ids.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_rules_statuses_by_ids.sh @@ -10,8 +10,12 @@ set -e ./check_env_variables.sh -# Example: ./find_rules_statuses_by_ids.sh '["12345","6789abc"]' +# Example: ./find_rules_statuses_by_ids.sh [\"12345\",\"6789abc\"] curl -g -k \ + -s \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X GET "${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_find_statuses?ids=$1" \ + -X POST "${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_find_statuses" \ + -d "{\"ids\": $1}" \ | jq . diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts index 7a211c5631da6..3bdcc3f92f44c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SignalSourceHit, SignalSearchResponse } from '../types'; +import { SignalSourceHit, SignalSearchResponse, BulkResponse, BulkItem } from '../types'; import { Logger, SavedObject, @@ -416,3 +416,68 @@ export const exampleFindRuleStatusResponse: ( }); export const mockLogger: Logger = loggingServiceMock.createLogger(); + +export const sampleBulkErrorItem = ( + { + status, + reason, + }: { + status: number; + reason: string; + } = { status: 400, reason: 'Invalid call' } +): BulkItem => { + return { + create: { + _index: 'mock_index', + _id: '123', + _version: 1, + status, + _shards: { + total: 1, + successful: 0, + failed: 1, + }, + error: { + type: 'Invalid', + reason, + shard: 'shard 123', + index: 'mock_index', + }, + }, + }; +}; + +export const sampleBulkItem = (): BulkItem => { + return { + create: { + _index: 'mock_index', + _id: '123', + _version: 1, + status: 200, + result: 'some result here', + _shards: { + total: 1, + successful: 1, + failed: 0, + }, + }, + }; +}; + +export const sampleEmptyBulkResponse = (): BulkResponse => ({ + took: 0, + errors: false, + items: [], +}); + +export const sampleBulkError = (): BulkResponse => ({ + took: 0, + errors: true, + items: [sampleBulkErrorItem()], +}); + +export const sampleBulkResponse = (): BulkResponse => ({ + took: 0, + errors: true, + items: [sampleBulkItem()], +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts index 86d1278031695..510667b211d25 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts @@ -5,42 +5,28 @@ */ import { getQueryFilter, getFilter } from './get_filter'; -import { savedObjectsClientMock } from 'src/core/server/mocks'; import { PartialFilter } from '../types'; -import { AlertServices } from '../../../../../../../plugins/alerting/server'; +import { alertsMock, AlertServicesMock } from '../../../../../../../plugins/alerting/server/mocks'; describe('get_filter', () => { - let savedObjectsClient = savedObjectsClientMock.create(); - savedObjectsClient.get = jest.fn().mockImplementation(() => ({ - attributes: { - query: { query: 'host.name: linux', language: 'kuery' }, - filters: [], - }, - })); - let servicesMock: AlertServices = { - savedObjectsClient, - callCluster: jest.fn(), - alertInstanceFactory: jest.fn(), - }; + let servicesMock: AlertServicesMock; beforeAll(() => { jest.resetAllMocks(); }); beforeEach(() => { - savedObjectsClient = savedObjectsClientMock.create(); - savedObjectsClient.get = jest.fn().mockImplementation(() => ({ + servicesMock = alertsMock.createAlertServices(); + servicesMock.savedObjectsClient.get.mockImplementation(async (type: string, id: string) => ({ + id, + type, + references: [], attributes: { query: { query: 'host.name: linux', language: 'kuery' }, language: 'kuery', filters: [], }, })); - servicesMock = { - savedObjectsClient, - callCluster: jest.fn(), - alertInstanceFactory: jest.fn(), - }; }); afterEach(() => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_input_output_index.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_input_output_index.test.ts index 18286dc7754e0..ccd882228d4de 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_input_output_index.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_input_output_index.test.ts @@ -4,22 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { savedObjectsClientMock } from 'src/core/server/mocks'; import { DEFAULT_INDEX_KEY } from '../../../../common/constants'; import { getInputIndex } from './get_input_output_index'; import { defaultIndexPattern } from '../../../../default_index_pattern'; -import { AlertServices } from '../../../../../../../plugins/alerting/server'; +import { alertsMock, AlertServicesMock } from '../../../../../../../plugins/alerting/server/mocks'; describe('get_input_output_index', () => { - let savedObjectsClient = savedObjectsClientMock.create(); - savedObjectsClient.get = jest.fn().mockImplementation(() => ({ - attributes: {}, - })); - let servicesMock: AlertServices = { - savedObjectsClient, - callCluster: jest.fn(), - alertInstanceFactory: jest.fn(), - }; + let servicesMock: AlertServicesMock; beforeAll(() => { jest.resetAllMocks(); @@ -30,20 +21,21 @@ describe('get_input_output_index', () => { }); beforeEach(() => { - savedObjectsClient = savedObjectsClientMock.create(); - savedObjectsClient.get = jest.fn().mockImplementation(() => ({ + servicesMock = alertsMock.createAlertServices(); + servicesMock.savedObjectsClient.get.mockImplementation(async (type: string, id: string) => ({ + id, + type, + references: [], attributes: {}, })); - servicesMock = { - savedObjectsClient, - callCluster: jest.fn(), - alertInstanceFactory: jest.fn(), - }; }); describe('getInputOutputIndex', () => { test('Returns inputIndex if inputIndex is passed in', async () => { - savedObjectsClient.get = jest.fn().mockImplementation(() => ({ + servicesMock.savedObjectsClient.get.mockImplementation(async (type: string, id: string) => ({ + id, + type, + references: [], attributes: {}, })); const inputIndex = await getInputIndex(servicesMock, '8.0.0', ['test-input-index-1']); @@ -51,7 +43,10 @@ describe('get_input_output_index', () => { }); test('Returns a saved object inputIndex if passed in inputIndex is undefined', async () => { - savedObjectsClient.get = jest.fn().mockImplementation(() => ({ + servicesMock.savedObjectsClient.get.mockImplementation(async (type: string, id: string) => ({ + id, + type, + references: [], attributes: { [DEFAULT_INDEX_KEY]: ['configured-index-1', 'configured-index-2'], }, @@ -61,7 +56,10 @@ describe('get_input_output_index', () => { }); test('Returns a saved object inputIndex if passed in inputIndex is null', async () => { - savedObjectsClient.get = jest.fn().mockImplementation(() => ({ + servicesMock.savedObjectsClient.get.mockImplementation(async (type: string, id: string) => ({ + id, + type, + references: [], attributes: { [DEFAULT_INDEX_KEY]: ['configured-index-1', 'configured-index-2'], }, @@ -71,7 +69,10 @@ describe('get_input_output_index', () => { }); test('Returns a saved object inputIndex default from constants if inputIndex passed in is null and the key is also null', async () => { - savedObjectsClient.get = jest.fn().mockImplementation(() => ({ + servicesMock.savedObjectsClient.get.mockImplementation(async (type: string, id: string) => ({ + id, + type, + references: [], attributes: { [DEFAULT_INDEX_KEY]: null, }, @@ -81,7 +82,10 @@ describe('get_input_output_index', () => { }); test('Returns a saved object inputIndex default from constants if inputIndex passed in is undefined and the key is also null', async () => { - savedObjectsClient.get = jest.fn().mockImplementation(() => ({ + servicesMock.savedObjectsClient.get.mockImplementation(async (type: string, id: string) => ({ + id, + type, + references: [], attributes: { [DEFAULT_INDEX_KEY]: null, }, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 81600b0b8dd9b..9e2f36fe2653a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -16,20 +16,16 @@ import { } from './__mocks__/es_results'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; import { DEFAULT_SIGNALS_INDEX } from '../../../../common/constants'; -import { savedObjectsClientMock } from 'src/core/server/mocks'; +import { alertsMock, AlertServicesMock } from '../../../../../../../plugins/alerting/server/mocks'; import uuid from 'uuid'; -export const mockService = { - callCluster: jest.fn(), - alertInstanceFactory: jest.fn(), - savedObjectsClient: savedObjectsClientMock.create(), -}; - describe('searchAfterAndBulkCreate', () => { + let mockService: AlertServicesMock; let inputIndexPattern: string[] = []; beforeEach(() => { jest.clearAllMocks(); inputIndexPattern = ['auditbeat-*']; + mockService = alertsMock.createAlertServices(); }); test('if successful with empty search results', async () => { @@ -65,7 +61,7 @@ describe('searchAfterAndBulkCreate', () => { const sampleParams = sampleRuleAlertParams(30); const someGuids = Array.from({ length: 13 }).map(x => uuid.v4()); mockService.callCluster - .mockReturnValueOnce({ + .mockResolvedValueOnce({ took: 100, errors: false, items: [ @@ -79,8 +75,8 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockReturnValueOnce(repeatedSearchResultsWithSortId(3, 1, someGuids.slice(0, 3))) - .mockReturnValueOnce({ + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(3, 1, someGuids.slice(0, 3))) + .mockResolvedValueOnce({ took: 100, errors: false, items: [ @@ -94,8 +90,8 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockReturnValueOnce(repeatedSearchResultsWithSortId(3, 1, someGuids.slice(3, 6))) - .mockReturnValueOnce({ + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(3, 1, someGuids.slice(3, 6))) + .mockResolvedValueOnce({ took: 100, errors: false, items: [ @@ -139,7 +135,7 @@ describe('searchAfterAndBulkCreate', () => { test('if unsuccessful first bulk create', async () => { const someGuids = Array.from({ length: 4 }).map(x => uuid.v4()); const sampleParams = sampleRuleAlertParams(10); - mockService.callCluster.mockReturnValue(sampleBulkCreateDuplicateResult); + mockService.callCluster.mockResolvedValue(sampleBulkCreateDuplicateResult); const { success, createdSignalsCount } = await searchAfterAndBulkCreate({ someResult: repeatedSearchResultsWithSortId(4, 1, someGuids), ruleParams: sampleParams, @@ -169,7 +165,7 @@ describe('searchAfterAndBulkCreate', () => { test('if unsuccessful iteration of searchAfterAndBulkCreate due to empty sort ids', async () => { const sampleParams = sampleRuleAlertParams(); - mockService.callCluster.mockReturnValueOnce({ + mockService.callCluster.mockResolvedValueOnce({ took: 100, errors: false, items: [ @@ -212,7 +208,7 @@ describe('searchAfterAndBulkCreate', () => { test('if unsuccessful iteration of searchAfterAndBulkCreate due to empty sort ids and 0 total hits', async () => { const sampleParams = sampleRuleAlertParams(); - mockService.callCluster.mockReturnValueOnce({ + mockService.callCluster.mockResolvedValueOnce({ took: 100, errors: false, items: [ @@ -256,7 +252,7 @@ describe('searchAfterAndBulkCreate', () => { const sampleParams = sampleRuleAlertParams(10); const someGuids = Array.from({ length: 4 }).map(x => uuid.v4()); mockService.callCluster - .mockReturnValueOnce({ + .mockResolvedValueOnce({ took: 100, errors: false, items: [ @@ -270,7 +266,7 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockReturnValueOnce(sampleDocSearchResultsNoSortId()); + .mockResolvedValueOnce(sampleDocSearchResultsNoSortId()); const { success, createdSignalsCount } = await searchAfterAndBulkCreate({ someResult: repeatedSearchResultsWithSortId(4, 1, someGuids), ruleParams: sampleParams, @@ -301,7 +297,7 @@ describe('searchAfterAndBulkCreate', () => { const sampleParams = sampleRuleAlertParams(10); const someGuids = Array.from({ length: 4 }).map(x => uuid.v4()); mockService.callCluster - .mockReturnValueOnce({ + .mockResolvedValueOnce({ took: 100, errors: false, items: [ @@ -315,7 +311,7 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockReturnValueOnce(sampleEmptyDocSearchResults()); + .mockResolvedValueOnce(sampleEmptyDocSearchResults()); const { success, createdSignalsCount } = await searchAfterAndBulkCreate({ someResult: repeatedSearchResultsWithSortId(4, 1, someGuids), ruleParams: sampleParams, @@ -346,7 +342,7 @@ describe('searchAfterAndBulkCreate', () => { const sampleParams = sampleRuleAlertParams(10); const someGuids = Array.from({ length: 4 }).map(x => uuid.v4()); mockService.callCluster - .mockReturnValueOnce({ + .mockResolvedValueOnce({ took: 100, errors: false, items: [ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index 03fb5832fdf42..31b407da111ea 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -5,11 +5,10 @@ */ import moment from 'moment'; -import { savedObjectsClientMock } from 'src/core/server/mocks'; import { loggerMock } from 'src/core/server/logging/logger.mock'; import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; import { signalRulesAlertType } from './signal_rule_alert_type'; -import { AlertInstance } from '../../../../../../../plugins/alerting/server'; +import { alertsMock, AlertServicesMock } from '../../../../../../../plugins/alerting/server/mocks'; import { ruleStatusServiceFactory } from './rule_status_service'; import { getGapBetweenRuns } from './utils'; import { RuleExecutorOptions } from './types'; @@ -28,18 +27,9 @@ jest.mock('../notifications/schedule_notification_actions'); jest.mock('./find_ml_signals'); jest.mock('./bulk_create_ml_signals'); -const getPayload = ( - ruleAlert: RuleAlertType, - alertInstanceFactoryMock: () => AlertInstance, - savedObjectsClient: ReturnType<typeof savedObjectsClientMock.create>, - callClusterMock: jest.Mock -) => ({ +const getPayload = (ruleAlert: RuleAlertType, services: AlertServicesMock) => ({ alertId: ruleAlert.id, - services: { - savedObjectsClient, - alertInstanceFactory: alertInstanceFactoryMock, - callCluster: callClusterMock, - }, + services, params: { ...ruleAlert.params, actions: [], @@ -78,24 +68,14 @@ describe('rules_notification_alert_type', () => { modulesProvider: jest.fn(), resultsServiceProvider: jest.fn(), }; - let payload: RuleExecutorOptions; + let payload: jest.Mocked<RuleExecutorOptions>; let alert: ReturnType<typeof signalRulesAlertType>; - let alertInstanceMock: Record<string, jest.Mock>; - let alertInstanceFactoryMock: () => AlertInstance; - let savedObjectsClient: ReturnType<typeof savedObjectsClientMock.create>; let logger: ReturnType<typeof loggerMock.create>; - let callClusterMock: jest.Mock; + let alertServices: AlertServicesMock; let ruleStatusService: Record<string, jest.Mock>; beforeEach(() => { - alertInstanceMock = { - scheduleActions: jest.fn(), - replaceState: jest.fn(), - }; - alertInstanceMock.replaceState.mockReturnValue(alertInstanceMock); - alertInstanceFactoryMock = jest.fn().mockReturnValue(alertInstanceMock); - callClusterMock = jest.fn(); - savedObjectsClient = savedObjectsClientMock.create(); + alertServices = alertsMock.createAlertServices(); logger = loggerMock.create(); ruleStatusService = { success: jest.fn(), @@ -111,20 +91,20 @@ describe('rules_notification_alert_type', () => { searchAfterTimes: [], createdSignalsCount: 10, }); - callClusterMock.mockResolvedValue({ + alertServices.callCluster.mockResolvedValue({ hits: { total: { value: 10 }, }, }); const ruleAlert = getResult(); - savedObjectsClient.get.mockResolvedValue({ + alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', references: [], attributes: ruleAlert, }); - payload = getPayload(ruleAlert, alertInstanceFactoryMock, savedObjectsClient, callClusterMock); + payload = getPayload(ruleAlert, alertServices); alert = signalRulesAlertType({ logger, @@ -164,7 +144,7 @@ describe('rules_notification_alert_type', () => { }, ]; - savedObjectsClient.get.mockResolvedValue({ + alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', references: [], @@ -195,7 +175,7 @@ describe('rules_notification_alert_type', () => { }, ]; - savedObjectsClient.get.mockResolvedValue({ + alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', references: [], @@ -214,12 +194,7 @@ describe('rules_notification_alert_type', () => { describe('ML rule', () => { it('should throw an error if ML plugin was not available', async () => { const ruleAlert = getMlResult(); - payload = getPayload( - ruleAlert, - alertInstanceFactoryMock, - savedObjectsClient, - callClusterMock - ); + payload = getPayload(ruleAlert, alertServices); alert = signalRulesAlertType({ logger, version, @@ -235,12 +210,7 @@ describe('rules_notification_alert_type', () => { it('should throw an error if machineLearningJobId or anomalyThreshold was not null', async () => { const ruleAlert = getMlResult(); ruleAlert.params.anomalyThreshold = undefined; - payload = getPayload( - ruleAlert, - alertInstanceFactoryMock, - savedObjectsClient, - callClusterMock - ); + payload = getPayload(ruleAlert, alertServices); await alert.executor(payload); expect(logger.error).toHaveBeenCalled(); expect(logger.error.mock.calls[0][0]).toContain( @@ -250,12 +220,7 @@ describe('rules_notification_alert_type', () => { it('should throw an error if Machine learning job summary was null', async () => { const ruleAlert = getMlResult(); - payload = getPayload( - ruleAlert, - alertInstanceFactoryMock, - savedObjectsClient, - callClusterMock - ); + payload = getPayload(ruleAlert, alertServices); jobsSummaryMock.mockResolvedValue([]); await alert.executor(payload); expect(logger.warn).toHaveBeenCalled(); @@ -268,12 +233,7 @@ describe('rules_notification_alert_type', () => { it('should log an error if Machine learning job was not started', async () => { const ruleAlert = getMlResult(); - payload = getPayload( - ruleAlert, - alertInstanceFactoryMock, - savedObjectsClient, - callClusterMock - ); + payload = getPayload(ruleAlert, alertServices); jobsSummaryMock.mockResolvedValue([ { id: 'some_job_id', @@ -297,12 +257,7 @@ describe('rules_notification_alert_type', () => { it('should not call ruleStatusService.success if no anomalies were found', async () => { const ruleAlert = getMlResult(); - payload = getPayload( - ruleAlert, - alertInstanceFactoryMock, - savedObjectsClient, - callClusterMock - ); + payload = getPayload(ruleAlert, alertServices); jobsSummaryMock.mockResolvedValue([]); (findMlSignals as jest.Mock).mockResolvedValue({ hits: { @@ -320,12 +275,7 @@ describe('rules_notification_alert_type', () => { it('should call ruleStatusService.success if signals were created', async () => { const ruleAlert = getMlResult(); - payload = getPayload( - ruleAlert, - alertInstanceFactoryMock, - savedObjectsClient, - callClusterMock - ); + payload = getPayload(ruleAlert, alertServices); jobsSummaryMock.mockResolvedValue([ { id: 'some_job_id', @@ -360,13 +310,8 @@ describe('rules_notification_alert_type', () => { id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', }, ]; - payload = getPayload( - ruleAlert, - alertInstanceFactoryMock, - savedObjectsClient, - callClusterMock - ); - savedObjectsClient.get.mockResolvedValue({ + payload = getPayload(ruleAlert, alertServices); + alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', references: [], diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts index 45365b446cbf0..3401d7417ec62 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts @@ -16,17 +16,13 @@ import { sampleBulkCreateErrorResult, sampleDocWithAncestors, } from './__mocks__/es_results'; -import { savedObjectsClientMock } from 'src/core/server/mocks'; import { DEFAULT_SIGNALS_INDEX } from '../../../../common/constants'; import { singleBulkCreate, filterDuplicateRules } from './single_bulk_create'; - -export const mockService = { - callCluster: jest.fn(), - alertInstanceFactory: jest.fn(), - savedObjectsClient: savedObjectsClientMock.create(), -}; +import { alertsMock, AlertServicesMock } from '../../../../../../../plugins/alerting/server/mocks'; describe('singleBulkCreate', () => { + const mockService: AlertServicesMock = alertsMock.createAlertServices(); + beforeEach(() => { jest.clearAllMocks(); }); @@ -135,7 +131,7 @@ describe('singleBulkCreate', () => { test('create successful bulk create', async () => { const sampleParams = sampleRuleAlertParams(); - mockService.callCluster.mockReturnValueOnce({ + mockService.callCluster.mockResolvedValueOnce({ took: 100, errors: false, items: [ @@ -169,7 +165,7 @@ describe('singleBulkCreate', () => { test('create successful bulk create with docs with no versioning', async () => { const sampleParams = sampleRuleAlertParams(); - mockService.callCluster.mockReturnValueOnce({ + mockService.callCluster.mockResolvedValueOnce({ took: 100, errors: false, items: [ @@ -203,7 +199,7 @@ describe('singleBulkCreate', () => { test('create unsuccessful bulk create due to empty search results', async () => { const sampleParams = sampleRuleAlertParams(); - mockService.callCluster.mockReturnValue(false); + mockService.callCluster.mockResolvedValue(false); const { success, createdItemsCount } = await singleBulkCreate({ someResult: sampleEmptyDocSearchResults(), ruleParams: sampleParams, @@ -230,7 +226,7 @@ describe('singleBulkCreate', () => { test('create successful bulk create when bulk create has duplicate errors', async () => { const sampleParams = sampleRuleAlertParams(); const sampleSearchResult = sampleDocSearchResultsNoSortId; - mockService.callCluster.mockReturnValue(sampleBulkCreateDuplicateResult); + mockService.callCluster.mockResolvedValue(sampleBulkCreateDuplicateResult); const { success, createdItemsCount } = await singleBulkCreate({ someResult: sampleSearchResult(), ruleParams: sampleParams, @@ -259,7 +255,7 @@ describe('singleBulkCreate', () => { test('create successful bulk create when bulk create has multiple error statuses', async () => { const sampleParams = sampleRuleAlertParams(); const sampleSearchResult = sampleDocSearchResultsNoSortId; - mockService.callCluster.mockReturnValue(sampleBulkCreateErrorResult); + mockService.callCluster.mockResolvedValue(sampleBulkCreateErrorResult); const { success, createdItemsCount } = await singleBulkCreate({ someResult: sampleSearchResult(), ruleParams: sampleParams, @@ -354,7 +350,7 @@ describe('singleBulkCreate', () => { test('create successful and returns proper createdItemsCount', async () => { const sampleParams = sampleRuleAlertParams(); - mockService.callCluster.mockReturnValue(sampleBulkCreateDuplicateResult); + mockService.callCluster.mockResolvedValue(sampleBulkCreateDuplicateResult); const { success, createdItemsCount } = await singleBulkCreate({ someResult: sampleDocSearchResultsNoSortId(), ruleParams: sampleParams, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts index fc33d0e15e43f..4373a35cac0c5 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts @@ -10,7 +10,7 @@ import { AlertServices } from '../../../../../../../plugins/alerting/server'; import { SignalSearchResponse, BulkResponse } from './types'; import { RuleAlertAction } from '../../../../common/detection_engine/types'; import { RuleTypeParams, RefreshTypes } from '../types'; -import { generateId, makeFloatString } from './utils'; +import { generateId, makeFloatString, errorAggregator } from './utils'; import { buildBulkBody } from './build_bulk_body'; import { Logger } from '../../../../../../../../src/core/server'; @@ -134,17 +134,10 @@ export const singleBulkCreate = async ({ logger.debug(`took property says bulk took: ${response.took} milliseconds`); if (response.errors) { - const itemsWithErrors = response.items.filter(item => item.create.error); - const errorCountsByStatus = countBy(itemsWithErrors, item => item.create.status); - delete errorCountsByStatus['409']; // Duplicate signals are expected - - if (!isEmpty(errorCountsByStatus)) { + const errorCountByMessage = errorAggregator(response, [409]); + if (!isEmpty(errorCountByMessage)) { logger.error( - `[-] bulkResponse had errors with response statuses:counts of...\n${JSON.stringify( - errorCountsByStatus, - null, - 2 - )}` + `[-] bulkResponse had errors with responses of: ${JSON.stringify(errorCountByMessage)}` ); } } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts index 9b726c38d3d96..dbeab70595e4f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts @@ -4,28 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { savedObjectsClientMock } from 'src/core/server/mocks'; import { sampleDocSearchResultsNoSortId, mockLogger, sampleDocSearchResultsWithSortId, } from './__mocks__/es_results'; import { singleSearchAfter } from './single_search_after'; - -export const mockService = { - callCluster: jest.fn(), - alertInstanceFactory: jest.fn(), - savedObjectsClient: savedObjectsClientMock.create(), -}; +import { alertsMock, AlertServicesMock } from '../../../../../../../plugins/alerting/server/mocks'; describe('singleSearchAfter', () => { + const mockService: AlertServicesMock = alertsMock.createAlertServices(); + beforeEach(() => { jest.clearAllMocks(); }); test('if singleSearchAfter works without a given sort id', async () => { let searchAfterSortId; - mockService.callCluster.mockReturnValue(sampleDocSearchResultsNoSortId); + mockService.callCluster.mockResolvedValue(sampleDocSearchResultsNoSortId); await expect( singleSearchAfter({ searchAfterSortId, @@ -41,7 +37,7 @@ describe('singleSearchAfter', () => { }); test('if singleSearchAfter works with a given sort id', async () => { const searchAfterSortId = '1234567891111'; - mockService.callCluster.mockReturnValue(sampleDocSearchResultsWithSortId); + mockService.callCluster.mockResolvedValue(sampleDocSearchResultsWithSortId); const { searchResult } = await singleSearchAfter({ searchAfterSortId, index: [], diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts index 040e32aa0d360..a7556d992d20a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts @@ -59,35 +59,35 @@ export interface SignalSource { }; } +export interface BulkItem { + create: { + _index: string; + _type?: string; + _id: string; + _version: number; + result?: string; + _shards?: { + total: number; + successful: number; + failed: number; + }; + _seq_no?: number; + _primary_term?: number; + status: number; + error?: { + type: string; + reason: string; + index_uuid?: string; + shard: string; + index: string; + }; + }; +} + export interface BulkResponse { took: number; errors: boolean; - items: [ - { - create: { - _index: string; - _type?: string; - _id: string; - _version: number; - result?: string; - _shards?: { - total: number; - successful: number; - failed: number; - }; - _seq_no?: number; - _primary_term?: number; - status: number; - error?: { - type: string; - reason: string; - index_uuid?: string; - shard: string; - index: string; - }; - }; - } - ]; + items: BulkItem[]; } export interface MGetResponse { @@ -169,3 +169,5 @@ export interface RuleAlertAttributes extends AlertAttributes { ruleId: string; }; } + +export type BulkResponseErrorAggregation = Record<string, { count: number; statusCode: number }>; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.test.ts index 873e06fcbb44e..e3a1b0c052aca 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.test.ts @@ -13,8 +13,18 @@ import { parseScheduleDates, getDriftTolerance, getGapBetweenRuns, + errorAggregator, } from './utils'; +import { BulkResponseErrorAggregation } from './types'; + +import { + sampleBulkResponse, + sampleEmptyBulkResponse, + sampleBulkError, + sampleBulkErrorItem, +} from './__mocks__/es_results'; + describe('utils', () => { const anchor = '2020-01-01T06:06:06.666Z'; const unix = moment(anchor).valueOf(); @@ -351,4 +361,206 @@ describe('utils', () => { expect(gap?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); }); }); + + describe('errorAggregator', () => { + test('it should aggregate with an empty object when given an empty bulk response', () => { + const empty = sampleEmptyBulkResponse(); + const aggregated = errorAggregator(empty, []); + const expected: BulkResponseErrorAggregation = {}; + expect(aggregated).toEqual(expected); + }); + + test('it should aggregate with an empty object when given a valid bulk response with no errors', () => { + const validResponse = sampleBulkResponse(); + const aggregated = errorAggregator(validResponse, []); + const expected: BulkResponseErrorAggregation = {}; + expect(aggregated).toEqual(expected); + }); + + test('it should aggregate with a single error when given a single error item', () => { + const singleError = sampleBulkError(); + const aggregated = errorAggregator(singleError, []); + const expected: BulkResponseErrorAggregation = { + 'Invalid call': { + count: 1, + statusCode: 400, + }, + }; + expect(aggregated).toEqual(expected); + }); + + test('it should aggregate two errors with a correct count when given the same two error items', () => { + const twoAggregatedErrors = sampleBulkError(); + const item1 = sampleBulkErrorItem(); + const item2 = sampleBulkErrorItem(); + twoAggregatedErrors.items = [item1, item2]; + const aggregated = errorAggregator(twoAggregatedErrors, []); + const expected: BulkResponseErrorAggregation = { + 'Invalid call': { + count: 2, + statusCode: 400, + }, + }; + expect(aggregated).toEqual(expected); + }); + + test('it should aggregate three errors with a correct count when given the same two error items', () => { + const twoAggregatedErrors = sampleBulkError(); + const item1 = sampleBulkErrorItem(); + const item2 = sampleBulkErrorItem(); + const item3 = sampleBulkErrorItem(); + twoAggregatedErrors.items = [item1, item2, item3]; + const aggregated = errorAggregator(twoAggregatedErrors, []); + const expected: BulkResponseErrorAggregation = { + 'Invalid call': { + count: 3, + statusCode: 400, + }, + }; + expect(aggregated).toEqual(expected); + }); + + test('it should aggregate two distinct errors with the correct count of 1 for each error type', () => { + const twoAggregatedErrors = sampleBulkError(); + const item1 = sampleBulkErrorItem({ status: 400, reason: 'Parse Error' }); + const item2 = sampleBulkErrorItem({ status: 500, reason: 'Bad Network' }); + twoAggregatedErrors.items = [item1, item2]; + const aggregated = errorAggregator(twoAggregatedErrors, []); + const expected: BulkResponseErrorAggregation = { + 'Parse Error': { + count: 1, + statusCode: 400, + }, + 'Bad Network': { + count: 1, + statusCode: 500, + }, + }; + expect(aggregated).toEqual(expected); + }); + + test('it should aggregate two of the same errors with the correct count of 2 for each error type', () => { + const twoAggregatedErrors = sampleBulkError(); + const item1 = sampleBulkErrorItem({ status: 400, reason: 'Parse Error' }); + const item2 = sampleBulkErrorItem({ status: 400, reason: 'Parse Error' }); + const item3 = sampleBulkErrorItem({ status: 500, reason: 'Bad Network' }); + const item4 = sampleBulkErrorItem({ status: 500, reason: 'Bad Network' }); + twoAggregatedErrors.items = [item1, item2, item3, item4]; + const aggregated = errorAggregator(twoAggregatedErrors, []); + const expected: BulkResponseErrorAggregation = { + 'Parse Error': { + count: 2, + statusCode: 400, + }, + 'Bad Network': { + count: 2, + statusCode: 500, + }, + }; + expect(aggregated).toEqual(expected); + }); + + test('it should aggregate three of the same errors with the correct count of 2 for each error type', () => { + const twoAggregatedErrors = sampleBulkError(); + const item1 = sampleBulkErrorItem({ status: 400, reason: 'Parse Error' }); + const item2 = sampleBulkErrorItem({ status: 400, reason: 'Parse Error' }); + const item3 = sampleBulkErrorItem({ status: 500, reason: 'Bad Network' }); + const item4 = sampleBulkErrorItem({ status: 500, reason: 'Bad Network' }); + const item5 = sampleBulkErrorItem({ status: 502, reason: 'Bad Gateway' }); + const item6 = sampleBulkErrorItem({ status: 502, reason: 'Bad Gateway' }); + twoAggregatedErrors.items = [item1, item2, item3, item4, item5, item6]; + const aggregated = errorAggregator(twoAggregatedErrors, []); + const expected: BulkResponseErrorAggregation = { + 'Parse Error': { + count: 2, + statusCode: 400, + }, + 'Bad Network': { + count: 2, + statusCode: 500, + }, + 'Bad Gateway': { + count: 2, + statusCode: 502, + }, + }; + expect(aggregated).toEqual(expected); + }); + + test('it should aggregate a mix of errors with the correct aggregate count of each', () => { + const twoAggregatedErrors = sampleBulkError(); + const item1 = sampleBulkErrorItem({ status: 400, reason: 'Parse Error' }); + const item2 = sampleBulkErrorItem({ status: 500, reason: 'Bad Network' }); + const item3 = sampleBulkErrorItem({ status: 500, reason: 'Bad Network' }); + const item4 = sampleBulkErrorItem({ status: 502, reason: 'Bad Gateway' }); + const item5 = sampleBulkErrorItem({ status: 502, reason: 'Bad Gateway' }); + const item6 = sampleBulkErrorItem({ status: 502, reason: 'Bad Gateway' }); + twoAggregatedErrors.items = [item1, item2, item3, item4, item5, item6]; + const aggregated = errorAggregator(twoAggregatedErrors, []); + const expected: BulkResponseErrorAggregation = { + 'Parse Error': { + count: 1, + statusCode: 400, + }, + 'Bad Network': { + count: 2, + statusCode: 500, + }, + 'Bad Gateway': { + count: 3, + statusCode: 502, + }, + }; + expect(aggregated).toEqual(expected); + }); + + test('it will ignore error single codes such as 409', () => { + const twoAggregatedErrors = sampleBulkError(); + const item1 = sampleBulkErrorItem({ status: 409, reason: 'Conflict Error' }); + const item2 = sampleBulkErrorItem({ status: 409, reason: 'Conflict Error' }); + const item3 = sampleBulkErrorItem({ status: 500, reason: 'Bad Network' }); + const item4 = sampleBulkErrorItem({ status: 502, reason: 'Bad Gateway' }); + const item5 = sampleBulkErrorItem({ status: 502, reason: 'Bad Gateway' }); + const item6 = sampleBulkErrorItem({ status: 502, reason: 'Bad Gateway' }); + twoAggregatedErrors.items = [item1, item2, item3, item4, item5, item6]; + const aggregated = errorAggregator(twoAggregatedErrors, [409]); + const expected: BulkResponseErrorAggregation = { + 'Bad Network': { + count: 1, + statusCode: 500, + }, + 'Bad Gateway': { + count: 3, + statusCode: 502, + }, + }; + expect(aggregated).toEqual(expected); + }); + + test('it will ignore two error codes such as 409 and 502', () => { + const twoAggregatedErrors = sampleBulkError(); + const item1 = sampleBulkErrorItem({ status: 409, reason: 'Conflict Error' }); + const item2 = sampleBulkErrorItem({ status: 409, reason: 'Conflict Error' }); + const item3 = sampleBulkErrorItem({ status: 500, reason: 'Bad Network' }); + const item4 = sampleBulkErrorItem({ status: 502, reason: 'Bad Gateway' }); + const item5 = sampleBulkErrorItem({ status: 502, reason: 'Bad Gateway' }); + const item6 = sampleBulkErrorItem({ status: 502, reason: 'Bad Gateway' }); + twoAggregatedErrors.items = [item1, item2, item3, item4, item5, item6]; + const aggregated = errorAggregator(twoAggregatedErrors, [409, 502]); + const expected: BulkResponseErrorAggregation = { + 'Bad Network': { + count: 1, + statusCode: 500, + }, + }; + expect(aggregated).toEqual(expected); + }); + + test('it will return an empty object given valid inputs and status codes to ignore', () => { + const bulkResponse = sampleBulkResponse(); + const aggregated = errorAggregator(bulkResponse, [409, 502]); + const expected: BulkResponseErrorAggregation = {}; + expect(aggregated).toEqual(expected); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts index 49af310db559f..077d3a9279c5e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts @@ -7,6 +7,7 @@ import { createHash } from 'crypto'; import moment from 'moment'; import dateMath from '@elastic/datemath'; import { parseDuration } from '../../../../../../../plugins/alerting/server'; +import { BulkResponse, BulkResponseErrorAggregation } from './types'; export const generateId = ( docIndex: string, @@ -91,3 +92,45 @@ export const getGapBetweenRuns = ({ }; export const makeFloatString = (num: number): string => Number(num).toFixed(2); + +/** + * Given a BulkResponse this will return an aggregation based on the errors if any exist + * from the BulkResponse. Errors are aggregated on the reason as the unique key. + * + * Example would be: + * { + * 'Parse Error': { + * count: 100, + * statusCode: 400, + * }, + * 'Internal server error': { + * count: 3, + * statusCode: 500, + * } + * } + * If this does not return any errors then you will get an empty object like so: {} + * @param response The bulk response to aggregate based on the error message + * @param ignoreStatusCodes Optional array of status codes to ignore when creating aggregate error messages + * @returns The aggregated example as shown above. + */ +export const errorAggregator = ( + response: BulkResponse, + ignoreStatusCodes: number[] +): BulkResponseErrorAggregation => { + return response.items.reduce<BulkResponseErrorAggregation>((accum, item) => { + if (item.create.error != null && !ignoreStatusCodes.includes(item.create.status)) { + if (accum[item.create.error.reason] == null) { + accum[item.create.error.reason] = { + count: 1, + statusCode: item.create.status, + }; + } else { + accum[item.create.error.reason] = { + count: accum[item.create.error.reason].count + 1, + statusCode: item.create.status, + }; + } + } + return accum; + }, Object.create(null)); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/create_timelines_stream_from_ndjson.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/create_timelines_stream_from_ndjson.ts index 5373570a4f8cc..16654b2863ee5 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/create_timelines_stream_from_ndjson.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/create_timelines_stream_from_ndjson.ts @@ -3,8 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import * as rt from 'io-ts'; import { Transform } from 'stream'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { failure } from 'io-ts/lib/PathReporter'; +import { identity } from 'fp-ts/lib/function'; import { createConcatStream, createSplitStream, @@ -14,26 +18,28 @@ import { parseNdjsonStrings, filterExportedCounts, createLimitStream, -} from '../detection_engine/rules/create_rules_stream_from_ndjson'; -import { importTimelinesSchema } from './routes/schemas/import_timelines_schema'; -import { BadRequestError } from '../detection_engine/errors/bad_request_error'; +} from '../../utils/read_stream/create_stream_from_ndjson'; + import { ImportTimelineResponse } from './routes/utils/import_timelines'; +import { ImportTimelinesSchemaRt } from './routes/schemas/import_timelines_schema'; + +type ErrorFactory = (message: string) => Error; -export const validateTimelines = (): Transform => { - return createMapStream((obj: ImportTimelineResponse) => { - if (!(obj instanceof Error)) { - const validated = importTimelinesSchema.validate(obj); - if (validated.error != null) { - return new BadRequestError(validated.error.message); - } else { - return validated.value; - } - } else { - return obj; - } - }); +export const createPlainError = (message: string) => new Error(message); + +export const throwErrors = (createError: ErrorFactory) => (errors: rt.Errors) => { + throw createError(failure(errors).join('\n')); }; +export const decodeOrThrow = <A, O, I>( + runtimeType: rt.Type<A, O, I>, + createError: ErrorFactory = createPlainError +) => (inputValue: I) => + pipe(runtimeType.decode(inputValue), fold(throwErrors(createError), identity)); + +export const validateTimelines = (): Transform => + createMapStream((obj: ImportTimelineResponse) => decodeOrThrow(ImportTimelinesSchemaRt)(obj)); + export const createTimelinesStreamFromNdJson = (ruleLimit: number) => { return [ createSplitStream('\n'), diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts index 0e73e4bdd6c97..a83c443773302 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts @@ -6,11 +6,16 @@ import { TIMELINE_EXPORT_URL, TIMELINE_IMPORT_URL } from '../../../../../common/constants'; import { requestMock } from '../../../detection_engine/routes/__mocks__'; - +import stream from 'stream'; +const readable = new stream.Readable(); export const getExportTimelinesRequest = () => requestMock.create({ method: 'get', path: TIMELINE_EXPORT_URL, + query: { + file_name: 'mock_export_timeline.ndjson', + exclude_export_details: 'false', + }, body: { ids: ['f0e58720-57b6-11ea-b88d-3f1a31716be8', '890b8ae0-57df-11ea-a7c9-3976b7f1cb37'], }, @@ -22,7 +27,7 @@ export const getImportTimelinesRequest = (filename?: string) => path: TIMELINE_IMPORT_URL, query: { overwrite: false }, body: { - file: { hapi: { filename: filename ?? 'filename.ndjson' } }, + file: { ...readable, hapi: { filename: filename ?? 'filename.ndjson' } }, }, }); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts index fe434b5399212..4eadede40f5d9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts @@ -83,7 +83,7 @@ describe('export timelines', () => { }); describe('request validation', () => { - test('disallows singular id query param', async () => { + test('return validation error for request body', async () => { const request = requestMock.create({ method: 'get', path: TIMELINE_EXPORT_URL, @@ -91,7 +91,26 @@ describe('export timelines', () => { }); const result = server.validate(request); - expect(result.badRequest).toHaveBeenCalledWith('"id" is not allowed'); + expect(result.badRequest.mock.calls[0][0]).toEqual( + 'Invalid value undefined supplied to : { ids: Array<string> }/ids: Array<string>' + ); + }); + + test('return validation error for request params', async () => { + const request = requestMock.create({ + method: 'get', + path: TIMELINE_EXPORT_URL, + body: { id: 'someId' }, + }); + const result = server.validate(request); + + expect(result.badRequest.mock.calls[1][0]).toEqual( + [ + 'Invalid value undefined supplied to : { file_name: string, exclude_export_details: ("true" | "false") }/file_name: string', + 'Invalid value undefined supplied to : { file_name: string, exclude_export_details: ("true" | "false") }/exclude_export_details: ("true" | "false")/0: "true"', + 'Invalid value undefined supplied to : { file_name: string, exclude_export_details: ("true" | "false") }/exclude_export_details: ("true" | "false")/1: "false"', + ].join('\n') + ); }); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts index b8e7be13fff34..fa849c1c325a9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts @@ -7,31 +7,24 @@ import { set as _set } from 'lodash/fp'; import { IRouter } from '../../../../../../../../src/core/server'; import { LegacyServices } from '../../../types'; -import { ExportTimelineRequestParams } from '../types'; -import { - transformError, - buildRouteValidation, - buildSiemResponse, -} from '../../detection_engine/routes/utils'; +import { transformError, buildSiemResponse } from '../../detection_engine/routes/utils'; import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; +import { getExportTimelineByObjectIds } from './utils/export_timelines'; import { - exportTimelinesSchema, exportTimelinesQuerySchema, + exportTimelinesRequestBodySchema, } from './schemas/export_timelines_schema'; - -import { getExportTimelineByObjectIds } from './utils/export_timelines'; +import { buildRouteValidation } from '../../../utils/build_validation/route_validation'; export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['config']) => { router.post( { path: TIMELINE_EXPORT_URL, validate: { - query: buildRouteValidation<ExportTimelineRequestParams['query']>( - exportTimelinesQuerySchema - ), - body: buildRouteValidation<ExportTimelineRequestParams['body']>(exportTimelinesSchema), + query: buildRouteValidation(exportTimelinesQuerySchema), + body: buildRouteValidation(exportTimelinesRequestBodySchema), }, options: { tags: ['access:siem'], @@ -42,6 +35,7 @@ export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['co const siemResponse = buildSiemResponse(response); const savedObjectsClient = context.core.savedObjects.client; const exportSizeLimit = config().get<number>('savedObjects.maxImportExportSize'); + if (request.body?.ids != null && request.body.ids.length > exportSizeLimit) { return siemResponse.error({ statusCode: 400, @@ -51,7 +45,7 @@ export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['co const responseBody = await getExportTimelineByObjectIds({ client: savedObjectsClient, - request, + ids: request.body.ids, }); return response.ok({ diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts index e89aef4c70ecb..352f8f0a355fc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts @@ -334,7 +334,10 @@ describe('import timelines', () => { const result = server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( - 'child "file" fails because ["file" is required]' + [ + 'Invalid value undefined supplied to : { file: (ReadableRt & { hapi: { filename: string } }) }/file: (ReadableRt & { hapi: { filename: string } })/0: ReadableRt', + 'Invalid value undefined supplied to : { file: (ReadableRt & { hapi: { filename: string } }) }/file: (ReadableRt & { hapi: { filename: string } })/1: { hapi: { filename: string } }', + ].join('\n') ); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts index 2b41b4e7843a7..ad7ee28d8ad51 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts @@ -6,8 +6,8 @@ import { extname } from 'path'; import { chunk, omit, set } from 'lodash/fp'; + import { - buildRouteValidation, buildSiemResponse, createBulkErrorObject, BulkError, @@ -23,7 +23,6 @@ import { isBulkError, isImportRegular, ImportTimelineResponse, - ImportTimelinesRequestParams, ImportTimelinesSchema, PromiseFromStreams, } from './utils/import_timelines'; @@ -31,14 +30,14 @@ import { import { IRouter } from '../../../../../../../../src/core/server'; import { TIMELINE_IMPORT_URL } from '../../../../common/constants'; import { SetupPlugins } from '../../../plugin'; -import { importTimelinesPayloadSchema } from './schemas/import_timelines_schema'; +import { ImportTimelinesPayloadSchemaRt } from './schemas/import_timelines_schema'; import { importRulesSchema } from '../../detection_engine/routes/schemas/response/import_rules_schema'; import { LegacyServices } from '../../../types'; import { Timeline } from '../saved_object'; import { validate } from '../../detection_engine/routes/rules/validate'; import { FrameworkRequest } from '../../framework'; - +import { buildRouteValidation } from '../../../utils/build_validation/route_validation'; const CHUNK_PARSED_OBJECT_SIZE = 10; const timelineLib = new Timeline(); @@ -52,9 +51,7 @@ export const importTimelinesRoute = ( { path: `${TIMELINE_IMPORT_URL}`, validate: { - body: buildRouteValidation<ImportTimelinesRequestParams['body']>( - importTimelinesPayloadSchema - ), + body: buildRouteValidation(ImportTimelinesPayloadSchemaRt), }, options: { tags: ['access:siem'], @@ -65,28 +62,30 @@ export const importTimelinesRoute = ( }, }, async (context, request, response) => { - const siemResponse = buildSiemResponse(response); - const savedObjectsClient = context.core.savedObjects.client; - if (!savedObjectsClient) { - return siemResponse.error({ statusCode: 404 }); - } - const { filename } = request.body.file.hapi; + try { + const siemResponse = buildSiemResponse(response); + const savedObjectsClient = context.core.savedObjects.client; + if (!savedObjectsClient) { + return siemResponse.error({ statusCode: 404 }); + } - const fileExtension = extname(filename).toLowerCase(); + const { file } = request.body; + const { filename } = file.hapi; - if (fileExtension !== '.ndjson') { - return siemResponse.error({ - statusCode: 400, - body: `Invalid file extension ${fileExtension}`, - }); - } + const fileExtension = extname(filename).toLowerCase(); - const objectLimit = config().get<number>('savedObjects.maxImportExportSize'); + if (fileExtension !== '.ndjson') { + return siemResponse.error({ + statusCode: 400, + body: `Invalid file extension ${fileExtension}`, + }); + } + + const objectLimit = config().get<number>('savedObjects.maxImportExportSize'); - try { const readStream = createTimelinesStreamFromNdJson(objectLimit); const parsedObjects = await createPromiseFromStreams<PromiseFromStreams[]>([ - request.body.file, + file, ...readStream, ]); const [duplicateIdErrors, uniqueParsedObjects] = getTupleDuplicateErrorsAndUniqueTimeline( @@ -215,6 +214,7 @@ export const importTimelinesRoute = ( } } catch (err) { const error = transformError(err); + const siemResponse = buildSiemResponse(response); return siemResponse.error({ body: error.message, diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts index 04edbbd7046c9..6f8265903b2a7 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts @@ -4,17 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import Joi from 'joi'; +import * as rt from 'io-ts'; -/* eslint-disable @typescript-eslint/camelcase */ -import { ids, exclude_export_details, file_name } from './schemas'; -/* eslint-disable @typescript-eslint/camelcase */ - -export const exportTimelinesSchema = Joi.object({ - ids, -}).min(1); +export const exportTimelinesQuerySchema = rt.type({ + file_name: rt.string, + exclude_export_details: rt.union([rt.literal('true'), rt.literal('false')]), +}); -export const exportTimelinesQuerySchema = Joi.object({ - file_name: file_name.default('export.ndjson'), - exclude_export_details: exclude_export_details.default(false), +export const exportTimelinesRequestBodySchema = rt.type({ + ids: rt.array(rt.string), }); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/import_timelines_schema.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/import_timelines_schema.ts index 61ffa9681c53a..056fdaf0d2515 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/import_timelines_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/import_timelines_schema.ts @@ -3,55 +3,41 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import Joi from 'joi'; -import { - columns, - created, - createdBy, - dataProviders, - dateRange, - description, - eventNotes, - eventType, - favorite, - filters, - globalNotes, - kqlMode, - kqlQuery, - savedObjectId, - savedQueryId, - sort, - title, - updated, - updatedBy, - version, - pinnedEventIds, -} from './schemas'; +import * as rt from 'io-ts'; -export const importTimelinesPayloadSchema = Joi.object({ - file: Joi.object().required(), -}); +import { Readable } from 'stream'; +import { either } from 'fp-ts/lib/Either'; +import { eventNotes, globalNotes, pinnedEventIds } from './schemas'; +import { SavedTimelineRuntimeType } from '../../types'; + +export const ImportTimelinesSchemaRt = rt.intersection([ + SavedTimelineRuntimeType, + rt.type({ + savedObjectId: rt.string, + version: rt.string, + }), + rt.type({ + globalNotes, + eventNotes, + pinnedEventIds, + }), +]); -export const importTimelinesSchema = Joi.object({ - columns, - created, - createdBy, - dataProviders, - dateRange, - description, - eventNotes, - eventType, - filters, - favorite, - globalNotes, - kqlMode, - kqlQuery, - savedObjectId, - savedQueryId, - sort, - title, - updated, - updatedBy, - version, - pinnedEventIds, +const ReadableRt = new rt.Type<Readable, Readable, unknown>( + 'ReadableRt', + (u): u is Readable => u instanceof Readable, + (u, c) => + either.chain(rt.object.validate(u, c), s => { + const d = s as Readable; + return d.readable ? rt.success(d) : rt.failure(u, c); + }), + a => a +); +export const ImportTimelinesPayloadSchemaRt = rt.type({ + file: rt.intersection([ + ReadableRt, + rt.type({ + hapi: rt.type({ filename: rt.string }), + }), + ]), }); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts index fc87a775a9e68..71627363ef0f8 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts @@ -3,156 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import Joi from 'joi'; +import * as runtimeTypes from 'io-ts'; +import { unionWithNullType } from '../../../framework'; +import { SavedNoteRuntimeType } from '../../../note/types'; -const allowEmptyString = Joi.string().allow([null, '']); -const columnHeaderType = allowEmptyString; -export const created = Joi.number().allow(null); -export const createdBy = allowEmptyString; - -export const description = allowEmptyString; -export const end = Joi.number(); -export const eventId = allowEmptyString; -export const eventType = allowEmptyString; - -export const filters = Joi.array() - .items( - Joi.object({ - meta: Joi.object({ - alias: allowEmptyString, - controlledBy: allowEmptyString, - disabled: Joi.boolean().allow(null), - field: allowEmptyString, - formattedValue: allowEmptyString, - index: allowEmptyString, - key: allowEmptyString, - negate: Joi.boolean().allow(null), - params: allowEmptyString, - type: allowEmptyString, - value: allowEmptyString, - }), - exists: allowEmptyString, - match_all: allowEmptyString, - missing: allowEmptyString, - query: allowEmptyString, - range: allowEmptyString, - script: allowEmptyString, - }) - ) - .allow(null); - -const name = allowEmptyString; - -export const noteId = allowEmptyString; -export const note = allowEmptyString; - -export const start = Joi.number(); -export const savedQueryId = allowEmptyString; -export const savedObjectId = allowEmptyString; - -export const timelineId = allowEmptyString; -export const title = allowEmptyString; - -export const updated = Joi.number().allow(null); -export const updatedBy = allowEmptyString; -export const version = allowEmptyString; - -export const columns = Joi.array().items( - Joi.object({ - aggregatable: Joi.boolean().allow(null), - category: allowEmptyString, - columnHeaderType, - description, - example: allowEmptyString, - indexes: allowEmptyString, - id: allowEmptyString, - name, - placeholder: allowEmptyString, - searchable: Joi.boolean().allow(null), - type: allowEmptyString, - }).required() -); -export const dataProviders = Joi.array() - .items( - Joi.object({ - id: allowEmptyString, - name: allowEmptyString, - enabled: Joi.boolean().allow(null), - excluded: Joi.boolean().allow(null), - kqlQuery: allowEmptyString, - queryMatch: Joi.object({ - field: allowEmptyString, - displayField: allowEmptyString, - value: allowEmptyString, - displayValue: allowEmptyString, - operator: allowEmptyString, - }), - and: Joi.array() - .items( - Joi.object({ - id: allowEmptyString, - name, - enabled: Joi.boolean().allow(null), - excluded: Joi.boolean().allow(null), - kqlQuery: allowEmptyString, - queryMatch: Joi.object({ - field: allowEmptyString, - displayField: allowEmptyString, - value: allowEmptyString, - displayValue: allowEmptyString, - operator: allowEmptyString, - }).allow(null), - }) - ) - .allow(null), - }) - ) - .allow(null); -export const dateRange = Joi.object({ - start, - end, -}); -export const favorite = Joi.array().items( - Joi.object({ - keySearch: allowEmptyString, - fullName: allowEmptyString, - userName: allowEmptyString, - favoriteDate: Joi.number(), - }).allow(null) -); -const noteItem = Joi.object({ - noteId, - version, - eventId, - note, - timelineId, - created, - createdBy, - updated, - updatedBy, -}); -export const eventNotes = Joi.array().items(noteItem); -export const globalNotes = Joi.array().items(noteItem); -export const kqlMode = allowEmptyString; -export const kqlQuery = Joi.object({ - filterQuery: Joi.object({ - kuery: Joi.object({ - kind: allowEmptyString, - expression: allowEmptyString, - }).allow(null), - serializedQuery: allowEmptyString, - }).allow(null), -}); -export const pinnedEventIds = Joi.array() - .items(allowEmptyString) - .allow(null); -export const sort = Joi.object({ - columnId: allowEmptyString, - sortDirection: allowEmptyString, -}); -/* eslint-disable @typescript-eslint/camelcase */ - -export const ids = Joi.array().items(allowEmptyString); - -export const exclude_export_details = Joi.boolean(); -export const file_name = allowEmptyString; +export const eventNotes = runtimeTypes.array(unionWithNullType(SavedNoteRuntimeType)); +export const globalNotes = runtimeTypes.array(unionWithNullType(SavedNoteRuntimeType)); +export const pinnedEventIds = runtimeTypes.array(unionWithNullType(runtimeTypes.string)); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/export_timelines.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/export_timelines.ts index 8a28100fbae82..52ee2a891c9bb 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/export_timelines.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/export_timelines.ts @@ -26,12 +26,11 @@ import { import { ExportedTimelines, ExportTimelineSavedObjectsClient, - ExportTimelineRequest, ExportedNotes, TimelineSavedObject, } from '../../types'; +import { transformDataToNdjson } from '../../../../utils/read_stream/create_stream_from_ndjson'; -import { transformDataToNdjson } from '../../../detection_engine/routes/rules/utils'; export type TimelineSavedObjectsClient = Pick< SavedObjectsClient, | 'get' @@ -142,23 +141,17 @@ const getTimelines = async ( const getTimelinesFromObjects = async ( savedObjectsClient: ExportTimelineSavedObjectsClient, - request: ExportTimelineRequest + ids: string[] ): Promise<ExportedTimelines[]> => { - const timelines: TimelineSavedObject[] = await getTimelines(savedObjectsClient, request.body.ids); + const timelines: TimelineSavedObject[] = await getTimelines(savedObjectsClient, ids); // To Do for feature freeze // if (timelines.length !== request.body.ids.length) { // //figure out which is missing to tell user // } const [notes, pinnedEventIds] = await Promise.all([ - Promise.all( - request.body.ids.map(timelineId => getNotesByTimelineId(savedObjectsClient, timelineId)) - ), - Promise.all( - request.body.ids.map(timelineId => - getPinnedEventsByTimelineId(savedObjectsClient, timelineId) - ) - ), + Promise.all(ids.map(timelineId => getNotesByTimelineId(savedObjectsClient, timelineId))), + Promise.all(ids.map(timelineId => getPinnedEventsByTimelineId(savedObjectsClient, timelineId))), ]); const myNotes = notes.reduce<NoteSavedObject[]>( @@ -171,7 +164,7 @@ const getTimelinesFromObjects = async ( [] ); - const myResponse = request.body.ids.reduce<ExportedTimelines[]>((acc, timelineId) => { + const myResponse = ids.reduce<ExportedTimelines[]>((acc, timelineId) => { const myTimeline = timelines.find(t => t.savedObjectId === timelineId); if (myTimeline != null) { const timelineNotes = myNotes.filter(n => n.timelineId === timelineId); @@ -193,11 +186,11 @@ const getTimelinesFromObjects = async ( export const getExportTimelineByObjectIds = async ({ client, - request, + ids, }: { client: ExportTimelineSavedObjectsClient; - request: ExportTimelineRequest; + ids: string[]; }) => { - const timeline = await getTimelinesFromObjects(client, request); + const timeline = await getTimelinesFromObjects(client, ids); return transformDataToNdjson(timeline); }; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts index 35bf86c17db7e..523221192eca4 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts @@ -14,7 +14,7 @@ import { PinnedEventToReturnSavedObjectRuntimeType, PinnedEventSavedObject, } from '../pinned_event/types'; -import { SavedObjectsClient, KibanaRequest } from '../../../../../../../src/core/server'; +import { SavedObjectsClient } from '../../../../../../../src/core/server'; /* * ColumnHeader Types @@ -204,20 +204,9 @@ export const AllTimelineSavedObjectRuntimeType = runtimeTypes.type({ export interface AllTimelineSavedObject extends runtimeTypes.TypeOf<typeof AllTimelineSavedObjectRuntimeType> {} -export interface ExportTimelineRequestParams { - body: { ids: string[] }; - query: { - file_name: string; - exclude_export_details: boolean; - }; -} - -export type ExportTimelineRequest = KibanaRequest< - unknown, - ExportTimelineRequestParams['query'], - ExportTimelineRequestParams['body'], - 'post' ->; +/** + * Import/export timelines + */ export type ExportTimelineSavedObjectsClient = Pick< SavedObjectsClient, diff --git a/x-pack/legacy/plugins/siem/server/utils/build_validation/route_validation.test.ts b/x-pack/legacy/plugins/siem/server/utils/build_validation/route_validation.test.ts new file mode 100644 index 0000000000000..888cd5dfe5390 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/utils/build_validation/route_validation.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { buildRouteValidation } from './route_validation'; +import * as rt from 'io-ts'; +import { RouteValidationResultFactory } from '../../../../../../../src/core/server/http'; + +describe('buildRouteValidation', () => { + const schema = rt.type({ + ids: rt.array(rt.string), + }); + const validationResult: RouteValidationResultFactory = { + ok: jest.fn().mockImplementation(validatedInput => validatedInput), + badRequest: jest.fn().mockImplementation(e => e), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('return validation error', () => { + const input = { id: 'someId' }; + const result = buildRouteValidation(schema)(input, validationResult); + + expect(result).toEqual( + 'Invalid value undefined supplied to : { ids: Array<string> }/ids: Array<string>' + ); + }); + + test('return validated input', () => { + const input = { ids: ['someId'] }; + const result = buildRouteValidation(schema)(input, validationResult); + + expect(result).toEqual(input); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/utils/build_validation/route_validation.ts b/x-pack/legacy/plugins/siem/server/utils/build_validation/route_validation.ts new file mode 100644 index 0000000000000..1281c23cbc89a --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/utils/build_validation/route_validation.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import * as rt from 'io-ts'; +import { failure } from 'io-ts/lib/PathReporter'; +import { + RouteValidationFunction, + RouteValidationResultFactory, + RouteValidationError, +} from '../../../../../../../src/core/server'; + +type RequestValidationResult<T> = + | { + value: T; + error?: undefined; + } + | { + value?: undefined; + error: RouteValidationError; + }; + +export const buildRouteValidation = <T extends rt.Mixed, A = rt.TypeOf<T>>( + schema: T +): RouteValidationFunction<A> => ( + inputValue: unknown, + validationResult: RouteValidationResultFactory +) => + pipe( + schema.decode(inputValue), + fold<rt.Errors, A, RequestValidationResult<A>>( + (errors: rt.Errors) => validationResult.badRequest(failure(errors).join('\n')), + (validatedInput: A) => validationResult.ok(validatedInput) + ) + ); diff --git a/x-pack/legacy/plugins/siem/server/utils/read_stream/create_stream_from_ndjson.test.ts b/x-pack/legacy/plugins/siem/server/utils/read_stream/create_stream_from_ndjson.test.ts new file mode 100644 index 0000000000000..2b5b34edca140 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/utils/read_stream/create_stream_from_ndjson.test.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 { transformDataToNdjson } from './create_stream_from_ndjson'; +import { ImportRuleAlertRest } from '../../lib/detection_engine/types'; +import { sampleRule } from '../../lib/detection_engine/signals/__mocks__/es_results'; + +export const getOutputSample = (): Partial<ImportRuleAlertRest> => ({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', +}); + +export const getSampleAsNdjson = (sample: Partial<ImportRuleAlertRest>): string => { + return `${JSON.stringify(sample)}\n`; +}; + +describe('create_rules_stream_from_ndjson', () => { + describe('transformDataToNdjson', () => { + test('if rules are empty it returns an empty string', () => { + const ruleNdjson = transformDataToNdjson([]); + expect(ruleNdjson).toEqual(''); + }); + + test('single rule will transform with new line ending character for ndjson', () => { + const rule = sampleRule(); + const ruleNdjson = transformDataToNdjson([rule]); + expect(ruleNdjson.endsWith('\n')).toBe(true); + }); + + test('multiple rules will transform with two new line ending characters for ndjson', () => { + const result1 = sampleRule(); + const result2 = sampleRule(); + result2.id = 'some other id'; + result2.rule_id = 'some other id'; + result2.name = 'Some other rule'; + + const ruleNdjson = transformDataToNdjson([result1, result2]); + // this is how we count characters in JavaScript :-) + const count = ruleNdjson.split('\n').length - 1; + expect(count).toBe(2); + }); + + test('you can parse two rules back out without errors', () => { + const result1 = sampleRule(); + const result2 = sampleRule(); + result2.id = 'some other id'; + result2.rule_id = 'some other id'; + result2.name = 'Some other rule'; + + const ruleNdjson = transformDataToNdjson([result1, result2]); + const ruleStrings = ruleNdjson.split('\n'); + const reParsed1 = JSON.parse(ruleStrings[0]); + const reParsed2 = JSON.parse(ruleStrings[1]); + expect(reParsed1).toEqual(result1); + expect(reParsed2).toEqual(result2); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/utils/read_stream/create_stream_from_ndjson.ts b/x-pack/legacy/plugins/siem/server/utils/read_stream/create_stream_from_ndjson.ts new file mode 100644 index 0000000000000..0b7966926b5dd --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/utils/read_stream/create_stream_from_ndjson.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Transform } from 'stream'; +import { has, isString } from 'lodash/fp'; +import { ImportRuleAlertRest } from '../../lib/detection_engine/types'; +import { createMapStream, createFilterStream } from '../../../../../../../src/legacy/utils/streams'; +import { importRulesSchema } from '../../lib/detection_engine/routes/schemas/import_rules_schema'; +import { BadRequestError } from '../../lib/detection_engine/errors/bad_request_error'; + +export interface RulesObjectsExportResultDetails { + /** number of successfully exported objects */ + exportedCount: number; +} + +export const parseNdjsonStrings = (): Transform => { + return createMapStream((ndJsonStr: string) => { + if (isString(ndJsonStr) && ndJsonStr.trim() !== '') { + try { + return JSON.parse(ndJsonStr); + } catch (err) { + return err; + } + } + }); +}; + +export const filterExportedCounts = (): Transform => { + return createFilterStream<ImportRuleAlertRest | RulesObjectsExportResultDetails>( + obj => obj != null && !has('exported_count', obj) + ); +}; + +export const validateRules = (): Transform => { + return createMapStream((obj: ImportRuleAlertRest) => { + if (!(obj instanceof Error)) { + const validated = importRulesSchema.validate(obj); + if (validated.error != null) { + return new BadRequestError(validated.error.message); + } else { + return validated.value; + } + } else { + return obj; + } + }); +}; + +// Adaptation from: saved_objects/import/create_limit_stream.ts +export const createLimitStream = (limit: number): Transform => { + let counter = 0; + return new Transform({ + objectMode: true, + async transform(obj, _, done) { + if (counter >= limit) { + return done(new Error(`Can't import more than ${limit} rules`)); + } + counter++; + done(undefined, obj); + }, + }); +}; + +export const transformDataToNdjson = (data: unknown[]): string => { + if (data.length !== 0) { + const dataString = data.map(rule => JSON.stringify(rule)).join('\n'); + return `${dataString}\n`; + } else { + return ''; + } +}; diff --git a/x-pack/legacy/plugins/tilemap/public/vis_type_enhancers/update_tilemap_settings.js b/x-pack/legacy/plugins/tilemap/public/vis_type_enhancers/update_tilemap_settings.js index 45764016f0311..294bc31e3893e 100644 --- a/x-pack/legacy/plugins/tilemap/public/vis_type_enhancers/update_tilemap_settings.js +++ b/x-pack/legacy/plugins/tilemap/public/vis_type_enhancers/update_tilemap_settings.js @@ -4,20 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import uiRoutes from 'ui/routes'; import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; -import 'ui/vis/map/service_settings'; +import { npSetup } from 'ui/new_platform'; -uiRoutes.addSetupWork(function($injector, serviceSettings) { - const tileMapPluginInfo = xpackInfo.get('features.tilemap'); +const tileMapPluginInfo = xpackInfo.get('features.tilemap'); - if (!tileMapPluginInfo) { - return; - } - - if (!tileMapPluginInfo.license.active || !tileMapPluginInfo.license.valid) { - return; - } +if (tileMapPluginInfo && (tileMapPluginInfo.license.active || tileMapPluginInfo.license.valid)) { + const { serviceSettings } = npSetup.plugins.mapsLegacy; serviceSettings.addQueryParams({ license: tileMapPluginInfo.license.uid }); serviceSettings.disableZoomMessage(); -}); +} diff --git a/x-pack/legacy/plugins/uptime/common/graphql/types.ts b/x-pack/legacy/plugins/uptime/common/graphql/types.ts index c8beb91d807d5..506966ec6b5c9 100644 --- a/x-pack/legacy/plugins/uptime/common/graphql/types.ts +++ b/x-pack/legacy/plugins/uptime/common/graphql/types.ts @@ -15,294 +15,15 @@ export type UnsignedInteger = any; // ==================================================== export interface Query { - /** Get a list of all recorded pings for all monitors */ - allPings: PingResults; - /** Fetches the current state of Uptime monitors for the given parameters. */ getMonitorStates?: MonitorSummaryResult | null; } -export interface PingResults { - /** Total number of matching pings */ - total: UnsignedInteger; - /** Unique list of all locations the query matched */ - locations: string[]; - /** List of pings */ - pings: Ping[]; -} -/** A request sent from a monitor to a host */ -export interface Ping { - /** unique ID for this ping */ - id: string; - /** The timestamp of the ping's creation */ - timestamp: string; - /** The agent that recorded the ping */ - beat?: Beat | null; - - container?: Container | null; - - docker?: Docker | null; - - ecs?: Ecs | null; - - error?: Error | null; - - host?: Host | null; - - http?: Http | null; - - icmp?: Icmp | null; - - kubernetes?: Kubernetes | null; - - meta?: Meta | null; - - monitor?: Monitor | null; - - observer?: Observer | null; - - resolve?: Resolve | null; - - socks5?: Socks5 | null; - - summary?: Summary | null; - - tags?: string | null; - - tcp?: Tcp | null; - - tls?: PingTls | null; - - url?: Url | null; -} -/** An agent for recording a beat */ -export interface Beat { - hostname?: string | null; - - name?: string | null; - - timezone?: string | null; - - type?: string | null; -} - -export interface Container { - id?: string | null; - - image?: ContainerImage | null; - - name?: string | null; - - runtime?: string | null; -} - -export interface ContainerImage { - name?: string | null; - - tag?: string | null; -} - -export interface Docker { - id?: string | null; - - image?: string | null; - - name?: string | null; -} - -export interface Ecs { - version?: string | null; -} - -export interface Error { - code?: number | null; - - message?: string | null; - - type?: string | null; -} - -export interface Host { - architecture?: string | null; - - id?: string | null; - - hostname?: string | null; - - ip?: string | null; - - mac?: string | null; - - name?: string | null; - - os?: Os | null; -} - -export interface Os { - family?: string | null; - - kernel?: string | null; - - platform?: string | null; - - version?: string | null; - - name?: string | null; - - build?: string | null; -} - -export interface Http { - response?: HttpResponse | null; - - rtt?: HttpRtt | null; - - url?: string | null; -} - -export interface HttpResponse { - status_code?: UnsignedInteger | null; - - body?: HttpBody | null; -} - -export interface HttpBody { - /** Size of HTTP response body in bytes */ - bytes?: UnsignedInteger | null; - /** Hash of the HTTP response body */ - hash?: string | null; - /** Response body of the HTTP Response. May be truncated based on client settings. */ - content?: string | null; - /** Byte length of the content string, taking into account multibyte chars. */ - content_bytes?: UnsignedInteger | null; -} - -export interface HttpRtt { - content?: Duration | null; - - response_header?: Duration | null; - - total?: Duration | null; - - validate?: Duration | null; - - validate_body?: Duration | null; - - write_request?: Duration | null; -} /** The monitor's status for a ping */ export interface Duration { us?: UnsignedInteger | null; } -export interface Icmp { - requests?: number | null; - - rtt?: number | null; -} - -export interface Kubernetes { - container?: KubernetesContainer | null; - - namespace?: string | null; - - node?: KubernetesNode | null; - - pod?: KubernetesPod | null; -} - -export interface KubernetesContainer { - image?: string | null; - - name?: string | null; -} - -export interface KubernetesNode { - name?: string | null; -} - -export interface KubernetesPod { - name?: string | null; - - uid?: string | null; -} - -export interface Meta { - cloud?: MetaCloud | null; -} - -export interface MetaCloud { - availability_zone?: string | null; - - instance_id?: string | null; - - instance_name?: string | null; - - machine_type?: string | null; - - project_id?: string | null; - - provider?: string | null; - - region?: string | null; -} - -export interface Monitor { - duration?: Duration | null; - - host?: string | null; - /** The id of the monitor */ - id?: string | null; - /** The IP pinged by the monitor */ - ip?: string | null; - /** The name of the protocol being monitored */ - name?: string | null; - /** The protocol scheme of the monitored host */ - scheme?: string | null; - /** The status of the monitored host */ - status?: string | null; - /** The type of host being monitored */ - type?: string | null; - - check_group?: string | null; -} -/** Metadata added by a proccessor, which is specified in its configuration. */ -export interface Observer { - /** Geolocation data for the agent. */ - geo?: Geo | null; -} -/** Geolocation data added via processors to enrich events. */ -export interface Geo { - /** Name of the city in which the agent is running. */ - city_name?: string | null; - /** The name of the continent on which the agent is running. */ - continent_name?: string | null; - /** ISO designation for the agent's country. */ - country_iso_code?: string | null; - /** The name of the agent's country. */ - country_name?: string | null; - /** The lat/long of the agent. */ - location?: string | null; - /** A name for the host's location, e.g. 'us-east-1' or 'LAX'. */ - name?: string | null; - /** ISO designation of the agent's region. */ - region_iso_code?: string | null; - /** Name of the region hosting the agent. */ - region_name?: string | null; -} - -export interface Resolve { - host?: string | null; - - ip?: string | null; - - rtt?: Duration | null; -} - -export interface Socks5 { - rtt?: Rtt | null; -} - export interface Rtt { connect?: Duration | null; @@ -331,53 +52,10 @@ export interface Location { lon?: number | null; } -export interface Tcp { - port?: number | null; - - rtt?: Rtt | null; -} -/** Contains monitor transmission encryption information. */ -export interface PingTls { - /** The date and time after which the certificate is invalid. */ - certificate_not_valid_after?: string | null; - - certificate_not_valid_before?: string | null; - - certificates?: string | null; - - rtt?: Rtt | null; -} - -export interface Url { - full?: string | null; - - scheme?: string | null; - - domain?: string | null; - - port?: number | null; - - path?: string | null; - - query?: string | null; -} - export interface DocCount { count: UnsignedInteger; } -export interface Snapshot { - counts: SnapshotCount; -} - -export interface SnapshotCount { - up: number; - - down: number; - - total: number; -} - /** The primary object returned for monitor states. */ export interface MonitorSummaryResult { /** Used to go to the next page of results */ @@ -521,24 +199,6 @@ export interface SummaryHistogramPoint { down: number; } -export interface AllPingsQueryArgs { - /** Optional: the direction to sort by. Accepts 'asc' and 'desc'. Defaults to 'desc'. */ - sort?: string | null; - /** Optional: the number of results to return. */ - size?: number | null; - /** Optional: the monitor ID filter. */ - monitorId?: string | null; - /** Optional: the check status to filter by. */ - status?: string | null; - /** The lower limit of the date range. */ - dateRangeStart: string; - /** The upper limit of the date range. */ - dateRangeEnd: string; - /** Optional: agent location to filter by. */ - location?: string | null; - page?: number; -} - export interface GetMonitorStatesQueryArgs { dateRangeStart: string; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/common.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/common.ts index 37101b5b46fd2..9018f4acaa320 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/common.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/common.ts @@ -27,7 +27,13 @@ export const StatesIndexStatusType = t.type({ docCount: t.number, }); +export const DateRangeType = t.type({ + from: t.string, + to: t.string, +}); + export type Summary = t.TypeOf<typeof SummaryType>; export type CheckGeo = t.TypeOf<typeof CheckGeoType>; export type Location = t.TypeOf<typeof LocationType>; export type StatesIndexStatus = t.TypeOf<typeof StatesIndexStatusType>; +export type DateRange = t.TypeOf<typeof DateRangeType>; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/dynamic_settings.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/dynamic_settings.ts index 8dedd4672eeae..985b51891da99 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/dynamic_settings.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/dynamic_settings.ts @@ -6,10 +6,20 @@ import * as t from 'io-ts'; -export const DynamicSettingsType = t.type({ - heartbeatIndices: t.string, +export const CertificatesStatesThresholdType = t.interface({ + warningState: t.number, + errorState: t.number, }); +export const DynamicSettingsType = t.intersection([ + t.type({ + heartbeatIndices: t.string, + }), + t.partial({ + certificatesThresholds: CertificatesStatesThresholdType, + }), +]); + export const DynamicSettingsSaveType = t.intersection([ t.type({ success: t.boolean, @@ -21,7 +31,12 @@ export const DynamicSettingsSaveType = t.intersection([ export type DynamicSettings = t.TypeOf<typeof DynamicSettingsType>; export type DynamicSettingsSaveResponse = t.TypeOf<typeof DynamicSettingsSaveType>; +export type CertificatesStatesThreshold = t.TypeOf<typeof CertificatesStatesThresholdType>; export const defaultDynamicSettings: DynamicSettings = { heartbeatIndices: 'heartbeat-8*', + certificatesThresholds: { + errorState: 7, + warningState: 30, + }, }; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts index 5e3fb2326bdb9..652d60cbe304d 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts @@ -8,5 +8,6 @@ export * from './alerts'; export * from './common'; export * from './monitor'; export * from './overview_filters'; +export * from './ping'; export * from './snapshot'; export * from './dynamic_settings'; diff --git a/x-pack/legacy/plugins/uptime/common/types/ping/histogram.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/ping/histogram.ts similarity index 77% rename from x-pack/legacy/plugins/uptime/common/types/ping/histogram.ts rename to x-pack/legacy/plugins/uptime/common/runtime_types/ping/histogram.ts index 3ae32e15ca55c..2c3b52051be0f 100644 --- a/x-pack/legacy/plugins/uptime/common/types/ping/histogram.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/ping/histogram.ts @@ -28,3 +28,15 @@ export interface HistogramResult { histogram: HistogramDataPoint[]; interval: string; } + +export interface HistogramQueryResult { + key: number; + key_as_string: string; + doc_count: number; + down: { + doc_count: number; + }; + up: { + doc_count: number; + }; +} diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/ping/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/ping/index.ts new file mode 100644 index 0000000000000..a2fc7c1b243ba --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/ping/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './histogram'; +export * from './ping'; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/ping/ping.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/ping/ping.ts new file mode 100644 index 0000000000000..ee14b298f3810 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/ping/ping.ts @@ -0,0 +1,181 @@ +/* + * 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 * as t from 'io-ts'; +import { DateRangeType } from '../common'; + +export const HttpResponseBodyType = t.partial({ + bytes: t.number, + content: t.string, + content_bytes: t.number, + hash: t.string, +}); + +export type HttpResponseBody = t.TypeOf<typeof HttpResponseBodyType>; + +export const TlsType = t.partial({ + certificate_not_valid_after: t.string, + certificate_not_valid_before: t.string, +}); + +export type Tls = t.TypeOf<typeof TlsType>; + +export const MonitorType = t.intersection([ + t.type({ + duration: t.type({ + us: t.number, + }), + id: t.string, + status: t.string, + type: t.string, + }), + t.partial({ + check_group: t.string, + ip: t.string, + name: t.string, + timespan: t.partial({ + gte: t.string, + lte: t.string, + }), + }), +]); + +export type Monitor = t.TypeOf<typeof MonitorType>; + +export const PingType = t.intersection([ + t.type({ + timestamp: t.string, + monitor: MonitorType, + docId: t.string, + }), + t.partial({ + agent: t.intersection([ + t.type({ + ephemeral_id: t.string, + hostname: t.string, + id: t.string, + type: t.string, + version: t.string, + }), + t.partial({ + name: t.string, + }), + ]), + container: t.partial({ + id: t.string, + image: t.partial({ + name: t.string, + tag: t.string, + }), + name: t.string, + runtime: t.string, + }), + ecs: t.partial({ + version: t.string, + }), + error: t.intersection([ + t.partial({ + code: t.string, + id: t.string, + stack_trace: t.string, + type: t.string, + }), + t.type({ + // this is _always_ on the error field + message: t.string, + }), + ]), + http: t.partial({ + request: t.partial({ + body: t.partial({ + bytes: t.number, + content: t.partial({ + text: t.string, + }), + }), + bytes: t.number, + method: t.string, + referrer: t.string, + }), + response: t.partial({ + body: HttpResponseBodyType, + bytes: t.number, + redirects: t.string, + status_code: t.number, + }), + version: t.string, + }), + icmp: t.partial({ + requests: t.number, + rtt: t.partial({ + us: t.number, + }), + }), + kubernetes: t.partial({ + pod: t.partial({ + name: t.string, + uid: t.string, + }), + }), + observer: t.partial({ + geo: t.partial({ + name: t.string, + }), + }), + resolve: t.partial({ + ip: t.string, + rtt: t.partial({ + us: t.number, + }), + }), + summary: t.partial({ + down: t.number, + up: t.number, + }), + tags: t.array(t.string), + tcp: t.partial({ + rtt: t.partial({ + connect: t.partial({ + us: t.number, + }), + }), + }), + tls: TlsType, + // should this be partial? + url: t.partial({ + domain: t.string, + full: t.string, + port: t.number, + scheme: t.string, + }), + }), +]); + +export type Ping = t.TypeOf<typeof PingType>; + +export const PingsResponseType = t.type({ + total: t.number, + locations: t.array(t.string), + pings: t.array(PingType), +}); + +export type PingsResponse = t.TypeOf<typeof PingsResponseType>; + +export const GetPingsParamsType = t.intersection([ + t.type({ + dateRange: DateRangeType, + }), + t.partial({ + index: t.number, + size: t.number, + location: t.string, + monitorId: t.string, + sort: t.string, + status: t.string, + }), +]); + +export type GetPingsParams = t.TypeOf<typeof GetPingsParamsType>; diff --git a/x-pack/legacy/plugins/uptime/common/types/index.ts b/x-pack/legacy/plugins/uptime/common/types/index.ts index 2c39f2a3b7314..fcbb92caf26d5 100644 --- a/x-pack/legacy/plugins/uptime/common/types/index.ts +++ b/x-pack/legacy/plugins/uptime/common/types/index.ts @@ -34,12 +34,4 @@ export interface LocationDurationLine { export interface MonitorDurationResult { /** The average values for the monitor duration. */ locationDurationLines: LocationDurationLine[]; - /** The counts of up/down checks for the monitor. */ - status: StatusData[]; - /** The maximum status doc count in this chart. */ - statusMaxCount: number; - /** The maximum duration value in this chart. */ - durationMaxValue: number; } - -export * from './ping/histogram'; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/charts/monitor_duration.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/charts/monitor_duration.tsx index 7d1cb08cb8b1c..40480905350af 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/charts/monitor_duration.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/charts/monitor_duration.tsx @@ -6,7 +6,7 @@ import React, { useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { useUrlParams } from '../../../hooks'; +import { useGetUrlParams } from '../../../hooks'; import { getAnomalyRecordsAction, getMLCapabilitiesAction, @@ -28,13 +28,12 @@ interface Props { } export const DurationChart: React.FC<Props> = ({ monitorId }: Props) => { - const [getUrlParams] = useUrlParams(); const { dateRangeStart, dateRangeEnd, absoluteDateRangeStart, absoluteDateRangeEnd, - } = getUrlParams(); + } = useGetUrlParams(); const { durationLines, loading } = useSelector(selectDurationLines); diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/charts/ping_histogram.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/charts/ping_histogram.tsx index 50f91be4ff09f..cf35dbf4e5206 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/charts/ping_histogram.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/charts/ping_histogram.tsx @@ -14,8 +14,8 @@ import { import { getPingHistogram } from '../../../state/actions'; import { selectPingHistogram } from '../../../state/selectors'; import { withResponsiveWrapper, ResponsiveWrapperProps } from '../../higher_order'; -import { GetPingHistogramParams, HistogramResult } from '../../../../common/types'; -import { useUrlParams } from '../../../hooks'; +import { GetPingHistogramParams, HistogramResult } from '../../../../common/runtime_types'; +import { useGetUrlParams } from '../../../hooks'; type Props = ResponsiveWrapperProps & Pick<PingHistogramComponentProps, 'height' | 'data' | 'loading'> & @@ -30,14 +30,13 @@ const PingHistogramContainer: React.FC<Props> = ({ loading, esKuery, }) => { - const [getUrlParams] = useUrlParams(); const { absoluteDateRangeStart, absoluteDateRangeEnd, dateRangeStart: dateStart, dateRangeEnd: dateEnd, statusFilter, - } = getUrlParams(); + } = useGetUrlParams(); useEffect(() => { loadData({ monitorId, dateStart, dateEnd, statusFilter, filters: esKuery }); diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/charts/snapshot_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/charts/snapshot_container.tsx index ac8ff13d1edce..39ead242527f8 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/charts/snapshot_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/charts/snapshot_container.tsx @@ -6,7 +6,7 @@ import React, { useEffect } from 'react'; import { connect } from 'react-redux'; -import { useUrlParams } from '../../../hooks'; +import { useGetUrlParams } from '../../../hooks'; import { AppState } from '../../../state'; import { getSnapshotCountAction } from '../../../state/actions'; import { SnapshotComponent } from '../../functional/snapshot'; @@ -54,8 +54,7 @@ export const Container: React.FC<Props> = ({ esKuery, loadSnapshotCount, }: Props) => { - const [getUrlParams] = useUrlParams(); - const { dateRangeStart, dateRangeEnd, statusFilter } = getUrlParams(); + const { dateRangeStart, dateRangeEnd, statusFilter } = useGetUrlParams(); useEffect(() => { loadSnapshotCount({ dateRangeStart, dateRangeEnd, filters: esKuery, statusFilter }); diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/empty_state/empty_state.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/empty_state/empty_state.tsx index b383a696095a3..55c92e70b6066 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/empty_state/empty_state.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/empty_state/empty_state.tsx @@ -4,20 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect } from 'react'; +import React, { useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { indexStatusAction } from '../../../state/actions'; import { indexStatusSelector } from '../../../state/selectors'; import { EmptyStateComponent } from '../../functional/empty_state/empty_state'; +import { UptimeRefreshContext } from '../../../contexts'; export const EmptyState: React.FC = ({ children }) => { const { data, loading, error } = useSelector(indexStatusSelector); + const { lastRefresh } = useContext(UptimeRefreshContext); const dispatch = useDispatch(); useEffect(() => { dispatch(indexStatusAction.get()); - }, [dispatch]); + }, [dispatch, lastRefresh]); return ( <EmptyStateComponent diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/index.ts b/x-pack/legacy/plugins/uptime/public/components/connected/index.ts index 7e442cbe850ba..be3cc5b65aa0e 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/index.ts +++ b/x-pack/legacy/plugins/uptime/public/components/connected/index.ts @@ -13,5 +13,6 @@ export { MonitorStatusDetails } from './monitor/status_details_container'; export { MonitorStatusBar } from './monitor/status_bar_container'; export { MonitorListDrawer } from './monitor/list_drawer_container'; export { MonitorListActionsPopover } from './monitor/drawer_popover_container'; +export { PingList, PingListProps } from './pings'; export { DurationChart } from './charts/monitor_duration'; export { EmptyState } from './empty_state/empty_state'; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/list_drawer_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/list_drawer_container.tsx index ceeaa7026059f..70da62d5833af 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/list_drawer_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/list_drawer_container.tsx @@ -11,7 +11,7 @@ import { monitorDetailsSelector } from '../../../state/selectors'; import { MonitorDetailsActionPayload } from '../../../state/actions/types'; import { getMonitorDetailsAction } from '../../../state/actions/monitor'; import { MonitorListDrawerComponent } from '../../functional/monitor_list/monitor_list_drawer/monitor_list_drawer'; -import { useUrlParams } from '../../../hooks'; +import { useGetUrlParams } from '../../../hooks'; import { MonitorSummary } from '../../../../common/graphql/types'; import { MonitorDetails } from '../../../../common/runtime_types/monitor'; @@ -24,8 +24,7 @@ interface ContainerProps { const Container: React.FC<ContainerProps> = ({ summary, loadMonitorDetails, monitorDetails }) => { const monitorId = summary?.monitor_id; - const [getUrlParams] = useUrlParams(); - const { dateRangeStart: dateStart, dateRangeEnd: dateEnd } = getUrlParams(); + const { dateRangeStart: dateStart, dateRangeEnd: dateEnd } = useGetUrlParams(); useEffect(() => { loadMonitorDetails({ diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx index dd6f7a89cf9a3..3a96aa7c0275b 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx @@ -11,8 +11,8 @@ import { AppState } from '../../../state'; import { monitorLocationsSelector, monitorStatusSelector } from '../../../state/selectors'; import { MonitorStatusBarComponent } from '../../functional/monitor_status_details/monitor_status_bar'; import { getMonitorStatusAction } from '../../../state/actions'; -import { useUrlParams } from '../../../hooks'; -import { Ping } from '../../../../common/graphql/types'; +import { useGetUrlParams } from '../../../hooks'; +import { Ping } from '../../../../common/runtime_types'; import { MonitorLocations } from '../../../../common/runtime_types/monitor'; import { UptimeRefreshContext } from '../../../contexts'; @@ -39,8 +39,7 @@ const Container: React.FC<Props> = ({ }: Props) => { const { lastRefresh } = useContext(UptimeRefreshContext); - const [getUrlParams] = useUrlParams(); - const { dateRangeStart: dateStart, dateRangeEnd: dateEnd } = getUrlParams(); + const { dateRangeStart: dateStart, dateRangeEnd: dateEnd } = useGetUrlParams(); useEffect(() => { loadMonitorStatus({ dateStart, dateEnd, monitorId }); diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_details_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_details_container.tsx index 3ced251dfab8c..9d2e48830fbfe 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_details_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_details_container.tsx @@ -7,7 +7,7 @@ import React, { useContext, useEffect } from 'react'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; -import { useUrlParams } from '../../../hooks'; +import { useGetUrlParams } from '../../../hooks'; import { AppState } from '../../../state'; import { monitorLocationsSelector } from '../../../state/selectors'; import { getMonitorLocationsAction, MonitorLocationsPayload } from '../../../state/actions/monitor'; @@ -36,8 +36,7 @@ export const Container: React.FC<Props> = ({ }: Props) => { const { lastRefresh } = useContext(UptimeRefreshContext); - const [getUrlParams] = useUrlParams(); - const { dateRangeStart: dateStart, dateRangeEnd: dateEnd } = getUrlParams(); + const { dateRangeStart: dateStart, dateRangeEnd: dateEnd } = useGetUrlParams(); useEffect(() => { loadMonitorLocations({ dateStart, dateEnd, monitorId }); diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/pings/index.ts b/x-pack/legacy/plugins/uptime/public/components/connected/pings/index.ts new file mode 100644 index 0000000000000..95ced104e5188 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/pings/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 { PingList, PingListProps } from './ping_list'; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/pings/ping_list.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/pings/ping_list.tsx new file mode 100644 index 0000000000000..5b32a623495f1 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/pings/ping_list.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useSelector, useDispatch } from 'react-redux'; +import React, { useContext, useCallback } from 'react'; +import { selectPingList } from '../../../state/selectors'; +import { getPings } from '../../../state/actions'; +import { GetPingsParams } from '../../../../common/runtime_types'; +import { UptimeSettingsContext } from '../../../contexts'; +import { PingListComponent } from '../../functional'; + +export interface PingListProps { + monitorId: string; +} + +export const PingList = (props: PingListProps) => { + const { + lastRefresh, + pings: { + error, + loading, + pingList: { locations, pings, total }, + }, + } = useSelector(selectPingList); + + const { dateRangeStart: drs, dateRangeEnd: dre } = useContext(UptimeSettingsContext); + + const dispatch = useDispatch(); + const getPingsCallback = useCallback((params: GetPingsParams) => dispatch(getPings(params)), [ + dispatch, + ]); + + return ( + <PingListComponent + dateRange={{ + from: drs, + to: dre, + }} + error={error} + getPings={getPingsCallback} + lastRefresh={lastRefresh} + loading={loading} + locations={locations} + pings={pings} + total={total} + {...props} + /> + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/duration_charts.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/duration_charts.test.tsx index aac84c69d8d7b..57f9ce9abbca6 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/duration_charts.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/duration_charts.test.tsx @@ -43,21 +43,6 @@ describe('MonitorCharts component', () => { ], }, ], - status: [ - { x: 1548697620000, up: 74, down: null, total: 74 }, - { x: 1548697920000, up: 75, down: null, total: 75 }, - { x: 1548698220000, up: 75, down: null, total: 75 }, - { x: 1548698520000, up: 73, down: null, total: 73 }, - { x: 1548698820000, up: 75, down: null, total: 75 }, - { x: 1548699120000, up: 74, down: null, total: 74 }, - { x: 1548699420000, up: 75, down: null, total: 75 }, - { x: 1548699720000, up: 75, down: null, total: 75 }, - { x: 1548700020000, up: 75, down: null, total: 75 }, - { x: 1548700320000, up: 75, down: null, total: 75 }, - { x: 1548700620000, up: 75, down: null, total: 75 }, - ], - statusMaxCount: 75, - durationMaxValue: 6669234, }, }; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx index d149e7a6deb5a..ec2081d715554 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx @@ -9,8 +9,7 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui'; -import { Axis, Chart, Position, timeFormatter, Settings } from '@elastic/charts'; -import { SeriesIdentifier } from '@elastic/charts/dist/chart_types/xy_chart/utils/series'; +import { Axis, Chart, Position, timeFormatter, Settings, SeriesIdentifier } from '@elastic/charts'; import { getChartDateLabel } from '../../../lib/helper'; import { LocationDurationLine } from '../../../../common/types'; import { DurationLineSeriesList } from './duration_line_series_list'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/ping_histogram.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/ping_histogram.tsx index f988afc7fc84d..66e86d6731236 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/ping_histogram.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/ping_histogram.tsx @@ -13,7 +13,7 @@ import moment from 'moment'; import { getChartDateLabel } from '../../../lib/helper'; import { ChartWrapper } from './chart_wrapper'; import { UptimeThemeContext } from '../../../contexts'; -import { HistogramResult } from '../../../../common/types'; +import { HistogramResult } from '../../../../common/runtime_types'; import { useUrlParams } from '../../../hooks'; import { ChartEmptyState } from './chart_empty_state'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/index.ts index 8d0352e01d40e..d82912a6216e8 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/index.ts +++ b/x-pack/legacy/plugins/uptime/public/components/functional/index.ts @@ -15,6 +15,6 @@ export { KueryBarComponent } from './kuery_bar/kuery_bar'; export { MonitorCharts } from './monitor_charts'; export { MonitorList } from './monitor_list'; export { OverviewPageParsingErrorCallout } from './overview_page_parsing_error_callout'; -export { PingList } from './ping_list'; +export { PingListComponent } from './ping_list'; export { PingHistogramComponent } from './charts'; export { StatusPanel } from './status_panel'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_drawer/most_recent_error.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_drawer/most_recent_error.tsx index 036882b49359f..1963a9c852b11 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_drawer/most_recent_error.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_drawer/most_recent_error.tsx @@ -8,7 +8,7 @@ import { EuiText, EuiSpacer } from '@elastic/eui'; import moment from 'moment'; import { i18n } from '@kbn/i18n'; import { MonitorPageLink } from '../monitor_page_link'; -import { useUrlParams } from '../../../../hooks'; +import { useGetUrlParams } from '../../../../hooks'; import { stringifyUrlParams } from '../../../../lib/helper/stringify_url_params'; import { MonitorError } from '../../../../../common/runtime_types'; @@ -30,8 +30,7 @@ interface MostRecentErrorProps { } export const MostRecentError = ({ error, monitorId, timestamp }: MostRecentErrorProps) => { - const [getUrlParams] = useUrlParams(); - const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = getUrlParams(); + const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = useGetUrlParams(); params.selectedPingStatus = 'down'; const linkParameters = stringifyUrlParams(params, true); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_ssl_certificate.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_ssl_certificate.test.tsx index 2eae14301fd4d..57ed09cc30ef1 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_ssl_certificate.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_ssl_certificate.test.tsx @@ -9,11 +9,11 @@ import moment from 'moment'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { EuiBadge } from '@elastic/eui'; import { renderWithIntl } from 'test_utils/enzyme_helpers'; -import { PingTls } from '../../../../../common/graphql/types'; +import { Tls } from '../../../../../common/runtime_types'; import { MonitorSSLCertificate } from '../monitor_status_bar'; describe('MonitorStatusBar component', () => { - let monitorTls: PingTls; + let monitorTls: Tls; beforeEach(() => { const dateInTwoMonths = moment() diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_status.bar.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_status.bar.test.tsx index 0a53eeb89d793..5fd32c808da42 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_status.bar.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_status.bar.test.tsx @@ -8,7 +8,7 @@ import moment from 'moment'; import React from 'react'; import { renderWithIntl } from 'test_utils/enzyme_helpers'; import { MonitorStatusBarComponent } from '../monitor_status_bar'; -import { Ping } from '../../../../../common/graphql/types'; +import { Ping } from '../../../../../common/runtime_types'; describe('MonitorStatusBar component', () => { let monitorStatus: Ping; @@ -16,7 +16,7 @@ describe('MonitorStatusBar component', () => { beforeEach(() => { monitorStatus = { - id: 'id1', + docId: 'few213kl', timestamp: moment(new Date()) .subtract(15, 'm') .toString(), @@ -24,7 +24,9 @@ describe('MonitorStatusBar component', () => { duration: { us: 1234567, }, + id: 'id1', status: 'up', + type: 'http', }, url: { full: 'https://www.example.com/', diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx index c57348c4ab4cd..d92534aecd175 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx @@ -9,14 +9,13 @@ import moment from 'moment'; import { EuiSpacer, EuiText, EuiBadge } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; - -import { PingTls } from '../../../../../common/graphql/types'; +import { Tls } from '../../../../../common/runtime_types'; interface Props { /** * TLS information coming from monitor in ES heartbeat index */ - tls: PingTls | null | undefined; + tls: Tls | null | undefined; } export const MonitorSSLCertificate = ({ tls }: Props) => { diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_status_bar.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_status_bar.tsx index 22e4377944be1..ac3cedc517995 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_status_bar.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_status_bar.tsx @@ -17,7 +17,7 @@ import { import { MonitorSSLCertificate } from './monitor_ssl_certificate'; import * as labels from './translations'; import { StatusByLocations } from './status_by_location'; -import { Ping } from '../../../../../common/graphql/types'; +import { Ping } from '../../../../../common/runtime_types'; import { MonitorLocations } from '../../../../../common/runtime_types'; interface MonitorStatusBarProps { diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/__snapshots__/ping_list.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/__snapshots__/ping_list.test.tsx.snap index 2e59ec5e57337..154ab6399452d 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/__snapshots__/ping_list.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/__snapshots__/ping_list.test.tsx.snap @@ -1,384 +1,349 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`PingList component renders sorted list without errors 1`] = ` -<Fragment> - <EuiPanel> - <EuiTitle - size="xs" +<EuiPanel> + <EuiTitle + size="xs" + > + <h4> + <FormattedMessage + defaultMessage="History" + id="xpack.uptime.pingList.checkHistoryTitle" + values={Object {}} + /> + </h4> + </EuiTitle> + <EuiSpacer + size="s" + /> + <EuiFlexGroup + justifyContent="spaceBetween" + > + <EuiFlexItem + grow={false} > - <h4> - <FormattedMessage - defaultMessage="History" - id="xpack.uptime.pingList.checkHistoryTitle" - values={Object {}} + <EuiFormRow + aria-label="Status" + describedByIds={Array []} + display="row" + fullWidth={false} + hasChildLabel={true} + hasEmptyLabelSpace={false} + label="Status" + labelType="label" + > + <EuiSelect + aria-label="Status" + data-test-subj="xpack.uptime.pingList.statusSelect" + onChange={[Function]} + options={ + Array [ + Object { + "data-test-subj": "xpack.uptime.pingList.statusOptions.all", + "text": "All", + "value": "", + }, + Object { + "data-test-subj": "xpack.uptime.pingList.statusOptions.up", + "text": "Up", + "value": "up", + }, + Object { + "data-test-subj": "xpack.uptime.pingList.statusOptions.down", + "text": "Down", + "value": "down", + }, + ] + } + value="" /> - </h4> - </EuiTitle> - <EuiSpacer - size="s" - /> - <EuiFlexGroup - justifyContent="spaceBetween" - > - <EuiFlexItem - grow={false} + </EuiFormRow> + </EuiFlexItem> + <EuiFlexItem> + <EuiFormRow + aria-label="Location" + describedByIds={Array []} + display="row" + fullWidth={false} + hasChildLabel={true} + hasEmptyLabelSpace={false} + label="Location" + labelType="label" > - <EuiFlexGroup> - <EuiFlexItem - style={ + <EuiSelect + aria-label="Location" + data-test-subj="xpack.uptime.pingList.locationSelect" + onChange={[Function]} + options={ + Array [ Object { - "minWidth": 200, - } - } - > - <EuiFlexGroup> - <EuiFlexItem> - <EuiFormRow - aria-label="Status" - describedByIds={Array []} - display="row" - fullWidth={false} - hasChildLabel={true} - hasEmptyLabelSpace={false} - label="Status" - labelType="label" - > - <EuiSelect - aria-label="Status" - onChange={[Function]} - options={ - Array [ - Object { - "text": "All", - "value": "", - }, - Object { - "text": "Up", - "value": "up", - }, - Object { - "text": "Down", - "value": "down", - }, - ] - } - value="down" - /> - </EuiFormRow> - </EuiFlexItem> - <EuiFlexItem> - <EuiFormRow - aria-label="Location" - describedByIds={Array []} - display="row" - fullWidth={false} - hasChildLabel={true} - hasEmptyLabelSpace={false} - label="Location" - labelType="label" - > - <EuiSelect - aria-label="Location" - onChange={[Function]} - options={ - Array [ - Object { - "text": "All", - "value": "", - }, - Object { - "text": "nyc", - "value": "nyc", - }, - ] - } - value="" - /> - </EuiFormRow> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer - size="s" - /> - <EuiBasicTable - columns={ - Array [ - Object { - "field": "monitor.status", - "name": "Status", - "render": [Function], - }, - Object { - "align": "left", - "field": "observer.geo.name", - "name": "Location", - "render": [Function], + "data-test-subj": "xpack.uptime.pingList.locationOptions.all", + "text": "All", + "value": "", + }, + ] + } + value="" + /> + </EuiFormRow> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer + size="s" + /> + <EuiBasicTable + columns={ + Array [ + Object { + "field": "monitor.status", + "name": "Status", + "render": [Function], + }, + Object { + "align": "left", + "field": "observer.geo.name", + "name": "Location", + "render": [Function], + }, + Object { + "align": "right", + "dataType": "number", + "field": "monitor.ip", + "name": "IP", + }, + Object { + "align": "right", + "field": "monitor.duration.us", + "name": "Duration", + "render": [Function], + }, + Object { + "align": "right", + "field": "error.type", + "name": "Error type", + "render": [Function], + }, + Object { + "align": "right", + "field": "http.response.status_code", + "name": <ForwardRef(styled.span)> + Response code + </ForwardRef(styled.span)>, + "render": [Function], + }, + Object { + "align": "right", + "isExpander": true, + "render": [Function], + "width": "24px", + }, + ] + } + hasActions={true} + isExpandable={true} + itemId="docId" + itemIdToExpandedRowMap={Object {}} + items={ + Array [ + Object { + "docId": "fewjio21", + "error": Object { + "message": "dial tcp 127.0.0.1:9200: connect: connection refused", + "type": "io", }, - Object { - "align": "right", - "dataType": "number", - "field": "monitor.ip", - "name": "IP", + "monitor": Object { + "duration": Object { + "us": 1430, + }, + "id": "auto-tcp-0X81440A68E839814F", + "ip": "127.0.0.1", + "name": "", + "status": "down", + "type": "tcp", }, - Object { - "align": "right", - "field": "monitor.duration.us", - "name": "Duration", - "render": [Function], + "timestamp": "2019-01-28T17:47:08.078Z", + }, + Object { + "docId": "fewjoo21", + "error": Object { + "message": "dial tcp 127.0.0.1:9200: connect: connection refused", + "type": "io", }, - Object { - "align": "right", - "field": "error.type", - "name": "Error type", - "render": [Function], + "monitor": Object { + "duration": Object { + "us": 1370, + }, + "id": "auto-tcp-0X81440A68E839814D", + "ip": "127.0.0.1", + "name": "", + "status": "down", + "type": "tcp", }, - Object { - "align": "right", - "field": "http.response.status_code", - "name": <ForwardRef(styled.span)> - Response code - </ForwardRef(styled.span)>, - "render": [Function], + "timestamp": "2019-01-28T17:47:09.075Z", + }, + Object { + "docId": "fejjio21", + "monitor": Object { + "duration": Object { + "us": 1452, + }, + "id": "auto-tcp-0X81440A68E839814D", + "ip": "127.0.0.1", + "name": "", + "status": "up", + "type": "tcp", }, - Object { - "align": "right", - "isExpander": true, - "render": [Function], - "width": "24px", + "timestamp": "2019-01-28T17:47:06.077Z", + }, + Object { + "docId": "fewzio21", + "error": Object { + "message": "dial tcp 127.0.0.1:9200: connect: connection refused", + "type": "io", }, - ] - } - hasActions={true} - isExpandable={true} - itemId="id" - itemIdToExpandedRowMap={Object {}} - items={ - Array [ - Object { - "error": Object { - "message": "dial tcp 127.0.0.1:9200: connect: connection refused", - "type": "io", + "monitor": Object { + "duration": Object { + "us": 1094, }, - "http": null, - "id": "id1", - "monitor": Object { - "duration": Object { - "us": 1430, - }, - "id": "auto-tcp-0X81440A68E839814C", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "down", - "type": "tcp", - }, - "timestamp": "2019-01-28T17:47:08.078Z", + "id": "auto-tcp-0X81440A68E839814E", + "ip": "127.0.0.1", + "name": "", + "status": "down", + "type": "tcp", }, - Object { - "error": Object { - "message": "dial tcp 127.0.0.1:9200: connect: connection refused", - "type": "io", - }, - "http": null, - "id": "id2", - "monitor": Object { - "duration": Object { - "us": 1370, - }, - "id": "auto-tcp-0X81440A68E839814C", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "down", - "type": "tcp", - }, - "timestamp": "2019-01-28T17:47:09.075Z", + "timestamp": "2019-01-28T17:47:07.075Z", + }, + Object { + "docId": "fewpi321", + "error": Object { + "message": "Get http://localhost:12349/: dial tcp 127.0.0.1:12349: connect: connection refused", + "type": "io", }, - Object { - "error": null, - "http": null, - "id": "id3", - "monitor": Object { - "duration": Object { - "us": 1452, - }, - "id": "auto-tcp-0X81440A68E839814C", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "tcp", + "monitor": Object { + "duration": Object { + "us": 1597, }, - "timestamp": "2019-01-28T17:47:06.077Z", + "id": "auto-http-0X3675F89EF061209G", + "ip": "127.0.0.1", + "name": "", + "status": "down", + "type": "http", }, - Object { - "error": Object { - "message": "dial tcp 127.0.0.1:9200: connect: connection refused", - "type": "io", - }, - "http": null, - "id": "id4", - "monitor": Object { - "duration": Object { - "us": 1094, - }, - "id": "auto-tcp-0X81440A68E839814C", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "down", - "type": "tcp", - }, - "timestamp": "2019-01-28T17:47:07.075Z", + "timestamp": "2019-01-28T17:47:07.074Z", + }, + Object { + "docId": "0ewjio21", + "error": Object { + "message": "dial tcp 127.0.0.1:9200: connect: connection refused", + "type": "io", }, - Object { - "error": Object { - "message": "Get http://localhost:12349/: dial tcp 127.0.0.1:12349: connect: connection refused", - "type": "io", + "monitor": Object { + "duration": Object { + "us": 1699, }, - "http": null, - "id": "id5", - "monitor": Object { - "duration": Object { - "us": 1597, - }, - "id": "auto-http-0X3675F89EF0612091", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "down", - "type": "http", - }, - "timestamp": "2019-01-28T17:47:07.074Z", + "id": "auto-tcp-0X81440A68E839814H", + "ip": "127.0.0.1", + "name": "", + "status": "down", + "type": "tcp", }, - Object { - "error": Object { - "message": "dial tcp 127.0.0.1:9200: connect: connection refused", - "type": "io", - }, - "http": null, - "id": "id6", - "monitor": Object { - "duration": Object { - "us": 1699, - }, - "id": "auto-tcp-0X81440A68E839814C", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "down", - "type": "tcp", - }, - "timestamp": "2019-01-28T17:47:18.080Z", + "timestamp": "2019-01-28T17:47:18.080Z", + }, + Object { + "docId": "3ewjio21", + "error": Object { + "message": "dial tcp 127.0.0.1:9200: connect: connection refused", + "type": "io", }, - Object { - "error": Object { - "message": "dial tcp 127.0.0.1:9200: connect: connection refused", - "type": "io", - }, - "http": null, - "id": "id7", - "monitor": Object { - "duration": Object { - "us": 5384, - }, - "id": "auto-tcp-0X81440A68E839814C", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "down", - "type": "tcp", + "monitor": Object { + "duration": Object { + "us": 5384, }, - "timestamp": "2019-01-28T17:47:19.076Z", + "id": "auto-tcp-0X81440A68E839814I", + "ip": "127.0.0.1", + "name": "", + "status": "down", + "type": "tcp", }, - Object { - "error": Object { - "message": "Get http://localhost:12349/: dial tcp 127.0.0.1:12349: connect: connection refused", - "type": "io", - }, - "http": null, - "id": "id8", - "monitor": Object { - "duration": Object { - "us": 5397, - }, - "id": "auto-http-0X3675F89EF0612091", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "down", - "type": "http", + "timestamp": "2019-01-28T17:47:19.076Z", + }, + Object { + "docId": "fewjip21", + "error": Object { + "message": "Get http://localhost:12349/: dial tcp 127.0.0.1:12349: connect: connection refused", + "type": "io", + }, + "monitor": Object { + "duration": Object { + "us": 5397, }, - "timestamp": "2019-01-28T17:47:19.076Z", + "id": "auto-http-0X3675F89EF061209J", + "ip": "127.0.0.1", + "name": "", + "status": "down", + "type": "http", }, - Object { - "error": null, - "http": Object { - "response": Object { - "status_code": 200, - }, + "timestamp": "2019-01-28T17:47:19.076Z", + }, + Object { + "docId": "fewjio21", + "http": Object { + "response": Object { + "status_code": 200, }, - "id": "id9", - "monitor": Object { - "duration": Object { - "us": 127511, - }, - "id": "auto-http-0X131221E73F825974", - "ip": "172.217.7.4", - "name": "", - "scheme": null, - "status": "up", - "type": "http", + }, + "monitor": Object { + "duration": Object { + "us": 127511, }, - "timestamp": "2019-01-28T17:47:19.077Z", + "id": "auto-tcp-0X81440A68E839814C", + "ip": "172.217.7.4", + "name": "", + "status": "up", + "type": "http", }, - Object { - "error": null, - "http": Object { - "response": Object { - "status_code": 200, - }, + "timestamp": "2019-01-28T17:47:19.077Z", + }, + Object { + "docId": "fewjik81", + "http": Object { + "response": Object { + "status_code": 200, }, - "id": "id10", - "monitor": Object { - "duration": Object { - "us": 287543, - }, - "id": "auto-http-0X9CB71300ABD5A2A8", - "ip": "192.30.253.112", - "name": "", - "scheme": null, - "status": "up", - "type": "http", + }, + "monitor": Object { + "duration": Object { + "us": 287543, }, - "timestamp": "2019-01-28T17:47:19.077Z", + "id": "auto-http-0X131221E73F825974", + "ip": "192.30.253.112", + "name": "", + "status": "up", + "type": "http", }, - ] - } - loading={false} - noItemsMessage="No items found" - onChange={[Function]} - pagination={ - Object { - "initialPageSize": 25, - "pageIndex": 0, - "pageSize": 10, - "pageSizeOptions": Array [ - 10, - 25, - 50, - 100, - ], - "totalItemCount": 9231, - } + "timestamp": "2019-01-28T17:47:19.077Z", + }, + ] + } + loading={false} + noItemsMessage="No items found" + onChange={[Function]} + pagination={ + Object { + "initialPageSize": 10, + "pageIndex": 0, + "pageSize": 10, + "pageSizeOptions": Array [ + 10, + 25, + 50, + 100, + ], + "totalItemCount": 10, } - responsive={true} - tableLayout="fixed" - /> - </EuiPanel> -</Fragment> + } + responsive={true} + tableLayout="fixed" + /> +</EuiPanel> `; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/expanded_row.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/expanded_row.test.tsx index 9dbe48ec5553a..2c1434cfd64bd 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/expanded_row.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/expanded_row.test.tsx @@ -7,15 +7,23 @@ import { mountWithIntl, renderWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { PingListExpandedRowComponent } from '../expanded_row'; -import { Ping } from '../../../../../common/graphql/types'; +import { Ping } from '../../../../../common/runtime_types'; import { DocLinkForBody } from '../doc_link_body'; describe('PingListExpandedRow', () => { let ping: Ping; beforeEach(() => { ping = { - id: '123', + docId: 'fdeio12', timestamp: '19290310', + monitor: { + duration: { + us: 12345, + }, + id: '123', + status: 'down', + type: 'http', + }, http: { response: { body: { @@ -34,7 +42,7 @@ describe('PingListExpandedRow', () => { it('renders error information when an error field is present', () => { ping.error = { - code: 403, + code: '403', message: 'Forbidden', }; expect(shallowWithIntl(<PingListExpandedRowComponent ping={ping} />)).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/ping_list.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/ping_list.test.tsx index 68d285bd0baf1..ec256a886aa16 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/ping_list.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/ping_list.test.tsx @@ -6,211 +6,188 @@ import React from 'react'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { PingResults, Ping } from '../../../../../common/graphql/types'; -import { PingListComponent, AllLocationOption, toggleDetails } from '../ping_list'; +import { PingListComponent, toggleDetails } from '../ping_list'; import { ExpandedRowMap } from '../../monitor_list/types'; +import { Ping, PingsResponse } from '../../../../../common/runtime_types'; describe('PingList component', () => { - let pingList: { allPings: PingResults }; + let response: PingsResponse; beforeEach(() => { - pingList = { - allPings: { - total: 9231, - pings: [ - { - id: 'id1', - timestamp: '2019-01-28T17:47:08.078Z', - http: null, - error: { - message: 'dial tcp 127.0.0.1:9200: connect: connection refused', - type: 'io', - }, - monitor: { - duration: { us: 1430 }, - id: 'auto-tcp-0X81440A68E839814C', - ip: '127.0.0.1', - name: '', - scheme: null, - status: 'down', - type: 'tcp', - }, - }, - { - id: 'id2', - timestamp: '2019-01-28T17:47:09.075Z', - http: null, - error: { - message: 'dial tcp 127.0.0.1:9200: connect: connection refused', - type: 'io', - }, - monitor: { - duration: { us: 1370 }, - id: 'auto-tcp-0X81440A68E839814C', - ip: '127.0.0.1', - name: '', - scheme: null, - status: 'down', - type: 'tcp', - }, - }, - { - id: 'id3', - timestamp: '2019-01-28T17:47:06.077Z', - http: null, - error: null, - monitor: { - duration: { us: 1452 }, - id: 'auto-tcp-0X81440A68E839814C', - ip: '127.0.0.1', - name: '', - scheme: null, - status: 'up', - type: 'tcp', - }, - }, - { - id: 'id4', - timestamp: '2019-01-28T17:47:07.075Z', - http: null, - error: { - message: 'dial tcp 127.0.0.1:9200: connect: connection refused', - type: 'io', - }, - monitor: { - duration: { us: 1094 }, - id: 'auto-tcp-0X81440A68E839814C', - ip: '127.0.0.1', - name: '', - scheme: null, - status: 'down', - type: 'tcp', - }, - }, - { - id: 'id5', - timestamp: '2019-01-28T17:47:07.074Z', - http: null, - error: { - message: - 'Get http://localhost:12349/: dial tcp 127.0.0.1:12349: connect: connection refused', - type: 'io', - }, - monitor: { - duration: { us: 1597 }, - id: 'auto-http-0X3675F89EF0612091', - ip: '127.0.0.1', - name: '', - scheme: null, - status: 'down', - type: 'http', - }, - }, - { - id: 'id6', - timestamp: '2019-01-28T17:47:18.080Z', - http: null, - error: { - message: 'dial tcp 127.0.0.1:9200: connect: connection refused', - type: 'io', - }, - monitor: { - duration: { us: 1699 }, - id: 'auto-tcp-0X81440A68E839814C', - ip: '127.0.0.1', - name: '', - scheme: null, - status: 'down', - type: 'tcp', - }, - }, - { - id: 'id7', - timestamp: '2019-01-28T17:47:19.076Z', - http: null, - error: { - message: 'dial tcp 127.0.0.1:9200: connect: connection refused', - type: 'io', - }, - monitor: { - duration: { us: 5384 }, - id: 'auto-tcp-0X81440A68E839814C', - ip: '127.0.0.1', - name: '', - scheme: null, - status: 'down', - type: 'tcp', - }, - }, - { - id: 'id8', - timestamp: '2019-01-28T17:47:19.076Z', - http: null, - error: { - message: - 'Get http://localhost:12349/: dial tcp 127.0.0.1:12349: connect: connection refused', - type: 'io', - }, - monitor: { - duration: { us: 5397 }, - id: 'auto-http-0X3675F89EF0612091', - ip: '127.0.0.1', - name: '', - scheme: null, - status: 'down', - type: 'http', - }, - }, - { - id: 'id9', - timestamp: '2019-01-28T17:47:19.077Z', - http: { response: { status_code: 200 } }, - error: null, - monitor: { - duration: { us: 127511 }, - id: 'auto-http-0X131221E73F825974', - ip: '172.217.7.4', - name: '', - scheme: null, - status: 'up', - type: 'http', - }, - }, - { - id: 'id10', - timestamp: '2019-01-28T17:47:19.077Z', - http: { response: { status_code: 200 } }, - error: null, - monitor: { - duration: { us: 287543 }, - id: 'auto-http-0X9CB71300ABD5A2A8', - ip: '192.30.253.112', - name: '', - scheme: null, - status: 'up', - type: 'http', - }, - }, - ], - locations: ['nyc'], - }, + response = { + total: 9231, + locations: ['nyc'], + pings: [ + { + docId: 'fewjio21', + timestamp: '2019-01-28T17:47:08.078Z', + error: { + message: 'dial tcp 127.0.0.1:9200: connect: connection refused', + type: 'io', + }, + monitor: { + duration: { us: 1430 }, + id: 'auto-tcp-0X81440A68E839814F', + ip: '127.0.0.1', + name: '', + status: 'down', + type: 'tcp', + }, + }, + { + docId: 'fewjoo21', + timestamp: '2019-01-28T17:47:09.075Z', + error: { + message: 'dial tcp 127.0.0.1:9200: connect: connection refused', + type: 'io', + }, + monitor: { + duration: { us: 1370 }, + id: 'auto-tcp-0X81440A68E839814D', + ip: '127.0.0.1', + name: '', + status: 'down', + type: 'tcp', + }, + }, + { + docId: 'fejjio21', + timestamp: '2019-01-28T17:47:06.077Z', + monitor: { + duration: { us: 1452 }, + id: 'auto-tcp-0X81440A68E839814D', + ip: '127.0.0.1', + name: '', + status: 'up', + type: 'tcp', + }, + }, + { + docId: 'fewzio21', + timestamp: '2019-01-28T17:47:07.075Z', + error: { + message: 'dial tcp 127.0.0.1:9200: connect: connection refused', + type: 'io', + }, + monitor: { + duration: { us: 1094 }, + id: 'auto-tcp-0X81440A68E839814E', + ip: '127.0.0.1', + name: '', + status: 'down', + type: 'tcp', + }, + }, + { + docId: 'fewpi321', + timestamp: '2019-01-28T17:47:07.074Z', + error: { + message: + 'Get http://localhost:12349/: dial tcp 127.0.0.1:12349: connect: connection refused', + type: 'io', + }, + monitor: { + duration: { us: 1597 }, + id: 'auto-http-0X3675F89EF061209G', + ip: '127.0.0.1', + name: '', + status: 'down', + type: 'http', + }, + }, + { + docId: '0ewjio21', + timestamp: '2019-01-28T17:47:18.080Z', + error: { + message: 'dial tcp 127.0.0.1:9200: connect: connection refused', + type: 'io', + }, + monitor: { + duration: { us: 1699 }, + id: 'auto-tcp-0X81440A68E839814H', + ip: '127.0.0.1', + name: '', + status: 'down', + type: 'tcp', + }, + }, + { + docId: '3ewjio21', + timestamp: '2019-01-28T17:47:19.076Z', + error: { + message: 'dial tcp 127.0.0.1:9200: connect: connection refused', + type: 'io', + }, + monitor: { + duration: { us: 5384 }, + id: 'auto-tcp-0X81440A68E839814I', + ip: '127.0.0.1', + name: '', + status: 'down', + type: 'tcp', + }, + }, + { + docId: 'fewjip21', + timestamp: '2019-01-28T17:47:19.076Z', + error: { + message: + 'Get http://localhost:12349/: dial tcp 127.0.0.1:12349: connect: connection refused', + type: 'io', + }, + monitor: { + duration: { us: 5397 }, + id: 'auto-http-0X3675F89EF061209J', + ip: '127.0.0.1', + name: '', + status: 'down', + type: 'http', + }, + }, + { + docId: 'fewjio21', + timestamp: '2019-01-28T17:47:19.077Z', + http: { response: { status_code: 200 } }, + monitor: { + duration: { us: 127511 }, + id: 'auto-tcp-0X81440A68E839814C', + ip: '172.217.7.4', + name: '', + status: 'up', + type: 'http', + }, + }, + { + docId: 'fewjik81', + timestamp: '2019-01-28T17:47:19.077Z', + http: { response: { status_code: 200 } }, + monitor: { + duration: { us: 287543 }, + id: 'auto-http-0X131221E73F825974', + ip: '192.30.253.112', + name: '', + status: 'up', + type: 'http', + }, + }, + ], }; }); it('renders sorted list without errors', () => { - const { allPings } = pingList; const component = shallowWithIntl( <PingListComponent + dateRange={{ + from: 'now-15m', + to: 'now', + }} + getPings={jest.fn()} + lastRefresh={123} loading={false} - data={{ allPings }} - onPageCountChange={jest.fn()} - onPageIndexChange={jest.fn()} - onSelectedLocationChange={(_loc: any[]) => {}} - onSelectedStatusChange={jest.fn()} - pageIndex={0} - pageSize={10} - selectedOption="down" - selectedLocation={AllLocationOption.value} + locations={[]} + monitorId="foo" + pings={response.pings} + total={10} /> ); expect(component).toMatchSnapshot(); @@ -224,13 +201,38 @@ describe('PingList component', () => { beforeEach(() => { itemIdToExpandedRowMap = {}; - pings = pingList.allPings.pings; + pings = response.pings; }); it('should expand an item if empty', () => { const ping = pings[0]; toggleDetails(ping, itemIdToExpandedRowMap, setItemIdToExpandedRowMap); - expect(itemIdToExpandedRowMap).toHaveProperty(ping.id); + expect(itemIdToExpandedRowMap).toMatchInlineSnapshot(` + Object { + "fewjio21": <PingListExpandedRowComponent + ping={ + Object { + "docId": "fewjio21", + "error": Object { + "message": "dial tcp 127.0.0.1:9200: connect: connection refused", + "type": "io", + }, + "monitor": Object { + "duration": Object { + "us": 1430, + }, + "id": "auto-tcp-0X81440A68E839814F", + "ip": "127.0.0.1", + "name": "", + "status": "down", + "type": "tcp", + }, + "timestamp": "2019-01-28T17:47:08.078Z", + } + } + />, + } + `); }); it('should un-expand an item if clicked again', () => { @@ -245,7 +247,31 @@ describe('PingList component', () => { const pingB = pings[1]; toggleDetails(pingA, itemIdToExpandedRowMap, setItemIdToExpandedRowMap); toggleDetails(pingB, itemIdToExpandedRowMap, setItemIdToExpandedRowMap); - expect(itemIdToExpandedRowMap).toHaveProperty(pingB.id); + expect(pingA.docId).not.toEqual(pingB.docId); + expect(itemIdToExpandedRowMap[pingB.docId]).toMatchInlineSnapshot(` + <PingListExpandedRowComponent + ping={ + Object { + "docId": "fewjoo21", + "error": Object { + "message": "dial tcp 127.0.0.1:9200: connect: connection refused", + "type": "io", + }, + "monitor": Object { + "duration": Object { + "us": 1370, + }, + "id": "auto-tcp-0X81440A68E839814D", + "ip": "127.0.0.1", + "name": "", + "status": "down", + "type": "tcp", + }, + "timestamp": "2019-01-28T17:47:09.075Z", + } + } + /> + `); }); }); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/expanded_row.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/expanded_row.tsx index c684235122e34..28b96fcb1bf7b 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/expanded_row.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/expanded_row.tsx @@ -16,14 +16,14 @@ import { } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; -import { Ping, HttpBody } from '../../../../common/graphql/types'; +import { Ping, HttpResponseBody } from '../../../../common/runtime_types'; import { DocLinkForBody } from './doc_link_body'; interface Props { ping: Ping; } -const BodyDescription = ({ body }: { body: HttpBody }) => { +const BodyDescription = ({ body }: { body: HttpResponseBody }) => { const contentBytes = body.content_bytes || 0; const bodyBytes = body.bytes || 0; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/index.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/index.tsx index e57b229dfd973..808f3f90ef015 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/index.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/index.tsx @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './ping_list'; +export { PingListComponent } from './ping_list'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx index 19768c7104e91..934dfd961f9e0 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx @@ -20,109 +20,127 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { get } from 'lodash'; import moment from 'moment'; -import React, { Fragment, useState } from 'react'; +import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; -import { CriteriaWithPagination } from '@elastic/eui/src/components/basic_table/basic_table'; -import { Ping, PingResults } from '../../../../common/graphql/types'; +import { Ping, GetPingsParams, DateRange } from '../../../../common/runtime_types'; import { convertMicrosecondsToMilliseconds as microsToMillis } from '../../../lib/helper'; -import { UptimeGraphQLQueryProps, withUptimeGraphQL } from '../../higher_order'; -import { pingsQuery } from '../../../queries'; import { LocationName } from './location_name'; import { Pagination } from './../monitor_list'; import { PingListExpandedRowComponent } from './expanded_row'; +import { PingListProps } from '../../connected/pings'; -interface PingListQueryResult { - allPings?: PingResults; -} - -interface PingListProps { - onSelectedStatusChange: (status: string | undefined) => void; - onSelectedLocationChange: (location: any) => void; - onPageCountChange: (itemCount: number) => void; - onPageIndexChange: (index: number) => void; - pageSize: number; - pageIndex: number; - selectedOption: string; - selectedLocation: string | undefined; -} - -type Props = UptimeGraphQLQueryProps<PingListQueryResult> & PingListProps; -interface ExpandedRowMap { - [key: string]: JSX.Element; -} - -export const AllLocationOption = { text: 'All', value: '' }; +export const AllLocationOption = { + 'data-test-subj': 'xpack.uptime.pingList.locationOptions.all', + text: 'All', + value: '', +}; export const toggleDetails = ( ping: Ping, - itemIdToExpandedRowMap: ExpandedRowMap, - setItemIdToExpandedRowMap: (update: ExpandedRowMap) => any + expandedRows: Record<string, JSX.Element>, + setExpandedRows: (update: Record<string, JSX.Element>) => any ) => { - // If the user has clicked on the expanded map, close all expanded rows. - if (itemIdToExpandedRowMap[ping.id]) { - setItemIdToExpandedRowMap({}); + // If already expanded, collapse + if (expandedRows[ping.docId]) { + delete expandedRows[ping.docId]; + setExpandedRows({ ...expandedRows }); return; } // Otherwise expand this row - const newItemIdToExpandedRowMap: ExpandedRowMap = {}; - newItemIdToExpandedRowMap[ping.id] = <PingListExpandedRowComponent ping={ping} />; - setItemIdToExpandedRowMap(newItemIdToExpandedRowMap); + setExpandedRows({ + ...expandedRows, + [ping.docId]: <PingListExpandedRowComponent ping={ping} />, + }); }; const SpanWithMargin = styled.span` margin-right: 16px; `; -export const PingListComponent = ({ - data, - loading, - onPageCountChange, - onPageIndexChange, - onSelectedLocationChange, - onSelectedStatusChange, - pageIndex, - pageSize, - selectedOption, - selectedLocation, -}: Props) => { - const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<ExpandedRowMap>({}); +interface Props extends PingListProps { + dateRange: DateRange; + error?: Error; + getPings: (props: GetPingsParams) => void; + lastRefresh: number; + loading: boolean; + locations: string[]; + pings: Ping[]; + total: number; +} + +const DEFAULT_PAGE_SIZE = 10; + +const statusOptions = [ + { + 'data-test-subj': 'xpack.uptime.pingList.statusOptions.all', + text: i18n.translate('xpack.uptime.pingList.statusOptions.allStatusOptionLabel', { + defaultMessage: 'All', + }), + value: '', + }, + { + 'data-test-subj': 'xpack.uptime.pingList.statusOptions.up', + text: i18n.translate('xpack.uptime.pingList.statusOptions.upStatusOptionLabel', { + defaultMessage: 'Up', + }), + value: 'up', + }, + { + 'data-test-subj': 'xpack.uptime.pingList.statusOptions.down', + text: i18n.translate('xpack.uptime.pingList.statusOptions.downStatusOptionLabel', { + defaultMessage: 'Down', + }), + value: 'down', + }, +]; + +export const PingListComponent = (props: Props) => { + const [selectedLocation, setSelectedLocation] = useState<string>(''); + const [status, setStatus] = useState<string>(''); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const [pageIndex, setPageIndex] = useState(0); + const { + dateRange: { from, to }, + error, + getPings, + lastRefresh, + loading, + locations, + monitorId, + pings, + total, + } = props; + + useEffect(() => { + getPings({ + dateRange: { + from, + to, + }, + location: selectedLocation, + monitorId, + index: pageIndex, + size: pageSize, + status: status !== 'all' ? status : '', + }); + }, [from, to, getPings, monitorId, lastRefresh, selectedLocation, pageIndex, pageSize, status]); + + const [expandedRows, setExpandedRows] = useState<Record<string, JSX.Element>>({}); - const statusOptions = [ - { - text: i18n.translate('xpack.uptime.pingList.statusOptions.allStatusOptionLabel', { - defaultMessage: 'All', - }), - value: '', - }, - { - text: i18n.translate('xpack.uptime.pingList.statusOptions.upStatusOptionLabel', { - defaultMessage: 'Up', - }), - value: 'up', - }, - { - text: i18n.translate('xpack.uptime.pingList.statusOptions.downStatusOptionLabel', { - defaultMessage: 'Down', - }), - value: 'down', - }, - ]; - const locations = get<string[]>(data, 'allPings.locations'); const locationOptions = !locations ? [AllLocationOption] : [AllLocationOption].concat( - locations.map(name => { - return { text: name, value: name }; - }) + locations.map(name => ({ + text: name, + 'data-test-subj': `xpack.uptime.pingList.locationOptions.${name}`, + value: name, + })) ); - const pings: Ping[] = data?.allPings?.pings ?? []; - const hasStatus: boolean = pings.reduce( - (hasHttpStatus: boolean, currentPing: Ping) => + (hasHttpStatus: boolean, currentPing) => hasHttpStatus || !!currentPing.http?.response?.status_code, false ); @@ -134,7 +152,7 @@ export const PingListComponent = ({ defaultMessage: 'Status', }), render: (pingStatus: string, item: Ping) => ( - <div> + <div data-test-subj={`xpack.uptime.pingList.ping-${item.docId}`}> <EuiHealth color={pingStatus === 'up' ? 'success' : 'danger'}> {pingStatus === 'up' ? i18n.translate('xpack.uptime.pingList.statusColumnHealthUpLabel', { @@ -189,7 +207,7 @@ export const PingListComponent = ({ name: i18n.translate('xpack.uptime.pingList.errorTypeColumnLabel', { defaultMessage: 'Error type', }), - render: (error: string) => error ?? '-', + render: (errorType: string) => errorType ?? '-', }, // Only add this column is there is any status present in list ...(hasStatus @@ -219,16 +237,16 @@ export const PingListComponent = ({ render: (item: Ping) => { return ( <EuiButtonIcon - onClick={() => toggleDetails(item, itemIdToExpandedRowMap, setItemIdToExpandedRowMap)} - disabled={!item.error && !(item.http?.response?.body?.bytes > 0)} + onClick={() => toggleDetails(item, expandedRows, setExpandedRows)} + disabled={!item.error && !(item.http?.response?.body?.bytes ?? 0 > 0)} aria-label={ - itemIdToExpandedRowMap[item.id] + expandedRows[item.docId] ? i18n.translate('xpack.uptime.pingList.collapseRow', { defaultMessage: 'Collapse', }) : i18n.translate('xpack.uptime.pingList.expandRow', { defaultMessage: 'Expand' }) } - iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'} + iconType={expandedRows[item.docId] ? 'arrowUp' : 'arrowDown'} /> ); }, @@ -236,104 +254,83 @@ export const PingListComponent = ({ ]; const pagination: Pagination = { - initialPageSize: 25, + initialPageSize: DEFAULT_PAGE_SIZE, pageIndex, pageSize, pageSizeOptions: [10, 25, 50, 100], - totalItemCount: data?.allPings?.total ?? pageSize, + /** + * we're not currently supporting pagination in this component + * so the first page is the only page + */ + totalItemCount: total, }; return ( - <Fragment> - <EuiPanel> - <EuiTitle size="xs"> - <h4> - <FormattedMessage - id="xpack.uptime.pingList.checkHistoryTitle" - defaultMessage="History" + <EuiPanel> + <EuiTitle size="xs"> + <h4> + <FormattedMessage id="xpack.uptime.pingList.checkHistoryTitle" defaultMessage="History" /> + </h4> + </EuiTitle> + <EuiSpacer size="s" /> + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiFormRow + label="Status" + aria-label={i18n.translate('xpack.uptime.pingList.statusLabel', { + defaultMessage: 'Status', + })} + > + <EuiSelect + options={statusOptions} + aria-label={i18n.translate('xpack.uptime.pingList.statusLabel', { + defaultMessage: 'Status', + })} + data-test-subj="xpack.uptime.pingList.statusSelect" + value={status} + onChange={selected => { + setStatus(selected.target.value); + }} /> - </h4> - </EuiTitle> - <EuiSpacer size="s" /> - <EuiFlexGroup justifyContent="spaceBetween"> - <EuiFlexItem grow={false}> - <EuiFlexGroup> - <EuiFlexItem style={{ minWidth: 200 }}> - <EuiFlexGroup> - <EuiFlexItem> - <EuiFormRow - label="Status" - aria-label={i18n.translate('xpack.uptime.pingList.statusLabel', { - defaultMessage: 'Status', - })} - > - <EuiSelect - options={statusOptions} - aria-label={i18n.translate('xpack.uptime.pingList.statusLabel', { - defaultMessage: 'Status', - })} - value={selectedOption} - onChange={selected => { - if (typeof selected.target.value === 'string') { - onSelectedStatusChange( - selected.target && selected.target.value !== '' - ? selected.target.value - : undefined - ); - } - }} - /> - </EuiFormRow> - </EuiFlexItem> - <EuiFlexItem> - <EuiFormRow - label="Location" - aria-label={i18n.translate('xpack.uptime.pingList.locationLabel', { - defaultMessage: 'Location', - })} - > - <EuiSelect - options={locationOptions} - value={selectedLocation} - aria-label={i18n.translate('xpack.uptime.pingList.locationLabel', { - defaultMessage: 'Location', - })} - onChange={selected => { - onSelectedLocationChange( - selected.target && selected.target.value !== '' - ? selected.target.value - : null - ); - }} - /> - </EuiFormRow> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer size="s" /> - <EuiBasicTable - loading={loading} - columns={columns} - isExpandable={true} - hasActions={true} - items={pings} - itemId="id" - itemIdToExpandedRowMap={itemIdToExpandedRowMap} - pagination={pagination} - onChange={(criteria: CriteriaWithPagination<Ping>) => { - onPageCountChange(criteria.page!.size); - onPageIndexChange(criteria.page!.index); - }} - /> - </EuiPanel> - </Fragment> + </EuiFormRow> + </EuiFlexItem> + <EuiFlexItem> + <EuiFormRow + label="Location" + aria-label={i18n.translate('xpack.uptime.pingList.locationLabel', { + defaultMessage: 'Location', + })} + > + <EuiSelect + options={locationOptions} + value={selectedLocation} + aria-label={i18n.translate('xpack.uptime.pingList.locationLabel', { + defaultMessage: 'Location', + })} + data-test-subj="xpack.uptime.pingList.locationSelect" + onChange={selected => { + setSelectedLocation(selected.target.value); + }} + /> + </EuiFormRow> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer size="s" /> + <EuiBasicTable + loading={loading} + columns={columns} + error={error?.message} + isExpandable={true} + hasActions={true} + items={pings} + itemId="docId" + itemIdToExpandedRowMap={expandedRows} + pagination={pagination} + onChange={(criteria: any) => { + setPageSize(criteria.page!.size); + setPageIndex(criteria.page!.index); + }} + /> + </EuiPanel> ); }; - -export const PingList = withUptimeGraphQL<PingListQueryResult, PingListProps>( - PingListComponent, - pingsQuery -); diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/confirm_delete.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/confirm_delete.test.tsx.snap index 24ef7eda0d129..d83e45fea1aec 100644 --- a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/confirm_delete.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/confirm_delete.test.tsx.snap @@ -6,6 +6,7 @@ exports[`ML Confirm Job Delete shallow renders without errors 1`] = ` buttonColor="danger" cancelButtonText="Cancel" confirmButtonText="Delete" + data-test-subj="uptimeMLJobDeleteConfirmModel" defaultFocusedButton="confirm" onCancel={[MockFunction]} onConfirm={[MockFunction]} @@ -35,6 +36,7 @@ exports[`ML Confirm Job Delete shallow renders without errors while loading 1`] buttonColor="danger" cancelButtonText="Cancel" confirmButtonText="Delete" + data-test-subj="uptimeMLJobDeleteConfirmModel" defaultFocusedButton="confirm" onCancel={[MockFunction]} onConfirm={[MockFunction]} diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/license_info.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/license_info.test.tsx.snap index 2457488c4facc..fb40a42e47f75 100644 --- a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/license_info.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/license_info.test.tsx.snap @@ -4,6 +4,7 @@ exports[`ShowLicenseInfo renders without errors 1`] = ` Array [ <div class="euiCallOut euiCallOut--primary license-info-trial" + data-test-subj="uptimeMLLicenseInfo" > <div class="euiCallOutHeader" @@ -54,6 +55,7 @@ exports[`ShowLicenseInfo shallow renders without errors 1`] = ` <EuiCallOut className="license-info-trial" color="primary" + data-test-subj="uptimeMLLicenseInfo" iconType="help" title="Start free 14-day trial" > diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap index ead27425c26f3..a83a1d99d7bb0 100644 --- a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap @@ -3,6 +3,7 @@ exports[`ML Flyout component renders without errors 1`] = ` <EuiFlyout closeButtonAriaLabel="Closes this dialog" + data-test-subj="uptimeMLFlyout" hideCloseButton={false} maxWidth={false} onClose={[Function]} @@ -69,6 +70,7 @@ exports[`ML Flyout component renders without errors 1`] = ` grow={false} > <EuiButton + data-test-subj="uptimeMLCreateJobBtn" disabled={true} fill={true} isLoading={false} @@ -99,6 +101,7 @@ exports[`ML Flyout component shows license info if no ml available 1`] = ` > <div class="euiFlyout euiFlyout--small" + data-test-subj="uptimeMLFlyout" role="dialog" tabindex="0" > @@ -137,6 +140,7 @@ exports[`ML Flyout component shows license info if no ml available 1`] = ` > <div class="euiCallOut euiCallOut--primary license-info-trial" + data-test-subj="uptimeMLLicenseInfo" > <div class="euiCallOutHeader" @@ -240,6 +244,7 @@ exports[`ML Flyout component shows license info if no ml available 1`] = ` > <button class="euiButton euiButton--primary euiButton--fill" + data-test-subj="uptimeMLCreateJobBtn" disabled="" type="button" > diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_integerations.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_integerations.test.tsx.snap index ac4630f4e69a8..fa9b59e13c34e 100644 --- a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_integerations.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_integerations.test.tsx.snap @@ -9,6 +9,7 @@ exports[`ML Integrations renders without errors 1`] = ` > <button class="euiButtonEmpty euiButtonEmpty--primary" + data-test-subj="uptimeEnableAnomalyBtn" type="button" > <span diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_manage_job.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_manage_job.test.tsx.snap index 6eb2930c4875a..91bd9fa3d86b5 100644 --- a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_manage_job.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_manage_job.test.tsx.snap @@ -9,6 +9,7 @@ exports[`Manage ML Job renders without errors 1`] = ` > <button class="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--iconRight" + data-test-subj="uptimeManageMLJobBtn" type="button" > <span diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/confirm_delete.tsx b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/confirm_delete.tsx index 6754676765fb6..628f943ef2e08 100644 --- a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/confirm_delete.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/confirm_delete.tsx @@ -25,6 +25,7 @@ export const ConfirmJobDeletion: React.FC<Props> = ({ loading, onConfirm, onCanc confirmButtonText="Delete" buttonColor="danger" defaultFocusedButton="confirm" + data-test-subj="uptimeMLJobDeleteConfirmModel" > {!loading ? ( <p> diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/license_info.tsx b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/license_info.tsx index 92badb4043ed6..fae81177a728c 100644 --- a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/license_info.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/license_info.tsx @@ -13,6 +13,7 @@ export const ShowLicenseInfo = () => { return ( <> <EuiCallOut + data-test-subj="uptimeMLLicenseInfo" className="license-info-trial" title={labels.START_TRAIL} color="primary" diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/manage_ml_job.tsx b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/manage_ml_job.tsx index 29f003437f7cb..ec3e8bb2b9f68 100644 --- a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/manage_ml_job.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/manage_ml_job.tsx @@ -7,13 +7,13 @@ import React, { useContext, useState } from 'react'; import { EuiButtonEmpty, EuiContextMenu, EuiIcon, EuiPopover } from '@elastic/eui'; -import { useParams } from 'react-router-dom'; import { useSelector } from 'react-redux'; import { canDeleteMLJobSelector } from '../../../state/selectors'; import { UptimeSettingsContext } from '../../../contexts'; import * as labels from './translations'; import { getMLJobLinkHref } from './ml_job_link'; -import { useUrlParams } from '../../../hooks'; +import { useGetUrlParams } from '../../../hooks'; +import { useMonitorId } from '../../../hooks/use_monitor'; interface Props { hasMLJob: boolean; @@ -28,14 +28,13 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro const canDeleteMLJob = useSelector(canDeleteMLJobSelector); - const [getUrlParams] = useUrlParams(); - const { dateRangeStart, dateRangeEnd } = getUrlParams(); + const { dateRangeStart, dateRangeEnd } = useGetUrlParams(); - let { monitorId } = useParams(); - monitorId = atob(monitorId || ''); + const monitorId = useMonitorId(); const button = ( <EuiButtonEmpty + data-test-subj={hasMLJob ? 'uptimeManageMLJobBtn' : 'uptimeEnableAnomalyBtn'} iconType={hasMLJob ? 'arrowDown' : 'machineLearningApp'} iconSide={hasMLJob ? 'right' : 'left'} onClick={hasMLJob ? () => setIsPopOverOpen(true) : onEnableJob} @@ -62,6 +61,7 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro }, { name: labels.DISABLE_ANOMALY_DETECTION, + 'data-test-subj': 'uptimeDeleteMLJobBtn', icon: <EuiIcon type="trash" size="m" />, onClick: () => { setIsPopOverOpen(false); @@ -74,7 +74,11 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro return ( <EuiPopover button={button} isOpen={isPopOverOpen} closePopover={() => setIsPopOverOpen(false)}> - <EuiContextMenu initialPanelId={0} panels={panels} /> + <EuiContextMenu + initialPanelId={0} + panels={panels} + data-test-subj="uptimeManageMLContextMenu" + /> </EuiPopover> ); }; diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_flyout.tsx b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_flyout.tsx index fdecfbf20810c..8c3f814e841f7 100644 --- a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_flyout.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_flyout.tsx @@ -39,7 +39,7 @@ export function MLFlyoutView({ isCreatingJob, onClickCreate, onClose, canCreateM const hasPlatinumLicense = license?.getFeature('ml')?.isAvailable; return ( - <EuiFlyout onClose={onClose} size="s"> + <EuiFlyout onClose={onClose} size="s" data-test-subj="uptimeMLFlyout"> <EuiFlyoutHeader> <EuiTitle> <h2>{labels.ENABLE_ANOMALY_DETECTION}</h2> @@ -76,6 +76,7 @@ export function MLFlyoutView({ isCreatingJob, onClickCreate, onClose, canCreateM </EuiFlexItem> <EuiFlexItem grow={false}> <EuiButton + data-test-subj="uptimeMLCreateJobBtn" onClick={() => onClickCreate()} fill isLoading={isCreatingJob} diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_flyout_container.tsx b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_flyout_container.tsx index 9eed24e2810d8..a13de192cc443 100644 --- a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_flyout_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_flyout_container.tsx @@ -6,7 +6,6 @@ import React, { useContext, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { useParams } from 'react-router-dom'; import { canCreateMLJobSelector, hasMLJobSelector, @@ -24,8 +23,9 @@ import { import { MLFlyoutView } from './ml_flyout'; import { ML_JOB_ID } from '../../../../common/constants'; import { UptimeRefreshContext, UptimeSettingsContext } from '../../../contexts'; -import { useUrlParams } from '../../../hooks'; +import { useGetUrlParams } from '../../../hooks'; import { getDynamicSettings } from '../../../state/actions/dynamic_settings'; +import { useMonitorId } from '../../../hooks/use_monitor'; interface Props { onClose: () => void; @@ -41,7 +41,9 @@ const showMLJobNotification = ( ) => { if (success) { notifications.toasts.success({ - title: <p>{labels.JOB_CREATED_SUCCESS_TITLE}</p>, + title: ( + <p data-test-subj="uptimeMLJobSuccessfullyCreated">{labels.JOB_CREATED_SUCCESS_TITLE}</p> + ), body: ( <p> {labels.JOB_CREATED_SUCCESS_MESSAGE} @@ -54,7 +56,7 @@ const showMLJobNotification = ( }); } else { notifications.toasts.danger({ - title: <p>{labels.JOB_CREATION_FAILED}</p>, + title: <p data-test-subj="uptimeMLJobCreationFailed">{labels.JOB_CREATION_FAILED}</p>, body: message ?? <p>{labels.JOB_CREATION_FAILED_MESSAGE}</p>, toastLifeTimeMs: 10000, }); @@ -77,8 +79,7 @@ export const MachineLearningFlyout: React.FC<Props> = ({ onClose }) => { const { refreshApp } = useContext(UptimeRefreshContext); - let { monitorId } = useParams(); - monitorId = atob(monitorId || ''); + const monitorId = useMonitorId(); const canCreateMLJob = useSelector(canCreateMLJobSelector) && heartbeatIndices !== ''; @@ -93,8 +94,7 @@ export const MachineLearningFlyout: React.FC<Props> = ({ onClose }) => { const [isCreatingJob, setIsCreatingJob] = useState(false); - const [getUrlParams] = useUrlParams(); - const { dateRangeStart, dateRangeEnd } = getUrlParams(); + const { dateRangeStart, dateRangeEnd } = useGetUrlParams(); useEffect(() => { if (isCreatingJob && !isMLJobCreating) { diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_integeration.tsx b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_integeration.tsx index a27796167091e..e053ca733cb8c 100644 --- a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_integeration.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_integeration.tsx @@ -5,8 +5,6 @@ */ import React, { useContext, useEffect, useState } from 'react'; - -import { useParams } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import { MachineLearningFlyout } from './ml_flyout_container'; import { @@ -23,6 +21,7 @@ import * as labels from './translations'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { ManageMLJobComponent } from './manage_ml_job'; import { JobStat } from '../../../../../../../plugins/ml/common/types/data_recognizer'; +import { useMonitorId } from '../../../hooks/use_monitor'; export const MLIntegrationComponent = () => { const [isMlFlyoutOpen, setIsMlFlyoutOpen] = useState(false); @@ -32,8 +31,7 @@ export const MLIntegrationComponent = () => { const { notifications } = useKibana(); - let { monitorId } = useParams(); - monitorId = atob(monitorId || ''); + const monitorId = useMonitorId(); const dispatch = useDispatch(); @@ -59,7 +57,7 @@ export const MLIntegrationComponent = () => { if (isConfirmDeleteJobOpen && jobDeletionSuccess?.[getMLJobId(monitorId as string)]?.deleted) { setIsConfirmDeleteJobOpen(false); notifications.toasts.success({ - title: <p>{labels.JOB_DELETION}</p>, + title: <p data-test-subj="uptimeMLJobSuccessfullyDeleted">{labels.JOB_DELETION}</p>, body: <p>{labels.JOB_DELETION_SUCCESS}</p>, toastLifeTimeMs: 3000, }); diff --git a/x-pack/legacy/plugins/uptime/public/components/settings/__tests__/__snapshots__/certificate_form.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/settings/__tests__/__snapshots__/certificate_form.test.tsx.snap new file mode 100644 index 0000000000000..36bc9bb860211 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/settings/__tests__/__snapshots__/certificate_form.test.tsx.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CertificateForm shallow renders expected elements for valid props 1`] = ` +<ContextProvider + value={ + Object { + "history": Object { + "action": "POP", + "block": [Function], + "canGo": [Function], + "createHref": [Function], + "entries": Array [ + Object { + "hash": "", + "key": "TestKeyForTesting", + "pathname": "/", + "search": "", + "state": undefined, + }, + ], + "go": [Function], + "goBack": [Function], + "goForward": [Function], + "index": 0, + "length": 1, + "listen": [Function], + "location": Object { + "hash": "", + "key": "TestKeyForTesting", + "pathname": "/", + "search": "", + "state": undefined, + }, + "push": [Function], + "replace": [Function], + }, + "location": Object { + "hash": "", + "key": "TestKeyForTesting", + "pathname": "/", + "search": "", + "state": undefined, + }, + "match": Object { + "isExact": true, + "params": Object {}, + "path": "/", + "url": "/", + }, + "staticContext": undefined, + } + } +> + <CertificateExpirationForm + fieldErrors={Object {}} + formFields={ + Object { + "certificatesThresholds": Object { + "errorState": 7, + "warningState": 36, + }, + "heartbeatIndices": "heartbeat-8*", + } + } + isDisabled={false} + onChange={[MockFunction]} + /> +</ContextProvider> +`; diff --git a/x-pack/legacy/plugins/uptime/public/components/settings/__tests__/__snapshots__/indices_form.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/settings/__tests__/__snapshots__/indices_form.test.tsx.snap new file mode 100644 index 0000000000000..93151198c0f49 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/settings/__tests__/__snapshots__/indices_form.test.tsx.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CertificateForm shallow renders expected elements for valid props 1`] = ` +<ContextProvider + value={ + Object { + "history": Object { + "action": "POP", + "block": [Function], + "canGo": [Function], + "createHref": [Function], + "entries": Array [ + Object { + "hash": "", + "key": "TestKeyForTesting", + "pathname": "/", + "search": "", + "state": undefined, + }, + ], + "go": [Function], + "goBack": [Function], + "goForward": [Function], + "index": 0, + "length": 1, + "listen": [Function], + "location": Object { + "hash": "", + "key": "TestKeyForTesting", + "pathname": "/", + "search": "", + "state": undefined, + }, + "push": [Function], + "replace": [Function], + }, + "location": Object { + "hash": "", + "key": "TestKeyForTesting", + "pathname": "/", + "search": "", + "state": undefined, + }, + "match": Object { + "isExact": true, + "params": Object {}, + "path": "/", + "url": "/", + }, + "staticContext": undefined, + } + } +> + <IndicesForm + fieldErrors={Object {}} + formFields={ + Object { + "certificatesThresholds": Object { + "errorState": 7, + "warningState": 36, + }, + "heartbeatIndices": "heartbeat-8*", + } + } + isDisabled={false} + onChange={[MockFunction]} + /> +</ContextProvider> +`; diff --git a/x-pack/legacy/plugins/uptime/public/components/settings/__tests__/certificate_form.test.tsx b/x-pack/legacy/plugins/uptime/public/components/settings/__tests__/certificate_form.test.tsx new file mode 100644 index 0000000000000..a3158f3d72445 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/settings/__tests__/certificate_form.test.tsx @@ -0,0 +1,27 @@ +/* + * 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 { CertificateExpirationForm } from '../certificate_form'; +import { shallowWithRouter } from '../../../lib'; + +describe('CertificateForm', () => { + it('shallow renders expected elements for valid props', () => { + expect( + shallowWithRouter( + <CertificateExpirationForm + onChange={jest.fn()} + formFields={{ + heartbeatIndices: 'heartbeat-8*', + certificatesThresholds: { errorState: 7, warningState: 36 }, + }} + fieldErrors={{}} + isDisabled={false} + /> + ) + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/settings/__tests__/indices_form.test.tsx b/x-pack/legacy/plugins/uptime/public/components/settings/__tests__/indices_form.test.tsx new file mode 100644 index 0000000000000..654d51019d4e5 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/settings/__tests__/indices_form.test.tsx @@ -0,0 +1,27 @@ +/* + * 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 { IndicesForm } from '../indices_form'; +import { shallowWithRouter } from '../../../lib'; + +describe('CertificateForm', () => { + it('shallow renders expected elements for valid props', () => { + expect( + shallowWithRouter( + <IndicesForm + onChange={jest.fn()} + formFields={{ + heartbeatIndices: 'heartbeat-8*', + certificatesThresholds: { errorState: 7, warningState: 36 }, + }} + fieldErrors={{}} + isDisabled={false} + /> + ) + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/settings/certificate_form.tsx b/x-pack/legacy/plugins/uptime/public/components/settings/certificate_form.tsx new file mode 100644 index 0000000000000..5103caee1e1c0 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/settings/certificate_form.tsx @@ -0,0 +1,160 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import { useSelector } from 'react-redux'; +import { + EuiDescribedFormGroup, + EuiFormRow, + EuiCode, + EuiFieldNumber, + EuiTitle, + EuiSpacer, + EuiSelect, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { defaultDynamicSettings, DynamicSettings } from '../../../common/runtime_types'; +import { selectDynamicSettings } from '../../state/selectors'; + +type NumStr = string | number; + +export type OnFieldChangeType = (field: string, value?: NumStr) => void; + +export interface SettingsFormProps { + onChange: OnFieldChangeType; + formFields: DynamicSettings | null; + fieldErrors: any; + isDisabled: boolean; +} + +export const CertificateExpirationForm: React.FC<SettingsFormProps> = ({ + onChange, + formFields, + fieldErrors, + isDisabled, +}) => { + const dss = useSelector(selectDynamicSettings); + + return ( + <> + <EuiTitle size="s"> + <h3> + <FormattedMessage + id="xpack.uptime.sourceConfiguration.certificationSectionTitle" + defaultMessage="Certificate Expiration" + /> + </h3> + </EuiTitle> + <EuiSpacer size="m" /> + <EuiDescribedFormGroup + title={ + <h4> + <FormattedMessage + id="xpack.uptime.sourceConfiguration.stateThresholds" + defaultMessage="Expiration State Thresholds" + /> + </h4> + } + description={ + <FormattedMessage + id="xpack.uptime.sourceConfiguration.stateThresholdsDescription" + defaultMessage="Set certificate expiration warning/error thresholds" + /> + } + > + <EuiFormRow + describedByIds={['errorState']} + error={fieldErrors?.certificatesThresholds?.errorState} + fullWidth + helpText={ + <FormattedMessage + id="xpack.uptime.sourceConfiguration.errorStateDefaultValue" + defaultMessage="The default value is {defaultValue}" + values={{ + defaultValue: ( + <EuiCode>{defaultDynamicSettings?.certificatesThresholds?.errorState}</EuiCode> + ), + }} + /> + } + isInvalid={!!fieldErrors?.certificatesThresholds?.errorState} + label={ + <FormattedMessage + id="xpack.uptime.sourceConfiguration.errorStateLabel" + defaultMessage="Error state" + /> + } + > + <EuiFlexGroup> + <EuiFlexItem grow={2}> + <EuiFieldNumber + data-test-subj={`error-state-threshold-input-${dss.loading ? 'loading' : 'loaded'}`} + fullWidth + disabled={isDisabled} + isLoading={dss.loading} + value={formFields?.certificatesThresholds?.errorState || ''} + onChange={({ currentTarget: { value } }: any) => + onChange( + 'certificatesThresholds.errorState', + value === '' ? undefined : Number(value) + ) + } + /> + </EuiFlexItem> + <EuiFlexItem grow={1}> + <EuiSelect options={[{ value: 'day', text: 'Days' }]} /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFormRow> + <EuiFormRow + describedByIds={['warningState']} + error={fieldErrors?.certificatesThresholds?.warningState} + fullWidth + helpText={ + <FormattedMessage + id="xpack.uptime.sourceConfiguration.warningStateDefaultValue" + defaultMessage="The default value is {defaultValue}" + values={{ + defaultValue: ( + <EuiCode>{defaultDynamicSettings?.certificatesThresholds?.warningState}</EuiCode> + ), + }} + /> + } + isInvalid={!!fieldErrors?.certificatesThresholds?.warningState} + label={ + <FormattedMessage + id="xpack.uptime.sourceConfiguration.warningStateLabel" + defaultMessage="Warning state" + /> + } + > + <EuiFlexGroup> + <EuiFlexItem grow={2}> + <EuiFieldNumber + data-test-subj={`warning-state-threshold-input-${ + dss.loading ? 'loading' : 'loaded' + }`} + fullWidth + disabled={isDisabled} + isLoading={dss.loading} + value={formFields?.certificatesThresholds?.warningState || ''} + onChange={(event: any) => + onChange('certificatesThresholds.warningState', Number(event.currentTarget.value)) + } + /> + </EuiFlexItem> + <EuiFlexItem grow={1}> + <EuiSelect options={[{ value: 'day', text: 'Days' }]} /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFormRow> + </EuiDescribedFormGroup> + </> + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/settings/indices_form.tsx b/x-pack/legacy/plugins/uptime/public/components/settings/indices_form.tsx new file mode 100644 index 0000000000000..c28eca2ea229e --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/settings/indices_form.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useSelector } from 'react-redux'; +import { + EuiDescribedFormGroup, + EuiFormRow, + EuiCode, + EuiFieldText, + EuiTitle, + EuiSpacer, +} from '@elastic/eui'; +import { defaultDynamicSettings } from '../../../common/runtime_types'; +import { selectDynamicSettings } from '../../state/selectors'; +import { SettingsFormProps } from './certificate_form'; + +export const IndicesForm: React.FC<SettingsFormProps> = ({ + onChange, + formFields, + fieldErrors, + isDisabled, +}) => { + const dss = useSelector(selectDynamicSettings); + + return ( + <> + <EuiTitle size="s"> + <h3> + <FormattedMessage + id="xpack.uptime.sourceConfiguration.indicesSectionTitle" + defaultMessage="Indices" + /> + </h3> + </EuiTitle> + <EuiSpacer size="m" /> + <EuiDescribedFormGroup + title={ + <h4> + <FormattedMessage + id="xpack.uptime.sourceConfiguration.heartbeatIndicesTitle" + defaultMessage="Uptime indices" + /> + </h4> + } + description={ + <FormattedMessage + id="xpack.uptime.sourceConfiguration.heartbeatIndicesDescription" + defaultMessage="Index pattern for matching indices that contain Heartbeat data" + /> + } + > + <EuiFormRow + describedByIds={['heartbeatIndices']} + error={fieldErrors?.heartbeatIndices} + fullWidth + helpText={ + <FormattedMessage + id="xpack.uptime.sourceConfiguration.heartbeatIndicesDefaultValue" + defaultMessage="The default value is {defaultValue}" + values={{ + defaultValue: <EuiCode>{defaultDynamicSettings.heartbeatIndices}</EuiCode>, + }} + /> + } + isInvalid={!!fieldErrors?.heartbeatIndices} + label={ + <FormattedMessage + id="xpack.uptime.sourceConfiguration.heartbeatIndicesLabel" + defaultMessage="Heartbeat indices" + /> + } + > + <EuiFieldText + data-test-subj={`heartbeat-indices-input-${dss.loading ? 'loading' : 'loaded'}`} + fullWidth + disabled={isDisabled} + isLoading={dss.loading} + value={formFields?.heartbeatIndices || ''} + onChange={(event: any) => onChange('heartbeatIndices', event.currentTarget.value)} + /> + </EuiFormRow> + </EuiDescribedFormGroup> + </> + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/contexts/uptime_settings_context.tsx b/x-pack/legacy/plugins/uptime/public/contexts/uptime_settings_context.tsx index 44a87d310c9c7..c5a0ec4831798 100644 --- a/x-pack/legacy/plugins/uptime/public/contexts/uptime_settings_context.tsx +++ b/x-pack/legacy/plugins/uptime/public/contexts/uptime_settings_context.tsx @@ -8,7 +8,7 @@ import React, { createContext, useMemo } from 'react'; import { UptimeAppProps } from '../uptime_app'; import { CLIENT_DEFAULTS, CONTEXT_DEFAULTS } from '../../common/constants'; import { CommonlyUsedRange } from '../components/functional/uptime_date_picker'; -import { useUrlParams } from '../hooks'; +import { useGetUrlParams } from '../hooks'; import { ILicense } from '../../../../../plugins/licensing/common/types'; export interface UptimeSettingsContextValues { @@ -50,9 +50,7 @@ export const UptimeSettingsContextProvider: React.FC<UptimeAppProps> = ({ childr plugins, } = props; - const [getUrlParams] = useUrlParams(); - - const { dateRangeStart, dateRangeEnd } = getUrlParams(); + const { dateRangeStart, dateRangeEnd } = useGetUrlParams(); let license: ILicense | null = null; diff --git a/x-pack/legacy/plugins/uptime/public/hooks/__tests__/use_url_params.test.tsx b/x-pack/legacy/plugins/uptime/public/hooks/__tests__/use_url_params.test.tsx index a8999a50927d2..deb1f163c1326 100644 --- a/x-pack/legacy/plugins/uptime/public/hooks/__tests__/use_url_params.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/hooks/__tests__/use_url_params.test.tsx @@ -19,6 +19,7 @@ interface MockUrlParamsComponentProps { const UseUrlParamsTestComponent = ({ hook, updateParams }: MockUrlParamsComponentProps) => { const [params, setParams] = useState({}); const [getUrlParams, updateUrlParams] = hook(); + const queryParams = getUrlParams(); return ( <Fragment> {Object.keys(params).length > 0 ? <div>{JSON.stringify(params)}</div> : null} @@ -30,7 +31,7 @@ const UseUrlParamsTestComponent = ({ hook, updateParams }: MockUrlParamsComponen > Set url params </button> - <button id="getUrlParams" onClick={() => setParams(getUrlParams())}> + <button id="getUrlParams" onClick={() => setParams(queryParams)}> Get url params </button> </Fragment> diff --git a/x-pack/legacy/plugins/uptime/public/hooks/index.ts b/x-pack/legacy/plugins/uptime/public/hooks/index.ts index e022248df407a..1f50e995eda49 100644 --- a/x-pack/legacy/plugins/uptime/public/hooks/index.ts +++ b/x-pack/legacy/plugins/uptime/public/hooks/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './use_monitor'; export * from './use_url_params'; export * from './use_telemetry'; export * from './update_kuery_string'; diff --git a/x-pack/legacy/plugins/uptime/public/hooks/use_monitor.ts b/x-pack/legacy/plugins/uptime/public/hooks/use_monitor.ts new file mode 100644 index 0000000000000..8080ce2696a3c --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/hooks/use_monitor.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. + */ + +import { useParams } from 'react-router-dom'; + +export const useMonitorId = (): string => { + const { monitorId } = useParams(); + + // decode 64 base string, it was decoded to make it a valid url, since monitor id can be a url + return atob(monitorId || ''); +}; diff --git a/x-pack/legacy/plugins/uptime/public/hooks/use_telemetry.ts b/x-pack/legacy/plugins/uptime/public/hooks/use_telemetry.ts index 13fe523332ae5..a2012b8ac5636 100644 --- a/x-pack/legacy/plugins/uptime/public/hooks/use_telemetry.ts +++ b/x-pack/legacy/plugins/uptime/public/hooks/use_telemetry.ts @@ -5,7 +5,7 @@ */ import { useEffect } from 'react'; -import { useUrlParams } from './use_url_params'; +import { useGetUrlParams } from './use_url_params'; import { apiService } from '../state/api/utils'; import { API_URLS } from '../../common/constants'; @@ -17,8 +17,12 @@ export enum UptimePage { } export const useUptimeTelemetry = (page?: UptimePage) => { - const [getUrlParams] = useUrlParams(); - const { dateRangeStart, dateRangeEnd, autorefreshInterval, autorefreshIsPaused } = getUrlParams(); + const { + dateRangeStart, + dateRangeEnd, + autorefreshInterval, + autorefreshIsPaused, + } = useGetUrlParams(); useEffect(() => { if (!apiService.http) throw new Error('Core http services are not defined'); diff --git a/x-pack/legacy/plugins/uptime/public/hooks/use_url_params.ts b/x-pack/legacy/plugins/uptime/public/hooks/use_url_params.ts index 20063b2c1bc93..8b13e9e480559 100644 --- a/x-pack/legacy/plugins/uptime/public/hooks/use_url_params.ts +++ b/x-pack/legacy/plugins/uptime/public/hooks/use_url_params.ts @@ -15,27 +15,26 @@ export type UpdateUrlParams = (updatedParams: { export type UptimeUrlParamsHook = () => [GetUrlParams, UpdateUrlParams]; -export const useUrlParams: UptimeUrlParamsHook = () => { +const getParsedParams = (search: string) => { + return search ? parse(search[0] === '?' ? search.slice(1) : search, { sort: false }) : {}; +}; + +export const useGetUrlParams: GetUrlParams = () => { const location = useLocation(); - const history = useHistory(); - const getUrlParams: GetUrlParams = () => { - let search: string | undefined; - if (location) { - search = location.search; - } + const params = getParsedParams(location?.search); - const params = search - ? parse(search[0] === '?' ? search.slice(1) : search, { sort: false }) - : {}; + return getSupportedUrlParams(params); +}; - return getSupportedUrlParams(params); - }; +export const useUrlParams: UptimeUrlParamsHook = () => { + const location = useLocation(); + const history = useHistory(); const updateUrlParams: UpdateUrlParams = updatedParams => { if (!history || !location) return; const { pathname, search } = location; - const currentParams = parse(search[0] === '?' ? search.slice(1) : search, { sort: false }); + const currentParams = getParsedParams(search); const mergedParams = { ...currentParams, ...updatedParams, @@ -60,5 +59,5 @@ export const useUrlParams: UptimeUrlParamsHook = () => { }); }; - return [getUrlParams, updateUrlParams]; + return [useGetUrlParams, updateUrlParams]; }; diff --git a/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx b/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx index 21124b7323d68..683af81239c99 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx @@ -5,41 +5,21 @@ */ import { EuiSpacer } from '@elastic/eui'; -import React, { useContext, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import React from 'react'; import { useSelector } from 'react-redux'; -import { MonitorCharts, PingList } from '../components/functional'; -import { UptimeRefreshContext } from '../contexts'; -import { useUptimeTelemetry, useUrlParams, UptimePage } from '../hooks'; import { useTrackPageview } from '../../../../../plugins/observability/public'; -import { MonitorStatusDetails } from '../components/connected'; +import { MonitorStatusDetails, PingList } from '../components/connected'; import { monitorStatusSelector } from '../state/selectors'; import { PageHeader } from './page_header'; +import { MonitorCharts } from '../components/functional'; import { useBreadcrumbs } from '../hooks/use_breadcrumbs'; +import { useMonitorId, useUptimeTelemetry, UptimePage } from '../hooks'; export const MonitorPage: React.FC = () => { - // decode 64 base string, it was decoded to make it a valid url, since monitor id can be a url - let { monitorId } = useParams(); - monitorId = atob(monitorId || ''); - - const [pingListPageCount, setPingListPageCount] = useState<number>(10); - const { refreshApp } = useContext(UptimeRefreshContext); - const [getUrlParams, updateUrlParams] = useUrlParams(); - const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = getUrlParams(); - const { dateRangeStart, dateRangeEnd, selectedPingStatus } = params; - - const [selectedLocation, setSelectedLocation] = useState(undefined); - const [pingListIndex, setPingListIndex] = useState(0); + const monitorId = useMonitorId(); const selectedMonitor = useSelector(monitorStatusSelector); - const sharedVariables = { - dateRangeStart, - dateRangeEnd, - monitorId, - location: selectedLocation, - }; - useUptimeTelemetry(UptimePage.Monitor); useTrackPageview({ app: 'uptime', path: 'monitor' }); @@ -55,25 +35,7 @@ export const MonitorPage: React.FC = () => { <EuiSpacer size="s" /> <MonitorCharts monitorId={monitorId} /> <EuiSpacer size="s" /> - <PingList - onPageCountChange={setPingListPageCount} - onSelectedLocationChange={setSelectedLocation} - onSelectedStatusChange={(selectedStatus: string | undefined) => { - updateUrlParams({ selectedPingStatus: selectedStatus || '' }); - refreshApp(); - }} - onPageIndexChange={(index: number) => setPingListIndex(index)} - pageIndex={pingListIndex} - pageSize={pingListPageCount} - selectedOption={selectedPingStatus} - selectedLocation={selectedLocation} - variables={{ - ...sharedVariables, - page: pingListIndex, - size: pingListPageCount, - status: selectedPingStatus, - }} - /> + <PingList monitorId={monitorId} /> </> ); }; diff --git a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx index 943dbd6bd57ba..5550facaf42e9 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx @@ -13,7 +13,7 @@ import { OverviewPageParsingErrorCallout, StatusPanel, } from '../components/functional'; -import { useUrlParams, useUptimeTelemetry, UptimePage } from '../hooks'; +import { useUptimeTelemetry, UptimePage, useGetUrlParams } from '../hooks'; import { stringifyUrlParams } from '../lib/helper/stringify_url_params'; import { useTrackPageview } from '../../../../../plugins/observability/public'; import { DataPublicPluginSetup, IIndexPattern } from '../../../../../../src/plugins/data/public'; @@ -55,12 +55,11 @@ const getMonitorListPageSizeValue = () => { export const OverviewPageComponent = ({ autocomplete, indexPattern, setEsKueryFilters }: Props) => { const { colors } = useContext(UptimeThemeContext); - const [getUrlParams] = useUrlParams(); // TODO: this is temporary until we migrate the monitor list to our Redux implementation const [monitorListPageSize, setMonitorListPageSize] = useState<number>( getMonitorListPageSizeValue() ); - const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = getUrlParams(); + const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = useGetUrlParams(); const { dateRangeStart, dateRangeEnd, diff --git a/x-pack/legacy/plugins/uptime/public/pages/settings.tsx b/x-pack/legacy/plugins/uptime/public/pages/settings.tsx index d3e17a15ee0e0..049dffecd3f2e 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/settings.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/settings.tsx @@ -9,46 +9,54 @@ import { EuiButton, EuiButtonEmpty, EuiCallOut, - EuiCode, - EuiDescribedFormGroup, - EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiForm, - EuiFormRow, EuiPanel, EuiSpacer, - EuiTitle, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { connect } from 'react-redux'; -import { isEqual } from 'lodash'; +import { useDispatch, useSelector } from 'react-redux'; +import { cloneDeep, isEqual, set } from 'lodash'; import { i18n } from '@kbn/i18n'; import { Link } from 'react-router-dom'; -import { AppState } from '../state'; import { selectDynamicSettings } from '../state/selectors'; -import { DynamicSettingsState } from '../state/reducers/dynamic_settings'; import { getDynamicSettings, setDynamicSettings } from '../state/actions/dynamic_settings'; -import { defaultDynamicSettings, DynamicSettings } from '../../common/runtime_types'; +import { DynamicSettings } from '../../common/runtime_types'; import { useBreadcrumbs } from '../hooks/use_breadcrumbs'; import { OVERVIEW_ROUTE } from '../../common/constants'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { UptimePage, useUptimeTelemetry } from '../hooks'; +import { IndicesForm } from '../components/settings/indices_form'; +import { + CertificateExpirationForm, + OnFieldChangeType, +} from '../components/settings/certificate_form'; + +const getFieldErrors = (formFields: DynamicSettings | null) => { + if (formFields) { + const blankStr = 'May not be blank'; + const { certificatesThresholds, heartbeatIndices } = formFields; + const heartbeatIndErr = heartbeatIndices.match(/^\S+$/) ? '' : blankStr; + const errorStateErr = certificatesThresholds?.errorState ? null : blankStr; + const warningStateErr = certificatesThresholds?.warningState ? null : blankStr; + return { + heartbeatIndices: heartbeatIndErr, + certificatesThresholds: + errorStateErr || warningStateErr + ? { + errorState: errorStateErr, + warningState: warningStateErr, + } + : null, + }; + } + return null; +}; -interface Props { - dynamicSettingsState: DynamicSettingsState; -} - -interface DispatchProps { - dispatchGetDynamicSettings: typeof getDynamicSettings; - dispatchSetDynamicSettings: typeof setDynamicSettings; -} +export const SettingsPage = () => { + const dss = useSelector(selectDynamicSettings); -export const SettingsPageComponent = ({ - dynamicSettingsState: dss, - dispatchGetDynamicSettings, - dispatchSetDynamicSettings, -}: Props & DispatchProps) => { const settingsBreadcrumbText = i18n.translate('xpack.uptime.settingsBreadcrumbText', { defaultMessage: 'Settings', }); @@ -56,9 +64,11 @@ export const SettingsPageComponent = ({ useUptimeTelemetry(UptimePage.Settings); + const dispatch = useDispatch(); + useEffect(() => { - dispatchGetDynamicSettings({}); - }, [dispatchGetDynamicSettings]); + dispatch(getDynamicSettings({})); + }, [dispatch]); const [formFields, setFormFields] = useState<DynamicSettings | null>(dss.settings || null); @@ -66,22 +76,22 @@ export const SettingsPageComponent = ({ setFormFields({ ...dss.settings }); } - const fieldErrors = formFields && { - heartbeatIndices: formFields.heartbeatIndices.match(/^\S+$/) ? null : 'May not be blank', - }; + const fieldErrors = getFieldErrors(formFields); + const isFormValid = !(fieldErrors && Object.values(fieldErrors).find(v => !!v)); - const onChangeFormField = (field: keyof DynamicSettings, value: any) => { + const onChangeFormField: OnFieldChangeType = (field, value) => { if (formFields) { - formFields[field] = value; - setFormFields({ ...formFields }); + const newFormFields = cloneDeep(formFields); + set(newFormFields, field, value); + setFormFields(cloneDeep(newFormFields)); } }; const onApply = (event: React.FormEvent) => { event.preventDefault(); if (formFields) { - dispatchSetDynamicSettings(formFields); + dispatch(setDynamicSettings(formFields)); } }; @@ -112,7 +122,7 @@ export const SettingsPageComponent = ({ return ( <> - <Link to={OVERVIEW_ROUTE}> + <Link to={OVERVIEW_ROUTE} data-test-subj="uptimeSettingsToOverviewLink"> <EuiButtonEmpty size="s" color="primary" iconType="arrowLeft"> {i18n.translate('xpack.uptime.settings.returnToOverviewLinkLabel', { defaultMessage: 'Return to overview', @@ -128,68 +138,18 @@ export const SettingsPageComponent = ({ <EuiFlexItem grow={false}> <form onSubmit={onApply}> <EuiForm> - <EuiTitle size="s"> - <h3> - <FormattedMessage - id="xpack.uptime.sourceConfiguration.indicesSectionTitle" - defaultMessage="Indices" - /> - </h3> - </EuiTitle> - <EuiSpacer size="m" /> - <EuiDescribedFormGroup - title={ - <h4> - <FormattedMessage - id="xpack.uptime.sourceConfiguration.heartbeatIndicesTitle" - defaultMessage="Uptime indices" - /> - </h4> - } - description={ - <FormattedMessage - id="xpack.uptime.sourceConfiguration.heartbeatIndicesDescription" - defaultMessage="Index pattern for matching indices that contain Heartbeat data" - /> - } - > - <EuiFormRow - describedByIds={['heartbeatIndices']} - error={fieldErrors?.heartbeatIndices} - fullWidth - helpText={ - <FormattedMessage - id="xpack.uptime.sourceConfiguration.heartbeatIndicesDefaultValue" - defaultMessage="The default value is {defaultValue}" - values={{ - defaultValue: ( - <EuiCode>{defaultDynamicSettings.heartbeatIndices}</EuiCode> - ), - }} - /> - } - isInvalid={!!fieldErrors?.heartbeatIndices} - label={ - <FormattedMessage - id="xpack.uptime.sourceConfiguration.heartbeatIndicesLabel" - defaultMessage="Heartbeat indices" - /> - } - > - <EuiFieldText - data-test-subj={`heartbeat-indices-input-${ - dss.loading ? 'loading' : 'loaded' - }`} - fullWidth - disabled={isFormDisabled} - isLoading={dss.loading} - value={formFields?.heartbeatIndices || ''} - onChange={(event: any) => - onChangeFormField('heartbeatIndices', event.currentTarget.value) - } - /> - </EuiFormRow> - </EuiDescribedFormGroup> + <IndicesForm + onChange={onChangeFormField} + formFields={formFields} + fieldErrors={fieldErrors} + isDisabled={isFormDisabled} + /> + <CertificateExpirationForm + onChange={onChangeFormField} + formFields={formFields} + fieldErrors={fieldErrors} + isDisabled={isFormDisabled} + /> <EuiSpacer size="m" /> <EuiFlexGroup justifyContent="flexEnd" gutterSize="s"> @@ -230,18 +190,3 @@ export const SettingsPageComponent = ({ </> ); }; - -const mapStateToProps = (state: AppState) => ({ - dynamicSettingsState: selectDynamicSettings(state), -}); - -const mapDispatchToProps = (dispatch: any) => ({ - dispatchGetDynamicSettings: () => { - return dispatch(getDynamicSettings({})); - }, - dispatchSetDynamicSettings: (settings: DynamicSettings) => { - return dispatch(setDynamicSettings(settings)); - }, -}); - -export const SettingsPage = connect(mapStateToProps, mapDispatchToProps)(SettingsPageComponent); diff --git a/x-pack/legacy/plugins/uptime/public/queries/index.ts b/x-pack/legacy/plugins/uptime/public/queries/index.ts deleted file mode 100644 index 283382ec1b7ba..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/queries/index.ts +++ /dev/null @@ -1,7 +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 { pingsQuery, pingsQueryString } from './pings_query'; diff --git a/x-pack/legacy/plugins/uptime/public/queries/pings_query.ts b/x-pack/legacy/plugins/uptime/public/queries/pings_query.ts deleted file mode 100644 index ed20fe8eb2931..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/queries/pings_query.ts +++ /dev/null @@ -1,73 +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 gql from 'graphql-tag'; - -export const pingsQueryString = ` -query PingList( - $dateRangeStart: String! - $dateRangeEnd: String! - $monitorId: String - $status: String - $sort: String - $size: Int - $location: String - $page: Int -) { - allPings( - dateRangeStart: $dateRangeStart - dateRangeEnd: $dateRangeEnd - monitorId: $monitorId - status: $status - sort: $sort - size: $size - location: $location - page: $page - ) { - total - locations - pings { - id - timestamp - http { - response { - status_code - body { - bytes - hash - content - content_bytes - } - } - } - error { - message - type - } - monitor { - duration { - us - } - id - ip - name - scheme - status - type - } - observer { - geo { - name - } - } - } - } - } -`; - -export const pingsQuery = gql` - ${pingsQueryString} -`; diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/monitor_status.ts b/x-pack/legacy/plugins/uptime/public/state/actions/monitor_status.ts index a8f37d38ebae6..e8c862e209739 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/monitor_status.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/monitor_status.ts @@ -5,7 +5,7 @@ */ import { createAction } from 'redux-actions'; import { QueryParams } from './types'; -import { Ping } from '../../../common/graphql/types'; +import { Ping } from '../../../common/runtime_types'; export const getMonitorStatusAction = createAction<QueryParams>('GET_MONITOR_STATUS'); export const getMonitorStatusActionSuccess = createAction<Ping>('GET_MONITOR_STATUS_SUCCESS'); diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/ping.ts b/x-pack/legacy/plugins/uptime/public/state/actions/ping.ts index bb7258d9a54b2..70918a4cc70e5 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/ping.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/ping.ts @@ -5,8 +5,17 @@ */ import { createAction } from 'redux-actions'; -import { GetPingHistogramParams, HistogramResult } from '../../../common/types'; +import { + GetPingHistogramParams, + HistogramResult, + PingsResponse, + GetPingsParams, +} from '../../../common/runtime_types'; export const getPingHistogram = createAction<GetPingHistogramParams>('GET_PING_HISTOGRAM'); export const getPingHistogramSuccess = createAction<HistogramResult>('GET_PING_HISTOGRAM_SUCCESS'); export const getPingHistogramFail = createAction<Error>('GET_PING_HISTOGRAM_FAIL'); + +export const getPings = createAction<GetPingsParams>('GET PINGS'); +export const getPingsSuccess = createAction<PingsResponse>('GET PINGS SUCCESS'); +export const getPingsFail = createAction<Error>('GET PINGS FAIL'); diff --git a/x-pack/legacy/plugins/uptime/public/state/api/ml_anomaly.ts b/x-pack/legacy/plugins/uptime/public/state/api/ml_anomaly.ts index bcd2582fe18b9..f10745a50f56a 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/ml_anomaly.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/ml_anomaly.ts @@ -48,11 +48,8 @@ export const createMLJob = async ({ query: { bool: { filter: [ - { - term: { - 'monitor.id': lowerCaseMonitorId, - }, - }, + { term: { 'monitor.id': lowerCaseMonitorId } }, + { range: { 'monitor.duration.us': { gt: 0 } } }, ], }, }, diff --git a/x-pack/legacy/plugins/uptime/public/state/api/monitor_status.ts b/x-pack/legacy/plugins/uptime/public/state/api/monitor_status.ts index f9e171adda334..7c8ab3518b5a0 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/monitor_status.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/monitor_status.ts @@ -5,9 +5,9 @@ */ import { QueryParams } from '../actions/types'; -import { Ping } from '../../../common/graphql/types'; -import { apiService } from './utils'; +import { Ping } from '../../../common/runtime_types'; import { API_URLS } from '../../../common/constants'; +import { apiService } from './utils'; export const fetchMonitorStatus = async ({ monitorId, diff --git a/x-pack/legacy/plugins/uptime/public/state/api/ping.ts b/x-pack/legacy/plugins/uptime/public/state/api/ping.ts index df71cc8d67bd0..bbd3ddf399dcc 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/ping.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/ping.ts @@ -5,10 +5,21 @@ */ import { APIFn } from './types'; -import { GetPingHistogramParams, HistogramResult } from '../../../common/types'; +import { + PingsResponseType, + PingsResponse, + GetPingsParams, + GetPingHistogramParams, + HistogramResult, +} from '../../../common/runtime_types'; import { apiService } from './utils'; import { API_URLS } from '../../../common/constants/rest_api'; +export const fetchPings: APIFn<GetPingsParams, PingsResponse> = async ({ + dateRange: { from, to }, + ...optional +}) => await apiService.get(API_URLS.PINGS, { from, to, ...optional }, PingsResponseType); + export const fetchPingHistogram: APIFn<GetPingHistogramParams, HistogramResult> = async ({ monitorId, dateStart, @@ -19,9 +30,9 @@ export const fetchPingHistogram: APIFn<GetPingHistogramParams, HistogramResult> const queryParams = { dateStart, dateEnd, - ...(monitorId && { monitorId }), - ...(statusFilter && { statusFilter }), - ...(filters && { filters }), + monitorId, + statusFilter, + filters, }; return await apiService.get(API_URLS.PING_HISTOGRAM, queryParams); diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.ts b/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.ts index 49e497952ea44..b0734cb5ccabb 100644 --- a/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.ts +++ b/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.ts @@ -16,7 +16,7 @@ import { IHttpFetchError } from '../../../../../../../target/types/core/public/h * @param fail creates a failure action * @template T the action type expected by the fetch action * @template R the type that the API request should return on success - * @template S tye type of the success action + * @template S the type of the success action * @template F the type of the failure action */ export function fetchEffectFactory<T, R, S, F>( diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/index.ts b/x-pack/legacy/plugins/uptime/public/state/effects/index.ts index 8d457be1d1c78..8df11312b6455 100644 --- a/x-pack/legacy/plugins/uptime/public/state/effects/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/effects/index.ts @@ -11,7 +11,7 @@ import { fetchSnapshotCountEffect } from './snapshot'; import { fetchMonitorStatusEffect } from './monitor_status'; import { fetchDynamicSettingsEffect, setDynamicSettingsEffect } from './dynamic_settings'; import { fetchIndexPatternEffect } from './index_pattern'; -import { fetchPingHistogramEffect } from './ping'; +import { fetchPingsEffect, fetchPingHistogramEffect } from './ping'; import { fetchMonitorDurationEffect } from './monitor_duration'; import { fetchMLJobEffect } from './ml_anomaly'; import { fetchIndexStatusEffect } from './index_status'; @@ -24,6 +24,7 @@ export function* rootEffect() { yield fork(fetchDynamicSettingsEffect); yield fork(setDynamicSettingsEffect); yield fork(fetchIndexPatternEffect); + yield fork(fetchPingsEffect); yield fork(fetchPingHistogramEffect); yield fork(fetchMLJobEffect); yield fork(fetchMonitorDurationEffect); diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/ping.ts b/x-pack/legacy/plugins/uptime/public/state/effects/ping.ts index acb9b31915fa9..dec67ed8cf979 100644 --- a/x-pack/legacy/plugins/uptime/public/state/effects/ping.ts +++ b/x-pack/legacy/plugins/uptime/public/state/effects/ping.ts @@ -5,10 +5,21 @@ */ import { takeLatest } from 'redux-saga/effects'; -import { getPingHistogram, getPingHistogramSuccess, getPingHistogramFail } from '../actions'; -import { fetchPingHistogram } from '../api'; +import { + getPingHistogram, + getPingHistogramSuccess, + getPingHistogramFail, + getPings, + getPingsSuccess, + getPingsFail, +} from '../actions'; +import { fetchPingHistogram, fetchPings } from '../api'; import { fetchEffectFactory } from './fetch_effect'; +export function* fetchPingsEffect() { + yield takeLatest(String(getPings), fetchEffectFactory(fetchPings, getPingsSuccess, getPingsFail)); +} + export function* fetchPingHistogramEffect() { yield takeLatest( String(getPingHistogram), diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts index 7bf8af5dd4d03..0a3ff35df0599 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts @@ -13,6 +13,7 @@ import { monitorStatusReducer } from './monitor_status'; import { dynamicSettingsReducer } from './dynamic_settings'; import { indexPatternReducer } from './index_pattern'; import { pingReducer } from './ping'; +import { pingListReducer } from './ping_list'; import { monitorDurationReducer } from './monitor_duration'; import { indexStatusReducer } from './index_status'; import { mlJobsReducer } from './ml_anomaly'; @@ -26,6 +27,7 @@ export const rootReducer = combineReducers({ dynamicSettings: dynamicSettingsReducer, indexPattern: indexPatternReducer, ping: pingReducer, + pingList: pingListReducer, ml: mlJobsReducer, monitorDuration: monitorDurationReducer, indexStatus: indexStatusReducer, diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/monitor_status.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/monitor_status.ts index 6cfaa9f8f59c1..fa3e377b5ebf4 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/monitor_status.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/monitor_status.ts @@ -9,7 +9,7 @@ import { getMonitorStatusActionSuccess, getMonitorStatusActionFail, } from '../actions'; -import { Ping } from '../../../common/graphql/types'; +import { Ping } from '../../../common/runtime_types'; import { QueryParams } from '../actions/types'; export interface MonitorStatusState { diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/ping.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/ping.ts index 76775e6a0a355..4c8715038ce36 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/ping.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/ping.ts @@ -6,7 +6,7 @@ import { handleActions, Action } from 'redux-actions'; import { getPingHistogram, getPingHistogramSuccess, getPingHistogramFail } from '../actions'; -import { HistogramResult } from '../../../common/types'; +import { HistogramResult } from '../../../common/runtime_types'; export interface PingState { pingHistogram: HistogramResult | null; diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/ping_list.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/ping_list.ts new file mode 100644 index 0000000000000..e3ccb1e663eda --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/ping_list.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 { handleActions, Action } from 'redux-actions'; +import { PingsResponse } from '../../../common/runtime_types'; +import { getPings, getPingsSuccess, getPingsFail } from '../actions'; + +export interface PingListState { + pingList: PingsResponse; + error?: Error; + loading: boolean; +} + +const initialState: PingListState = { + pingList: { + total: 0, + locations: [], + pings: [], + }, + loading: false, +}; + +type PingListPayload = PingsResponse & Error; + +export const pingListReducer = handleActions<PingListState, PingListPayload>( + { + [String(getPings)]: state => ({ + ...state, + loading: true, + }), + + [String(getPingsSuccess)]: (state, action: Action<PingsResponse>) => ({ + ...state, + pingList: { ...action.payload }, + loading: false, + }), + + [String(getPingsFail)]: (state, action: Action<Error>) => ({ + ...state, + error: action.payload, + loading: false, + }), + }, + initialState +); diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts index 3b4547514a11e..77902f347f6f9 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -58,6 +58,14 @@ describe('state selectors', () => { loading: false, errors: [], }, + pingList: { + loading: false, + pingList: { + total: 0, + locations: [], + pings: [], + }, + }, monitorDuration: { durationLines: null, loading: false, diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts index 0fc3c7151cb3b..37695e4274306 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts @@ -41,6 +41,11 @@ export const selectPingHistogram = ({ ping, ui }: AppState) => { }; }; +export const selectPingList = ({ pingList, ui: { lastRefresh } }: AppState) => ({ + pings: pingList, + lastRefresh, +}); + const mlCapabilitiesSelector = (state: AppState) => state.ml.mlCapabilities.data; export const hasMLFeatureAvailable = createSelector( diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 469df4fd86e2c..658f8f3fd8cf9 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -255,7 +255,14 @@ describe('execute()', () => { services, }; sendEmailMock.mockReset(); - await actionType.executor(executorOptions); + const result = await actionType.executor(executorOptions); + expect(result).toMatchInlineSnapshot(` + Object { + "actionId": "some-id", + "data": undefined, + "status": "ok", + } + `); expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(` Object { "content": Object { @@ -282,4 +289,102 @@ describe('execute()', () => { } `); }); + + test('parameters are as expected with no auth', async () => { + const config: ActionTypeConfigType = { + service: null, + host: 'a host', + port: 42, + secure: true, + from: 'bob@example.com', + }; + const secrets: ActionTypeSecretsType = { + user: null, + password: null, + }; + const params: ActionParamsType = { + to: ['jim@example.com'], + cc: ['james@example.com'], + bcc: ['jimmy@example.com'], + subject: 'the subject', + message: 'a message to you', + }; + + const actionId = 'some-id'; + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; + sendEmailMock.mockReset(); + await actionType.executor(executorOptions); + expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(` + Object { + "content": Object { + "message": "a message to you", + "subject": "the subject", + }, + "routing": Object { + "bcc": Array [ + "jimmy@example.com", + ], + "cc": Array [ + "james@example.com", + ], + "from": "bob@example.com", + "to": Array [ + "jim@example.com", + ], + }, + "transport": Object { + "host": "a host", + "port": 42, + "secure": true, + }, + } + `); + }); + + test('returns expected result when an error is thrown', async () => { + const config: ActionTypeConfigType = { + service: null, + host: 'a host', + port: 42, + secure: true, + from: 'bob@example.com', + }; + const secrets: ActionTypeSecretsType = { + user: null, + password: null, + }; + const params: ActionParamsType = { + to: ['jim@example.com'], + cc: ['james@example.com'], + bcc: ['jimmy@example.com'], + subject: 'the subject', + message: 'a message to you', + }; + + const actionId = 'some-id'; + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; + sendEmailMock.mockReset(); + sendEmailMock.mockRejectedValue(new Error('wops')); + const result = await actionType.executor(executorOptions); + expect(result).toMatchInlineSnapshot(` + Object { + "actionId": "some-id", + "message": "error sending email", + "serviceMessage": "wops", + "status": "error", + } + `); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.ts b/x-pack/plugins/actions/server/builtin_action_types/email.ts index 7992920fdfcb4..ca8d089ad2946 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; import nodemailerGetService from 'nodemailer/lib/well-known'; -import { sendEmail, JSON_TRANSPORT_SERVICE } from './lib/send_email'; +import { sendEmail, JSON_TRANSPORT_SERVICE, SendEmailOptions, Transport } from './lib/send_email'; import { portSchema } from './lib/schemas'; import { Logger } from '../../../../../src/core/server'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; @@ -143,7 +143,7 @@ async function executor( const secrets = execOptions.secrets as ActionTypeSecretsType; const params = execOptions.params as ActionParamsType; - const transport: any = {}; + const transport: Transport = {}; if (secrets.user != null) { transport.user = secrets.user; @@ -155,12 +155,13 @@ async function executor( if (config.service !== null) { transport.service = config.service; } else { - transport.host = config.host; - transport.port = config.port; + // already validated service or host/port is not null ... + transport.host = config.host!; + transport.port = config.port!; transport.secure = getSecureValue(config.secure, config.port); } - const sendEmailOptions = { + const sendEmailOptions: SendEmailOptions = { transport, routing: { from: config.from, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts new file mode 100644 index 0000000000000..42160dc2fc22b --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -0,0 +1,175 @@ +/* + * 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. + */ + +jest.mock('nodemailer', () => ({ + createTransport: jest.fn(), +})); + +import { Logger } from '../../../../../../src/core/server'; +import { sendEmail } from './send_email'; +import { loggingServiceMock } from '../../../../../../src/core/server/mocks'; +import nodemailer from 'nodemailer'; + +const createTransportMock = nodemailer.createTransport as jest.Mock; +const sendMailMockResult = { result: 'does not matter' }; +const sendMailMock = jest.fn(); + +const mockLogger = loggingServiceMock.create().get() as jest.Mocked<Logger>; + +describe('send_email module', () => { + beforeEach(() => { + jest.resetAllMocks(); + createTransportMock.mockReturnValue({ sendMail: sendMailMock }); + sendMailMock.mockResolvedValue(sendMailMockResult); + }); + + test('handles authenticated email using service', async () => { + const sendEmailOptions = getSendEmailOptions(); + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "auth": Object { + "pass": "changeme", + "user": "elastic", + }, + "service": "whatever", + }, + ] + `); + expect(sendMailMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "bcc": Array [], + "cc": Array [ + "bob@example.com", + "robert@example.com", + ], + "from": "fred@example.com", + "html": "<p>a message</p> + ", + "subject": "a subject", + "text": "a message", + "to": Array [ + "jim@example.com", + ], + }, + ] + `); + }); + + test('handles unauthenticated email using not secure host/port', async () => { + const sendEmailOptions = getSendEmailOptions(); + delete sendEmailOptions.transport.service; + delete sendEmailOptions.transport.user; + delete sendEmailOptions.transport.password; + sendEmailOptions.transport.host = 'example.com'; + sendEmailOptions.transport.port = 1025; + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "host": "example.com", + "port": 1025, + "secure": false, + "tls": Object { + "rejectUnauthorized": false, + }, + }, + ] + `); + expect(sendMailMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "bcc": Array [], + "cc": Array [ + "bob@example.com", + "robert@example.com", + ], + "from": "fred@example.com", + "html": "<p>a message</p> + ", + "subject": "a subject", + "text": "a message", + "to": Array [ + "jim@example.com", + ], + }, + ] + `); + }); + + test('handles unauthenticated email using secure host/port', async () => { + const sendEmailOptions = getSendEmailOptions(); + delete sendEmailOptions.transport.service; + delete sendEmailOptions.transport.user; + delete sendEmailOptions.transport.password; + sendEmailOptions.transport.host = 'example.com'; + sendEmailOptions.transport.port = 1025; + sendEmailOptions.transport.secure = true; + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "host": "example.com", + "port": 1025, + "secure": true, + }, + ] + `); + expect(sendMailMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "bcc": Array [], + "cc": Array [ + "bob@example.com", + "robert@example.com", + ], + "from": "fred@example.com", + "html": "<p>a message</p> + ", + "subject": "a subject", + "text": "a message", + "to": Array [ + "jim@example.com", + ], + }, + ] + `); + }); + + test('passes nodemailer exceptions to caller', async () => { + const sendEmailOptions = getSendEmailOptions(); + + sendMailMock.mockReset(); + sendMailMock.mockRejectedValue(new Error('wops')); + + await expect(sendEmail(mockLogger, sendEmailOptions)).rejects.toThrow('wops'); + }); +}); + +function getSendEmailOptions(): any { + return { + content: { + message: 'a message', + subject: 'a subject', + }, + routing: { + from: 'fred@example.com', + to: ['jim@example.com'], + cc: ['bob@example.com', 'robert@example.com'], + bcc: [], + }, + transport: { + service: 'whatever', + user: 'elastic', + password: 'changeme', + }, + }; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts index 47d7aff8022ce..ffbf7485a8b0b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -14,30 +14,30 @@ import { Logger } from '../../../../../../src/core/server'; // an email "service" which doesn't actually send, just returns what it would send export const JSON_TRANSPORT_SERVICE = '__json'; -interface SendEmailOptions { +export interface SendEmailOptions { transport: Transport; routing: Routing; content: Content; } // config validation ensures either service is set or host/port are set -interface Transport { - user: string; - password: string; +export interface Transport { + user?: string; + password?: string; service?: string; // see: https://nodemailer.com/smtp/well-known/ host?: string; port?: number; secure?: boolean; // see: https://nodemailer.com/smtp/#tls-options } -interface Routing { +export interface Routing { from: string; to: string[]; cc: string[]; bcc: string[]; } -interface Content { +export interface Content { subject: string; message: string; } @@ -49,12 +49,14 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom const { from, to, cc, bcc } = routing; const { subject, message } = content; - const transportConfig: Record<string, any> = { - auth: { + const transportConfig: Record<string, any> = {}; + + if (user != null && password != null) { + transportConfig.auth = { user, pass: password, - }, - }; + }; + } if (service === JSON_TRANSPORT_SERVICE) { transportConfig.jsonTransport = true; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts index 1acb6c563801c..cc07a0b90330d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts @@ -147,7 +147,14 @@ class ServiceNow { comments: Comment[], field: string ): Promise<CommentResponse[]> { - const res = await Promise.all(comments.map(c => this.createComment(incidentId, c, field))); + // Create comments sequentially. + const promises = comments.reduce(async (prevPromise, currentComment) => { + const totalComments = await prevPromise; + const res = await this.createComment(incidentId, currentComment, field); + return [...totalComments, res]; + }, Promise.resolve([] as CommentResponse[])); + + const res = await promises; return res; } diff --git a/x-pack/plugins/actions/server/create_execute_function.test.ts b/x-pack/plugins/actions/server/create_execute_function.test.ts index 68c3967359ff4..6bdd30848e4b7 100644 --- a/x-pack/plugins/actions/server/create_execute_function.test.ts +++ b/x-pack/plugins/actions/server/create_execute_function.test.ts @@ -23,6 +23,7 @@ describe('execute()', () => { actionTypeRegistry: actionTypeRegistryMock.create(), getScopedSavedObjectsClient: jest.fn().mockReturnValueOnce(savedObjectsClient), isESOUsingEphemeralEncryptionKey: false, + preconfiguredActions: [], }); savedObjectsClient.get.mockResolvedValueOnce({ id: '123', @@ -68,6 +69,68 @@ describe('execute()', () => { }); }); + test('schedules the action with all given parameters with a preconfigured action', async () => { + const executeFn = createExecuteFunction({ + getBasePath, + taskManager: mockTaskManager, + actionTypeRegistry: actionTypeRegistryMock.create(), + getScopedSavedObjectsClient: jest.fn().mockReturnValueOnce(savedObjectsClient), + isESOUsingEphemeralEncryptionKey: false, + preconfiguredActions: [ + { + id: '123', + actionTypeId: 'mock-action-preconfigured', + config: {}, + isPreconfigured: true, + name: 'x', + secrets: {}, + }, + ], + }); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '123', + type: 'action', + attributes: { + actionTypeId: 'mock-action', + }, + references: [], + }); + savedObjectsClient.create.mockResolvedValueOnce({ + id: '234', + type: 'action_task_params', + attributes: {}, + references: [], + }); + await executeFn({ + id: '123', + params: { baz: false }, + spaceId: 'default', + apiKey: Buffer.from('123:abc').toString('base64'), + }); + expect(mockTaskManager.schedule).toHaveBeenCalledTimes(1); + expect(mockTaskManager.schedule.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "params": Object { + "actionTaskParamsId": "234", + "spaceId": "default", + }, + "scope": Array [ + "actions", + ], + "state": Object {}, + "taskType": "actions:mock-action-preconfigured", + }, + ] + `); + expect(savedObjectsClient.get).not.toHaveBeenCalled(); + expect(savedObjectsClient.create).toHaveBeenCalledWith('action_task_params', { + actionId: '123', + params: { baz: false }, + apiKey: Buffer.from('123:abc').toString('base64'), + }); + }); + test('uses API key when provided', async () => { const getScopedSavedObjectsClient = jest.fn().mockReturnValueOnce(savedObjectsClient); const executeFn = createExecuteFunction({ @@ -76,6 +139,7 @@ describe('execute()', () => { getScopedSavedObjectsClient, isESOUsingEphemeralEncryptionKey: false, actionTypeRegistry: actionTypeRegistryMock.create(), + preconfiguredActions: [], }); savedObjectsClient.get.mockResolvedValueOnce({ id: '123', @@ -125,6 +189,7 @@ describe('execute()', () => { getScopedSavedObjectsClient, isESOUsingEphemeralEncryptionKey: false, actionTypeRegistry: actionTypeRegistryMock.create(), + preconfiguredActions: [], }); savedObjectsClient.get.mockResolvedValueOnce({ id: '123', @@ -171,6 +236,7 @@ describe('execute()', () => { getScopedSavedObjectsClient, isESOUsingEphemeralEncryptionKey: true, actionTypeRegistry: actionTypeRegistryMock.create(), + preconfiguredActions: [], }); await expect( executeFn({ @@ -193,6 +259,7 @@ describe('execute()', () => { getScopedSavedObjectsClient, isESOUsingEphemeralEncryptionKey: false, actionTypeRegistry: mockedActionTypeRegistry, + preconfiguredActions: [], }); mockedActionTypeRegistry.ensureActionTypeEnabled.mockImplementation(() => { throw new Error('Fail'); diff --git a/x-pack/plugins/actions/server/create_execute_function.ts b/x-pack/plugins/actions/server/create_execute_function.ts index 4bbcda4cba7fc..4a9ddf412b7cc 100644 --- a/x-pack/plugins/actions/server/create_execute_function.ts +++ b/x-pack/plugins/actions/server/create_execute_function.ts @@ -6,7 +6,12 @@ import { SavedObjectsClientContract } from '../../../../src/core/server'; import { TaskManagerStartContract } from '../../task_manager/server'; -import { GetBasePathFunction, RawAction, ActionTypeRegistryContract } from './types'; +import { + GetBasePathFunction, + RawAction, + ActionTypeRegistryContract, + PreConfiguredAction, +} from './types'; interface CreateExecuteFunctionOptions { taskManager: TaskManagerStartContract; @@ -14,6 +19,7 @@ interface CreateExecuteFunctionOptions { getBasePath: GetBasePathFunction; isESOUsingEphemeralEncryptionKey: boolean; actionTypeRegistry: ActionTypeRegistryContract; + preconfiguredActions: PreConfiguredAction[]; } export interface ExecuteOptions { @@ -29,6 +35,7 @@ export function createExecuteFunction({ actionTypeRegistry, getScopedSavedObjectsClient, isESOUsingEphemeralEncryptionKey, + preconfiguredActions, }: CreateExecuteFunctionOptions) { return async function execute({ id, params, spaceId, apiKey }: ExecuteOptions) { if (isESOUsingEphemeralEncryptionKey === true) { @@ -61,9 +68,9 @@ export function createExecuteFunction({ }; const savedObjectsClient = getScopedSavedObjectsClient(fakeRequest); - const actionSavedObject = await savedObjectsClient.get<RawAction>('action', id); + const actionTypeId = await getActionTypeId(id); - actionTypeRegistry.ensureActionTypeEnabled(actionSavedObject.attributes.actionTypeId); + actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); const actionTaskParamsRecord = await savedObjectsClient.create('action_task_params', { actionId: id, @@ -72,7 +79,7 @@ export function createExecuteFunction({ }); await taskManager.schedule({ - taskType: `actions:${actionSavedObject.attributes.actionTypeId}`, + taskType: `actions:${actionTypeId}`, params: { spaceId, actionTaskParamsId: actionTaskParamsRecord.id, @@ -80,5 +87,15 @@ export function createExecuteFunction({ state: {}, scope: ['actions'], }); + + async function getActionTypeId(actionId: string): Promise<string> { + const pcAction = preconfiguredActions.find(action => action.id === actionId); + if (pcAction) { + return pcAction.actionTypeId; + } + + const actionSO = await savedObjectsClient.get<RawAction>('action', actionId); + return actionSO.attributes.actionTypeId; + } }; } diff --git a/x-pack/plugins/actions/server/lib/action_executor.test.ts b/x-pack/plugins/actions/server/lib/action_executor.test.ts index bbcb0457fc1d1..124e5951c714b 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.test.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts @@ -43,6 +43,7 @@ actionExecutor.initialize({ actionTypeRegistry, encryptedSavedObjectsPlugin, eventLogger: eventLoggerMock.create(), + preconfiguredActions: [], }); beforeEach(() => { @@ -232,6 +233,7 @@ test('throws an error when passing isESOUsingEphemeralEncryptionKey with value o actionTypeRegistry, encryptedSavedObjectsPlugin, eventLogger: eventLoggerMock.create(), + preconfiguredActions: [], }); await expect( customActionExecutor.execute(executeParams) diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index af0353247d99f..a33fb8830a930 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -11,6 +11,8 @@ import { ActionTypeRegistryContract, GetServicesFunction, RawAction, + PreConfiguredAction, + Services, } from '../types'; import { EncryptedSavedObjectsPluginStart } from '../../../encrypted_saved_objects/server'; import { SpacesServiceSetup } from '../../../spaces/server'; @@ -24,6 +26,7 @@ export interface ActionExecutorContext { encryptedSavedObjectsPlugin: EncryptedSavedObjectsPluginStart; actionTypeRegistry: ActionTypeRegistryContract; eventLogger: IEventLogger; + preconfiguredActions: PreConfiguredAction[]; } export interface ExecuteOptions { @@ -72,29 +75,22 @@ export class ActionExecutor { encryptedSavedObjectsPlugin, actionTypeRegistry, eventLogger, + preconfiguredActions, } = this.actionExecutorContext!; const services = getServices(request); - const namespace = spaces && spaces.getSpaceId(request); + const spaceId = spaces && spaces.getSpaceId(request); + const namespace = spaceId && spaceId !== 'default' ? { namespace: spaceId } : {}; - // Ensure user can read the action before processing - const { - attributes: { actionTypeId, config, name }, - } = await services.savedObjectsClient.get<RawAction>('action', actionId); - - actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); - - // Only get encrypted attributes here, the remaining attributes can be fetched in - // the savedObjectsClient call - const { - attributes: { secrets }, - } = await encryptedSavedObjectsPlugin.getDecryptedAsInternalUser<RawAction>( - 'action', + const { actionTypeId, name, config, secrets } = await getActionInfo( + services, + encryptedSavedObjectsPlugin, + preconfiguredActions, actionId, - { - namespace: namespace === 'default' ? undefined : namespace, - } + namespace.namespace ); + + actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); const actionType = actionTypeRegistry.get(actionTypeId); let validatedParams: Record<string, any>; @@ -112,7 +108,7 @@ export class ActionExecutor { const actionLabel = `${actionTypeId}:${actionId}: ${name}`; const event: IEvent = { event: { action: EVENT_LOG_ACTIONS.execute }, - kibana: { namespace, saved_objects: [{ type: 'action', id: actionId }] }, + kibana: { saved_objects: [{ type: 'action', id: actionId, ...namespace }] }, }; eventLogger.startTiming(event); @@ -174,3 +170,50 @@ function actionErrorToMessage(result: ActionTypeExecutorResult): string { return message; } + +interface ActionInfo { + actionTypeId: string; + name: string; + config: any; + secrets: any; +} + +async function getActionInfo( + services: Services, + encryptedSavedObjectsPlugin: EncryptedSavedObjectsPluginStart, + preconfiguredActions: PreConfiguredAction[], + actionId: string, + namespace: string | undefined +): Promise<ActionInfo> { + // check to see if it's a pre-configured action first + const pcAction = preconfiguredActions.find( + preconfiguredAction => preconfiguredAction.id === actionId + ); + if (pcAction) { + return { + actionTypeId: pcAction.actionTypeId, + name: pcAction.name, + config: pcAction.config, + secrets: pcAction.secrets, + }; + } + + // if not pre-configured action, should be a saved object + // ensure user can read the action before processing + const { + attributes: { actionTypeId, config, name }, + } = await services.savedObjectsClient.get<RawAction>('action', actionId); + + const { + attributes: { secrets }, + } = await encryptedSavedObjectsPlugin.getDecryptedAsInternalUser<RawAction>('action', actionId, { + namespace: namespace === 'default' ? undefined : namespace, + }); + + return { + actionTypeId, + name, + config, + secrets, + }; +} diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts index 43882cef21170..f070f714ee508 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts @@ -61,6 +61,7 @@ const actionExecutorInitializerParams = { actionTypeRegistry, encryptedSavedObjectsPlugin: mockedEncryptedSavedObjectsPlugin, eventLogger: eventLoggerMock.create(), + preconfiguredActions: [], }; const taskRunnerFactoryInitializerParams = { spaceIdToNamespace, diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts index 6215b08df81d4..fa5b2f9399a4d 100644 --- a/x-pack/plugins/actions/server/plugin.test.ts +++ b/x-pack/plugins/actions/server/plugin.test.ts @@ -99,6 +99,9 @@ describe('Actions Plugin', () => { savedObjects: { client: {}, }, + elasticsearch: { + adminClient: jest.fn(), + }, }, } as any, httpServerMock.createKibanaRequest(), diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 34c9e7aa9e8b8..a8ab3bbb2fad2 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -11,13 +11,13 @@ import { Plugin, CoreSetup, CoreStart, - IClusterClient, KibanaRequest, Logger, SharedGlobalConfig, RequestHandler, IContextProvider, SavedObjectsServiceStart, + ElasticsearchServiceStart, } from '../../../../src/core/server'; import { @@ -89,7 +89,6 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi private readonly logger: Logger; private serverBasePath?: string; - private adminClient?: IClusterClient; private taskRunnerFactory?: TaskRunnerFactory; private actionTypeRegistry?: ActionTypeRegistry; private actionExecutor?: ActionExecutor; @@ -173,7 +172,6 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi this.actionTypeRegistry = actionTypeRegistry; this.serverBasePath = core.http.basePath.serverBasePath; this.actionExecutor = actionExecutor; - this.adminClient = core.elasticsearch.adminClient; this.spaces = plugins.spaces?.spacesService; registerBuiltInActionTypes({ @@ -233,7 +231,6 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi actionTypeRegistry, taskRunnerFactory, kibanaIndex, - adminClient, isESOUsingEphemeralEncryptionKey, preconfiguredActions, } = this; @@ -242,9 +239,10 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi logger, eventLogger: this.eventLogger!, spaces: this.spaces, - getServices: this.getServicesFactory(core.savedObjects), + getServices: this.getServicesFactory(core.savedObjects, core.elasticsearch), encryptedSavedObjectsPlugin: plugins.encryptedSavedObjects, actionTypeRegistry: actionTypeRegistry!, + preconfiguredActions, }); taskRunnerFactory!.initialize({ @@ -265,6 +263,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi getScopedSavedObjectsClient: core.savedObjects.getScopedClient, getBasePath: this.getBasePath, isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!, + preconfiguredActions, }), isActionTypeEnabled: id => { return this.actionTypeRegistry!.isActionTypeEnabled(id); @@ -280,7 +279,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi savedObjectsClient: core.savedObjects.getScopedClient(request), actionTypeRegistry: actionTypeRegistry!, defaultKibanaIndex: await kibanaIndex, - scopedClusterClient: adminClient!.asScoped(request), + scopedClusterClient: core.elasticsearch.legacy.client.asScoped(request), preconfiguredActions, }); }, @@ -289,11 +288,11 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi } private getServicesFactory( - savedObjects: SavedObjectsServiceStart + savedObjects: SavedObjectsServiceStart, + elasticsearch: ElasticsearchServiceStart ): (request: KibanaRequest) => Services { - const { adminClient } = this; return request => ({ - callCluster: adminClient!.asScoped(request).callAsCurrentUser, + callCluster: elasticsearch.legacy.client.asScoped(request).callAsCurrentUser, savedObjectsClient: savedObjects.getScopedClient(request), }); } @@ -301,12 +300,8 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi private createRouteHandlerContext = ( defaultKibanaIndex: string ): IContextProvider<RequestHandler<any, any, any>, 'actions'> => { - const { - actionTypeRegistry, - adminClient, - isESOUsingEphemeralEncryptionKey, - preconfiguredActions, - } = this; + const { actionTypeRegistry, isESOUsingEphemeralEncryptionKey, preconfiguredActions } = this; + return async function actionsRouteHandlerContext(context, request) { return { getActionsClient: () => { @@ -319,7 +314,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi savedObjectsClient: context.core.savedObjects.client, actionTypeRegistry: actionTypeRegistry!, defaultKibanaIndex, - scopedClusterClient: adminClient!.asScoped(request), + scopedClusterClient: context.core.elasticsearch.adminClient, preconfiguredActions, }); }, diff --git a/x-pack/plugins/actions/server/usage/task.ts b/x-pack/plugins/actions/server/usage/task.ts index a07a2aa8f1c70..ed0d876ed0208 100644 --- a/x-pack/plugins/actions/server/usage/task.ts +++ b/x-pack/plugins/actions/server/usage/task.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Logger, CoreSetup } from 'kibana/server'; +import { Logger, CoreSetup, APICaller } from 'kibana/server'; import moment from 'moment'; import { RunContext, @@ -62,7 +62,11 @@ async function scheduleTasks(logger: Logger, taskManager: TaskManagerStartContra export function telemetryTaskRunner(logger: Logger, core: CoreSetup, kibanaIndex: string) { return ({ taskInstance }: RunContext) => { const { state } = taskInstance; - const callCluster = core.elasticsearch.adminClient.callAsInternalUser; + const callCluster = (...args: Parameters<APICaller>) => { + return core.getStartServices().then(([{ elasticsearch: { legacy: { client } } }]) => + client.callAsInternalUser(...args) + ); + }; return { async run() { return Promise.all([ diff --git a/x-pack/plugins/alerting/server/alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client.ts index 6f8478df58a53..3e4c26d3444c9 100644 --- a/x-pack/plugins/alerting/server/alerts_client.ts +++ b/x-pack/plugins/alerting/server/alerts_client.ts @@ -34,6 +34,7 @@ import { import { EncryptedSavedObjectsPluginStart } from '../../../plugins/encrypted_saved_objects/server'; import { TaskManagerStartContract } from '../../../plugins/task_manager/server'; import { taskInstanceToAlertTaskInstance } from './task_runner/alert_task_instance'; +import { deleteTaskIfItExists } from './lib/delete_task_if_it_exists'; type NormalizedAlertAction = Omit<AlertAction, 'actionTypeId'>; export type CreateAPIKeyResult = @@ -268,7 +269,7 @@ export class AlertsClient { const removeResult = await this.savedObjectsClient.delete('alert', id); await Promise.all([ - taskIdToRemove ? this.taskManager.remove(taskIdToRemove) : null, + taskIdToRemove ? deleteTaskIfItExists(this.taskManager, taskIdToRemove) : null, apiKeyToInvalidate ? this.invalidateApiKey({ apiKey: apiKeyToInvalidate }) : null, ]); @@ -510,7 +511,9 @@ export class AlertsClient { ); await Promise.all([ - attributes.scheduledTaskId ? this.taskManager.remove(attributes.scheduledTaskId) : null, + attributes.scheduledTaskId + ? deleteTaskIfItExists(this.taskManager, attributes.scheduledTaskId) + : null, apiKeyToInvalidate ? this.invalidateApiKey({ apiKey: apiKeyToInvalidate }) : null, ]); } diff --git a/x-pack/plugins/alerting/server/lib/delete_task_if_it_exists.test.ts b/x-pack/plugins/alerting/server/lib/delete_task_if_it_exists.test.ts new file mode 100644 index 0000000000000..84a1743387c9c --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/delete_task_if_it_exists.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; +import { taskManagerMock } from '../../../task_manager/server/mocks'; +import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; +import { deleteTaskIfItExists } from './delete_task_if_it_exists'; + +describe('deleteTaskIfItExists', () => { + test('removes the task by its ID', async () => { + const tm = taskManagerMock.createStart(); + const id = uuid.v4(); + + expect(await deleteTaskIfItExists(tm, id)).toBe(undefined); + + expect(tm.remove).toHaveBeenCalledWith(id); + }); + + test('handles 404 errors caused by the task not existing', async () => { + const tm = taskManagerMock.createStart(); + const id = uuid.v4(); + + tm.remove.mockRejectedValue(SavedObjectsErrorHelpers.createGenericNotFoundError('task', id)); + + expect(await deleteTaskIfItExists(tm, id)).toBe(undefined); + + expect(tm.remove).toHaveBeenCalledWith(id); + }); + + test('throws if any other errro is caused by task removal', async () => { + const tm = taskManagerMock.createStart(); + const id = uuid.v4(); + + const error = SavedObjectsErrorHelpers.createInvalidVersionError(uuid.v4()); + tm.remove.mockRejectedValue(error); + + expect(deleteTaskIfItExists(tm, id)).rejects.toBe(error); + + expect(tm.remove).toHaveBeenCalledWith(id); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/delete_task_if_it_exists.ts b/x-pack/plugins/alerting/server/lib/delete_task_if_it_exists.ts new file mode 100644 index 0000000000000..53bb1b5cb5d53 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/delete_task_if_it_exists.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 { TaskManagerStartContract } from '../../../task_manager/server'; +import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; + +export async function deleteTaskIfItExists(taskManager: TaskManagerStartContract, taskId: string) { + try { + await taskManager.remove(taskId); + } catch (err) { + if (!SavedObjectsErrorHelpers.isNotFoundError(err)) { + throw err; + } + } +} diff --git a/x-pack/plugins/alerting/server/lib/is_alert_not_found_error.test.ts b/x-pack/plugins/alerting/server/lib/is_alert_not_found_error.test.ts new file mode 100644 index 0000000000000..46ceee3ce420b --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/is_alert_not_found_error.test.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 { isAlertSavedObjectNotFoundError } from './is_alert_not_found_error'; +import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; +import uuid from 'uuid'; + +describe('isAlertSavedObjectNotFoundError', () => { + test('identifies SavedObjects Not Found errors', () => { + const id = uuid.v4(); + // ensure the error created by SO parses as a string with the format we expect + expect( + `${SavedObjectsErrorHelpers.createGenericNotFoundError('alert', id)}`.includes(`alert/${id}`) + ).toBe(true); + + const errorBySavedObjectsHelper = SavedObjectsErrorHelpers.createGenericNotFoundError( + 'alert', + id + ); + + expect(isAlertSavedObjectNotFoundError(errorBySavedObjectsHelper, id)).toBe(true); + }); + + test('identifies generic errors', () => { + const id = uuid.v4(); + expect(isAlertSavedObjectNotFoundError(new Error(`not found`), id)).toBe(false); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/is_alert_not_found_error.ts b/x-pack/plugins/alerting/server/lib/is_alert_not_found_error.ts new file mode 100644 index 0000000000000..0aa83ad0e883c --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/is_alert_not_found_error.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 { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; + +export function isAlertSavedObjectNotFoundError(err: Error, alertId: string) { + return SavedObjectsErrorHelpers.isNotFoundError(err) && `${err}`.includes(alertId); +} diff --git a/x-pack/plugins/alerting/server/mocks.ts b/x-pack/plugins/alerting/server/mocks.ts index 55ad722dcf881..a9e224142a632 100644 --- a/x-pack/plugins/alerting/server/mocks.ts +++ b/x-pack/plugins/alerting/server/mocks.ts @@ -6,6 +6,8 @@ import { alertsClientMock } from './alerts_client.mock'; import { PluginSetupContract, PluginStartContract } from './plugin'; +import { savedObjectsClientMock } from '../../../../src/core/server/mocks'; +import { AlertInstance } from './alert_instance'; export { alertsClientMock }; @@ -24,7 +26,44 @@ const createStartMock = () => { return mock; }; +export type AlertInstanceMock = jest.Mocked<AlertInstance>; +const createAlertInstanceFactoryMock = () => { + const mock = { + hasScheduledActions: jest.fn(), + isThrottled: jest.fn(), + getScheduledActionOptions: jest.fn(), + unscheduleActions: jest.fn(), + getState: jest.fn(), + scheduleActions: jest.fn(), + replaceState: jest.fn(), + updateLastScheduledActions: jest.fn(), + toJSON: jest.fn(), + toRaw: jest.fn(), + }; + + // support chaining + mock.replaceState.mockReturnValue(mock); + mock.unscheduleActions.mockReturnValue(mock); + mock.scheduleActions.mockReturnValue(mock); + + return (mock as unknown) as AlertInstanceMock; +}; + +const createAlertServicesMock = () => { + const alertInstanceFactoryMock = createAlertInstanceFactoryMock(); + return { + alertInstanceFactory: jest + .fn<jest.Mocked<AlertInstance>, [string]>() + .mockReturnValue(alertInstanceFactoryMock), + callCluster: jest.fn(), + savedObjectsClient: savedObjectsClientMock.create(), + }; +}; +export type AlertServicesMock = ReturnType<typeof createAlertServicesMock>; + export const alertsMock = { + createAlertInstanceFactory: createAlertInstanceFactoryMock, createSetup: createSetupMock, createStart: createStartMock, + createAlertServices: createAlertServicesMock, }; diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index fdca6c0a9b503..ad39d09bd6d3d 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -19,7 +19,6 @@ import { TaskRunnerFactory } from './task_runner'; import { AlertsClientFactory } from './alerts_client_factory'; import { LicenseState } from './lib/license_state'; import { - IClusterClient, KibanaRequest, Logger, PluginInitializerContext, @@ -29,6 +28,7 @@ import { IContextProvider, RequestHandler, SharedGlobalConfig, + ElasticsearchServiceStart, } from '../../../../src/core/server'; import { @@ -94,7 +94,6 @@ export class AlertingPlugin { private readonly logger: Logger; private alertTypeRegistry?: AlertTypeRegistry; private readonly taskRunnerFactory: TaskRunnerFactory; - private adminClient?: IClusterClient; private serverBasePath?: string; private licenseState: LicenseState | null = null; private isESOUsingEphemeralEncryptionKey?: boolean; @@ -119,7 +118,6 @@ export class AlertingPlugin { } public async setup(core: CoreSetup, plugins: AlertingPluginsSetup): Promise<PluginSetupContract> { - this.adminClient = core.elasticsearch.adminClient; this.licenseState = new LicenseState(plugins.licensing.license$); this.spaces = plugins.spaces?.spacesService; this.security = plugins.security; @@ -223,7 +221,7 @@ export class AlertingPlugin { taskRunnerFactory.initialize({ logger, - getServices: this.getServicesFactory(core.savedObjects), + getServices: this.getServicesFactory(core.savedObjects, core.elasticsearch), spaceIdToNamespace: this.spaceIdToNamespace, actionsPlugin: plugins.actions, encryptedSavedObjectsPlugin: plugins.encryptedSavedObjects, @@ -263,11 +261,11 @@ export class AlertingPlugin { }; private getServicesFactory( - savedObjects: SavedObjectsServiceStart + savedObjects: SavedObjectsServiceStart, + elasticsearch: ElasticsearchServiceStart ): (request: KibanaRequest) => Services { - const { adminClient } = this; return request => ({ - callCluster: adminClient!.asScoped(request).callAsCurrentUser, + callCluster: elasticsearch.legacy.client.asScoped(request).callAsCurrentUser, savedObjectsClient: savedObjects.getScopedClient(request), }); } diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts index 8d037a1ecee91..756080baba626 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts @@ -91,7 +91,6 @@ test('calls actionsPlugin.execute per selected action', async () => { "alerting": Object { "instance_id": "2", }, - "namespace": "default", "saved_objects": Array [ Object { "id": "1", diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts index de06c8bbb374a..72f9e70905dc2 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts @@ -87,16 +87,17 @@ export function createExecutionHandler({ apiKey, }); + const namespace = spaceId === 'default' ? {} : { namespace: spaceId }; + const event: IEvent = { event: { action: EVENT_LOG_ACTIONS.executeAction }, kibana: { alerting: { instance_id: alertInstanceId, }, - namespace: spaceId, saved_objects: [ - { type: 'alert', id: alertId }, - { type: 'action', id: action.id }, + { type: 'alert', id: alertId, ...namespace }, + { type: 'action', id: action.id, ...namespace }, ], }, }; diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 520f8d5c99b16..31cc893f785cb 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -16,6 +16,7 @@ import { PluginStartContract as ActionsPluginStart } from '../../../actions/serv import { actionsMock } from '../../../actions/server/mocks'; import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; import { IEventLogger } from '../../../event_log/server'; +import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; const alertType = { id: 'test', @@ -169,10 +170,10 @@ describe('Task Runner', () => { "action": "execute", }, "kibana": Object { - "namespace": undefined, "saved_objects": Array [ Object { "id": "1", + "namespace": undefined, "type": "alert", }, ], @@ -229,10 +230,10 @@ describe('Task Runner', () => { "action": "execute", }, "kibana": Object { - "namespace": undefined, "saved_objects": Array [ Object { "id": "1", + "namespace": undefined, "type": "alert", }, ], @@ -249,10 +250,10 @@ describe('Task Runner', () => { "alerting": Object { "instance_id": "1", }, - "namespace": undefined, "saved_objects": Array [ Object { "id": "1", + "namespace": undefined, "type": "alert", }, ], @@ -269,14 +270,15 @@ describe('Task Runner', () => { "alerting": Object { "instance_id": "1", }, - "namespace": undefined, "saved_objects": Array [ Object { "id": "1", + "namespace": undefined, "type": "alert", }, Object { "id": "1", + "namespace": undefined, "type": "action", }, ], @@ -344,10 +346,10 @@ describe('Task Runner', () => { "action": "execute", }, "kibana": Object { - "namespace": undefined, "saved_objects": Array [ Object { "id": "1", + "namespace": undefined, "type": "alert", }, ], @@ -364,10 +366,10 @@ describe('Task Runner', () => { "alerting": Object { "instance_id": "2", }, - "namespace": undefined, "saved_objects": Array [ Object { "id": "1", + "namespace": undefined, "type": "alert", }, ], @@ -560,10 +562,10 @@ describe('Task Runner', () => { "action": "execute", }, "kibana": Object { - "namespace": undefined, "saved_objects": Array [ Object { "id": "1", + "namespace": undefined, "type": "alert", }, ], @@ -664,4 +666,36 @@ describe('Task Runner', () => { } `); }); + + test('avoids rescheduling a failed Alert Task Runner when it throws due to failing to fetch the alert', async () => { + savedObjectsClient.get.mockImplementation(() => { + throw SavedObjectsErrorHelpers.createGenericNotFoundError('task', '1'); + }); + + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + + const runnerResult = await taskRunner.run(); + + expect(runnerResult).toMatchInlineSnapshot(` + Object { + "runAt": undefined, + "state": Object { + "previousStartedAt": 1970-01-01T00:00:00.000Z, + }, + } + `); + }); }); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index 2ba56396279ea..1d4b12e96bc76 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -26,12 +26,13 @@ import { taskInstanceToAlertTaskInstance } from './alert_task_instance'; import { AlertInstances } from '../alert_instance/alert_instance'; import { EVENT_LOG_ACTIONS } from '../plugin'; import { IEvent, IEventLogger } from '../../../event_log/server'; +import { isAlertSavedObjectNotFoundError } from '../lib/is_alert_not_found_error'; const FALLBACK_RETRY_INTERVAL: IntervalSchedule = { interval: '5m' }; interface AlertTaskRunResult { state: AlertTaskState; - runAt: Date; + runAt: Date | undefined; } interface AlertTaskInstance extends ConcreteTaskInstance { @@ -173,7 +174,7 @@ export class TaskRunner { const alertLabel = `${this.alertType.id}:${alertId}: '${name}'`; const event: IEvent = { event: { action: EVENT_LOG_ACTIONS.execute }, - kibana: { namespace, saved_objects: [{ type: 'alert', id: alertId }] }, + kibana: { saved_objects: [{ type: 'alert', id: alertId, namespace }] }, }; eventLogger.startTiming(event); @@ -328,22 +329,29 @@ export class TaskRunner { }; }, (err: Error) => { - this.logger.error(`Executing Alert "${alertId}" has resulted in Error: ${err.message}`); + const message = `Executing Alert "${alertId}" has resulted in Error: ${err.message}`; + if (isAlertSavedObjectNotFoundError(err, alertId)) { + this.logger.debug(message); + } else { + this.logger.error(message); + } return { ...originalState, previousStartedAt, }; } ), - runAt: resolveErr<Date, Error>(runAt, () => - getNextRunAt( - new Date(), - // if we fail at this point we wish to recover but don't have access to the Alert's - // attributes, so we'll use a default interval to prevent the underlying task from - // falling into a failed state - FALLBACK_RETRY_INTERVAL - ) - ), + runAt: resolveErr<Date | undefined, Error>(runAt, err => { + return isAlertSavedObjectNotFoundError(err, alertId) + ? undefined + : getNextRunAt( + new Date(), + // if we fail at this point we wish to recover but don't have access to the Alert's + // attributes, so we'll use a default interval to prevent the underlying task from + // falling into a failed state + FALLBACK_RETRY_INTERVAL + ); + }), }; } } @@ -378,11 +386,10 @@ function generateNewAndResolvedInstanceEvents(params: GenerateNewAndResolvedInst action, }, kibana: { - namespace: params.namespace, alerting: { instance_id: id, }, - saved_objects: [{ type: 'alert', id: params.alertId }], + saved_objects: [{ type: 'alert', id: params.alertId, namespace: params.namespace }], }, message, }; diff --git a/x-pack/plugins/alerting/server/usage/task.ts b/x-pack/plugins/alerting/server/usage/task.ts index 3da60aef301e2..ab62d81d44f8a 100644 --- a/x-pack/plugins/alerting/server/usage/task.ts +++ b/x-pack/plugins/alerting/server/usage/task.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Logger, CoreSetup } from 'kibana/server'; +import { Logger, CoreSetup, APICaller } from 'kibana/server'; import moment from 'moment'; import { RunContext, @@ -65,7 +65,12 @@ async function scheduleTasks(logger: Logger, taskManager: TaskManagerStartContra export function telemetryTaskRunner(logger: Logger, core: CoreSetup, kibanaIndex: string) { return ({ taskInstance }: RunContext) => { const { state } = taskInstance; - const callCluster = core.elasticsearch.adminClient.callAsInternalUser; + const callCluster = (...args: Parameters<APICaller>) => { + return core.getStartServices().then(([{ elasticsearch: { legacy: { client } } }]) => + client.callAsInternalUser(...args) + ); + }; + return { async run() { return Promise.all([ diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap index 49840d2157af7..ea706be9f584a 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap @@ -29,15 +29,19 @@ Array [ "options": Array [ Object { "text": "off", + "value": "off", }, Object { "text": "errors", + "value": "errors", }, Object { "text": "transactions", + "value": "transactions", }, Object { "text": "all", + "value": "all", }, ], "type": "select", diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index e73aed35e87f9..7477238ba79ae 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -67,10 +67,10 @@ export const generalSettings: RawSettingDefinition[] = [ } ), options: [ - { text: 'off' }, - { text: 'errors' }, - { text: 'transactions' }, - { text: 'all' } + { text: 'off', value: 'off' }, + { text: 'errors', value: 'errors' }, + { text: 'transactions', value: 'transactions' }, + { text: 'all', value: 'all' } ], excludeAgents: ['js-base', 'rum-js'] }, diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts index 282ced346dda0..815b8cb3d4e83 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts @@ -76,7 +76,7 @@ interface FloatSetting extends BaseSetting { interface SelectSetting extends BaseSetting { type: 'select'; - options: Array<{ text: string }>; + options: Array<{ text: string; value: string }>; } interface BooleanSetting extends BaseSetting { diff --git a/x-pack/plugins/apm/common/ml_job_constants.test.ts b/x-pack/plugins/apm/common/ml_job_constants.test.ts index cea66f31303be..2aa50a305f7c8 100644 --- a/x-pack/plugins/apm/common/ml_job_constants.test.ts +++ b/x-pack/plugins/apm/common/ml_job_constants.test.ts @@ -21,6 +21,12 @@ describe('ml_job_constants', () => { expect(getMlJobId('myServiceName', 'myTransactionType')).toBe( 'myservicename-mytransactiontype-high_mean_response_time' ); + expect(getMlJobId('my service name')).toBe( + 'my_service_name-high_mean_response_time' + ); + expect(getMlJobId('my service name', 'my transaction type')).toBe( + 'my_service_name-my_transaction_type-high_mean_response_time' + ); }); it('getMlIndex', () => { diff --git a/x-pack/plugins/apm/common/ml_job_constants.ts b/x-pack/plugins/apm/common/ml_job_constants.ts index 37487d09e4f4c..01f5762e2dc4b 100644 --- a/x-pack/plugins/apm/common/ml_job_constants.ts +++ b/x-pack/plugins/apm/common/ml_job_constants.ts @@ -6,7 +6,7 @@ export function getMlPrefix(serviceName: string, transactionType?: string) { const maybeTransactionType = transactionType ? `${transactionType}-` : ''; - return `${serviceName}-${maybeTransactionType}`.toLowerCase(); + return encodeForMlApi(`${serviceName}-${maybeTransactionType}`); } export function getMlJobId(serviceName: string, transactionType?: string) { @@ -16,3 +16,7 @@ export function getMlJobId(serviceName: string, transactionType?: string) { export function getMlIndex(serviceName: string, transactionType?: string) { return `.ml-anomalies-${getMlJobId(serviceName, transactionType)}`; } + +export function encodeForMlApi(value: string) { + return value.replace(/\s+/g, '_').toLowerCase(); +} diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts index 85f233de2086d..e9801272cd57b 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts @@ -61,10 +61,10 @@ export const tasks: TelemetryTask[] = [ return prevJob.then(async data => { const { processorEvent, timeRange } = current; - const response = await search({ + const totalHitsResponse = await search({ index: indicesByProcessorEvent[processorEvent], body: { - size: 1, + size: 0, query: { bool: { filter: [ @@ -83,25 +83,43 @@ export const tasks: TelemetryTask[] = [ ] } }, - sort: { - '@timestamp': 'asc' - }, - _source: ['@timestamp'], track_total_hits: true } }); - const event = response.hits.hits[0]?._source as { - '@timestamp': number; - }; + const retainmentResponse = + timeRange === 'all' + ? await search({ + index: indicesByProcessorEvent[processorEvent], + body: { + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: processorEvent } } + ] + } + }, + sort: { + '@timestamp': 'asc' + }, + _source: ['@timestamp'] + } + }) + : null; + + const event = retainmentResponse?.hits.hits[0]?._source as + | { + '@timestamp': number; + } + | undefined; return merge({}, data, { counts: { [processorEvent]: { - [timeRange]: response.hits.total.value + [timeRange]: totalHitsResponse.hits.total.value } }, - ...(timeRange === 'all' && event + ...(event ? { retainment: { [processorEvent]: { diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index e18b6d33ca419..b434d41982f4c 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -3,7 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext, Plugin, CoreSetup } from 'src/core/server'; +import { + PluginInitializerContext, + Plugin, + CoreSetup, + CoreStart, + Logger +} from 'src/core/server'; import { Observable, combineLatest, AsyncSubject } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { Server } from 'hapi'; @@ -37,6 +43,8 @@ export interface APMPluginContract { } export class APMPlugin implements Plugin<APMPluginContract> { + private currentConfig?: APMConfig; + private logger?: Logger; legacySetup$: AsyncSubject<LegacySetup>; constructor(private readonly initContext: PluginInitializerContext) { this.initContext = initContext; @@ -56,7 +64,7 @@ export class APMPlugin implements Plugin<APMPluginContract> { actions?: ActionsPlugin['setup']; } ) { - const logger = this.initContext.logger.get(); + this.logger = this.initContext.logger.get(); const config$ = this.initContext.config.create<APMXPackConfig>(); const mergedConfig$ = combineLatest(plugins.apm_oss.config$, config$).pipe( map(([apmOssConfig, apmConfig]) => mergeConfigs(apmOssConfig, apmConfig)) @@ -71,49 +79,40 @@ export class APMPlugin implements Plugin<APMPluginContract> { } this.legacySetup$.subscribe(__LEGACY => { - createApmApi().init(core, { config$: mergedConfig$, logger, __LEGACY }); + createApmApi().init(core, { + config$: mergedConfig$, + logger: this.logger!, + __LEGACY + }); }); - const currentConfig = await mergedConfig$.pipe(take(1)).toPromise(); + this.currentConfig = await mergedConfig$.pipe(take(1)).toPromise(); if ( plugins.taskManager && plugins.usageCollection && - currentConfig['xpack.apm.telemetryCollectionEnabled'] + this.currentConfig['xpack.apm.telemetryCollectionEnabled'] ) { createApmTelemetry({ core, config$: mergedConfig$, usageCollector: plugins.usageCollection, taskManager: plugins.taskManager, - logger + logger: this.logger }); } - // create agent configuration index without blocking setup lifecycle - createApmAgentConfigurationIndex({ - esClient: core.elasticsearch.dataClient, - config: currentConfig, - logger - }); - // create custom action index without blocking setup lifecycle - createApmCustomLinkIndex({ - esClient: core.elasticsearch.dataClient, - config: currentConfig, - logger - }); - plugins.home.tutorials.registerTutorial( tutorialProvider({ - isEnabled: currentConfig['xpack.apm.ui.enabled'], - indexPatternTitle: currentConfig['apm_oss.indexPattern'], + isEnabled: this.currentConfig['xpack.apm.ui.enabled'], + indexPatternTitle: this.currentConfig['apm_oss.indexPattern'], cloud: plugins.cloud, indices: { - errorIndices: currentConfig['apm_oss.errorIndices'], - metricsIndices: currentConfig['apm_oss.metricsIndices'], - onboardingIndices: currentConfig['apm_oss.onboardingIndices'], - sourcemapIndices: currentConfig['apm_oss.sourcemapIndices'], - transactionIndices: currentConfig['apm_oss.transactionIndices'] + errorIndices: this.currentConfig['apm_oss.errorIndices'], + metricsIndices: this.currentConfig['apm_oss.metricsIndices'], + onboardingIndices: this.currentConfig['apm_oss.onboardingIndices'], + sourcemapIndices: this.currentConfig['apm_oss.sourcemapIndices'], + transactionIndices: this.currentConfig['apm_oss.transactionIndices'] } }) ); @@ -127,12 +126,29 @@ export class APMPlugin implements Plugin<APMPluginContract> { getApmIndices: async () => getApmIndices({ savedObjectsClient: await getInternalSavedObjectsClient(core), - config: currentConfig + config: await mergedConfig$.pipe(take(1)).toPromise() }) }; } - public async start() {} + public start(core: CoreStart) { + if (this.currentConfig == null || this.logger == null) { + throw new Error('APMPlugin needs to be setup before calling start()'); + } + + // create agent configuration index without blocking start lifecycle + createApmAgentConfigurationIndex({ + esClient: core.elasticsearch.legacy.client, + config: this.currentConfig, + logger: this.logger + }); + // create custom action index without blocking start lifecycle + createApmCustomLinkIndex({ + esClient: core.elasticsearch.legacy.client, + config: this.currentConfig, + logger: this.logger + }); + } public stop() {} } diff --git a/x-pack/plugins/canvas/server/plugin.ts b/x-pack/plugins/canvas/server/plugin.ts index 0325de9cf29e2..91a5634734559 100644 --- a/x-pack/plugins/canvas/server/plugin.ts +++ b/x-pack/plugins/canvas/server/plugin.ts @@ -14,6 +14,7 @@ import { initRoutes } from './routes'; import { registerCanvasUsageCollector } from './collectors'; import { loadSampleData } from './sample_data'; import { setupInterpreter } from './setup_interpreter'; +import { customElementType, workpadType } from './saved_objects'; interface PluginsSetup { expressions: ExpressionsServerSetup; @@ -29,6 +30,9 @@ export class CanvasPlugin implements Plugin { } public async setup(coreSetup: CoreSetup, plugins: PluginsSetup) { + coreSetup.savedObjects.registerType(customElementType); + coreSetup.savedObjects.registerType(workpadType); + plugins.features.registerFeature({ id: 'canvas', name: 'Canvas', diff --git a/x-pack/plugins/canvas/server/saved_objects/custom_element.ts b/x-pack/plugins/canvas/server/saved_objects/custom_element.ts new file mode 100644 index 0000000000000..14223455cbc21 --- /dev/null +++ b/x-pack/plugins/canvas/server/saved_objects/custom_element.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 { SavedObjectsType } from 'src/core/server'; +import { CUSTOM_ELEMENT_TYPE } from '../../../../legacy/plugins/canvas/common/lib/constants'; + +export const customElementType: SavedObjectsType = { + name: CUSTOM_ELEMENT_TYPE, + hidden: false, + namespaceType: 'single', + mappings: { + dynamic: false, + properties: { + name: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + }, + }, + }, + help: { type: 'text' }, + content: { type: 'text' }, + image: { type: 'text' }, + '@timestamp': { type: 'date' }, + '@created': { type: 'date' }, + }, + }, + migrations: {}, +}; diff --git a/x-pack/plugins/canvas/server/saved_objects/index.ts b/x-pack/plugins/canvas/server/saved_objects/index.ts new file mode 100644 index 0000000000000..dd7e74b87e2f4 --- /dev/null +++ b/x-pack/plugins/canvas/server/saved_objects/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 { workpadType } from './workpad'; +import { customElementType } from './custom_element'; + +export { customElementType, workpadType }; diff --git a/x-pack/plugins/canvas/server/saved_objects/migrations/remove_attributes_id.test.ts b/x-pack/plugins/canvas/server/saved_objects/migrations/remove_attributes_id.test.ts new file mode 100644 index 0000000000000..a7112504e9980 --- /dev/null +++ b/x-pack/plugins/canvas/server/saved_objects/migrations/remove_attributes_id.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { removeAttributesId } from './remove_attributes_id'; + +const context: any = { + log: jest.fn(), +}; + +describe(`removeAttributesId`, () => { + it('does not throw error on empty object', () => { + const migratedDoc = removeAttributesId({} as any, context); + expect(migratedDoc).toMatchInlineSnapshot(`Object {}`); + }); + + it('removes id from "attributes"', () => { + const migratedDoc = removeAttributesId( + { + foo: true, + attributes: { + id: '123', + bar: true, + }, + } as any, + context + ); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "bar": true, + }, + "foo": true, +} +`); + }); +}); diff --git a/x-pack/plugins/canvas/server/saved_objects/migrations/remove_attributes_id.ts b/x-pack/plugins/canvas/server/saved_objects/migrations/remove_attributes_id.ts new file mode 100644 index 0000000000000..893a73d7b5913 --- /dev/null +++ b/x-pack/plugins/canvas/server/saved_objects/migrations/remove_attributes_id.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. + */ + +import { SavedObjectMigrationFn } from 'src/core/server'; + +export const removeAttributesId: SavedObjectMigrationFn = doc => { + if (typeof doc.attributes === 'object' && doc.attributes !== null) { + delete (doc.attributes as any).id; + } + return doc; +}; diff --git a/x-pack/plugins/canvas/server/saved_objects/workpad.ts b/x-pack/plugins/canvas/server/saved_objects/workpad.ts new file mode 100644 index 0000000000000..918f4bf991076 --- /dev/null +++ b/x-pack/plugins/canvas/server/saved_objects/workpad.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 { SavedObjectsType } from 'src/core/server'; +import { CANVAS_TYPE } from '../../../../legacy/plugins/canvas/common/lib/constants'; +import { removeAttributesId } from './migrations/remove_attributes_id'; + +export const workpadType: SavedObjectsType = { + name: CANVAS_TYPE, + hidden: false, + namespaceType: 'single', + mappings: { + dynamic: false, + properties: { + name: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + }, + }, + }, + '@timestamp': { type: 'date' }, + '@created': { type: 'date' }, + }, + }, + migrations: { + '7.0.0': removeAttributesId, + }, +}; diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 3c5d3405f395e..1f08a41024905 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -167,6 +167,7 @@ export type CasesResponse = rt.TypeOf<typeof CasesResponseRt>; export type CasesFindResponse = rt.TypeOf<typeof CasesFindResponseRt>; export type CasePatchRequest = rt.TypeOf<typeof CasePatchRequestRt>; export type CasesPatchRequest = rt.TypeOf<typeof CasesPatchRequestRt>; +export type Status = rt.TypeOf<typeof StatusRt>; export type CaseExternalServiceRequest = rt.TypeOf<typeof CaseExternalServiceRequestRt>; export type ServiceConnectorCaseParams = rt.TypeOf<typeof ServiceConnectorCaseParamsRt>; export type ServiceConnectorCaseResponse = rt.TypeOf<typeof ServiceConnectorCaseResponseRt>; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts index 95cd66a9c51a2..e83dafc68ee69 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -11,14 +11,20 @@ import { SavedObjectsBulkUpdateObject, } from 'src/core/server'; -import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../../saved_object_types'; +import { + CASE_COMMENT_SAVED_OBJECT, + CASE_SAVED_OBJECT, + CASE_CONFIGURE_SAVED_OBJECT, +} from '../../../saved_object_types'; export const createMockSavedObjectsRepository = ({ caseSavedObject = [], caseCommentSavedObject = [], + caseConfigureSavedObject = [], }: { caseSavedObject?: any[]; caseCommentSavedObject?: any[]; + caseConfigureSavedObject?: any[]; }) => { const mockSavedObjectsClientContract = ({ bulkGet: jest.fn((objects: SavedObjectsBulkGetObject[]) => { @@ -70,6 +76,7 @@ export const createMockSavedObjectsRepository = ({ } return result[0]; } + const result = caseSavedObject.filter(s => s.id === id); if (!result.length) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); @@ -81,6 +88,23 @@ export const createMockSavedObjectsRepository = ({ throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); } + if ( + findArgs.type === CASE_CONFIGURE_SAVED_OBJECT && + caseConfigureSavedObject[0] && + caseConfigureSavedObject[0].id === 'throw-error-find' + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError('Error thrown for testing'); + } + + if (findArgs.type === CASE_CONFIGURE_SAVED_OBJECT) { + return { + page: 1, + per_page: 5, + total: caseConfigureSavedObject.length, + saved_objects: caseConfigureSavedObject, + }; + } + if (findArgs.type === CASE_COMMENT_SAVED_OBJECT) { return { page: 1, @@ -101,6 +125,13 @@ export const createMockSavedObjectsRepository = ({ throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); } + if ( + type === CASE_CONFIGURE_SAVED_OBJECT && + attributes.connector_id === 'throw-error-create' + ) { + throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); + } + if (type === CASE_COMMENT_SAVED_OBJECT) { const newCommentObj = { type, @@ -113,6 +144,20 @@ export const createMockSavedObjectsRepository = ({ caseCommentSavedObject = [...caseCommentSavedObject, newCommentObj]; return newCommentObj; } + + if (type === CASE_CONFIGURE_SAVED_OBJECT) { + const newConfiguration = { + type, + id: 'mock-configuration', + attributes, + updated_at: '2020-04-09T09:43:51.778Z', + version: attributes.connector_id === 'no-version' ? undefined : 'WzksMV0=', + }; + + caseConfigureSavedObject = [newConfiguration]; + return newConfiguration; + } + return { type, id: 'mock-it', @@ -143,6 +188,16 @@ export const createMockSavedObjectsRepository = ({ } } + if (type === CASE_CONFIGURE_SAVED_OBJECT) { + return { + id, + type, + updated_at: '2019-11-22T22:50:55.191Z', + attributes, + version: attributes.connector_id === 'no-version' ? undefined : 'WzE3LDFd', + }; + } + return { id, type, @@ -153,16 +208,29 @@ export const createMockSavedObjectsRepository = ({ }), delete: jest.fn((type: string, id: string) => { let result = caseSavedObject.filter(s => s.id === id); + if (type === CASE_COMMENT_SAVED_OBJECT) { result = caseCommentSavedObject.filter(s => s.id === id); } + + if (type === CASE_CONFIGURE_SAVED_OBJECT) { + result = caseConfigureSavedObject.filter(s => s.id === id); + } + if (type === CASE_COMMENT_SAVED_OBJECT && id === 'bad-guy') { throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); } + if (!result.length) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } + if ( + type === CASE_CONFIGURE_SAVED_OBJECT && + caseConfigureSavedObject[0].id === 'throw-error-delete' + ) { + throw new Error('Error thrown for testing'); + } return {}; }), deleteByNamespace: jest.fn(), diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 03da50f886fd5..75e793a80272f 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -5,7 +5,11 @@ */ import { SavedObject } from 'kibana/server'; -import { CaseAttributes, CommentAttributes } from '../../../../common/api'; +import { + CaseAttributes, + CommentAttributes, + CasesConfigureAttributes, +} from '../../../../common/api'; export const mockCases: Array<SavedObject<CaseAttributes>> = [ { @@ -225,7 +229,33 @@ export const mockCaseComments: Array<SavedObject<CommentAttributes>> = [ }, ], updated_at: '2019-11-25T22:32:30.608Z', + version: 'WzYsMV0=', + }, +]; +export const mockCaseConfigure: Array<SavedObject<CasesConfigureAttributes>> = [ + { + type: 'cases-configure', + id: 'mock-configuration-1', + attributes: { + connector_id: '123', + connector_name: 'My connector', + closure_type: 'close-by-user', + created_at: '2020-04-09T09:43:51.778Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: '2020-04-09T09:43:51.778Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + references: [], + updated_at: '2020-04-09T09:43:51.778Z', version: 'WzYsMV0=', }, ]; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts index b1881e394e796..d947ffbaf181d 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -5,13 +5,19 @@ */ import { RequestHandlerContext } from 'src/core/server'; +import { actionsClientMock } from '../../../../../actions/server/mocks'; +import { getActions } from '../__mocks__/request_responses'; export const createRouteContext = (client: any) => { + const actionsMock = actionsClientMock.create(); + actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); + return ({ core: { savedObjects: { client, }, }, + actions: { getActionsClient: () => actionsMock }, } as unknown) as RequestHandlerContext; }; diff --git a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts new file mode 100644 index 0000000000000..846013674986e --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { CasePostRequest, CasesConfigureRequest } from '../../../../common/api'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { FindActionResult } from '../../../../../actions/server/types'; + +export const newCase: CasePostRequest = { + title: 'My new case', + description: 'A description', + tags: ['new', 'case'], +}; + +export const getActions = (): FindActionResult[] => [ + { + id: 'e90075a5-c386-41e3-ae21-ba4e61510695', + actionTypeId: '.webhook', + name: 'Test', + config: { + method: 'post', + url: 'https://example.com', + headers: null, + }, + isPreconfigured: false, + referencedByCount: 0, + }, + { + id: 'd611af27-3532-4da9-8034-271fee81d634', + actionTypeId: '.servicenow', + name: 'ServiceNow', + config: { + casesConfiguration: { + mapping: [ + { + source: 'title', + target: 'short_description', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, + ], + }, + apiUrl: 'https://dev102283.service-now.com', + }, + isPreconfigured: false, + referencedByCount: 0, + }, +]; + +export const newConfiguration: CasesConfigureRequest = { + connector_id: '456', + connector_name: 'My connector 2', + closure_type: 'close-by-pushing', +}; diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.test.ts new file mode 100644 index 0000000000000..66d39c3f11d28 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.test.ts @@ -0,0 +1,112 @@ +/* + * 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, +} from '../../__fixtures__'; + +import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects'; +import { initGetCaseConfigure } from './get_configure'; + +describe('GET configuration', () => { + let routeHandler: RequestHandler<any, any, any>; + beforeAll(async () => { + routeHandler = await createRoute(initGetCaseConfigure, 'get'); + }); + + it('returns the configuration', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'get', + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(200); + expect(res.payload).toEqual({ + ...mockCaseConfigure[0].attributes, + version: mockCaseConfigure[0].version, + }); + }); + + it('handles undefined version correctly', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'get', + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: [{ ...mockCaseConfigure[0], version: undefined }], + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(200); + expect(res.payload).toEqual({ + connector_id: '123', + connector_name: 'My connector', + closure_type: 'close-by-user', + created_at: '2020-04-09T09:43:51.778Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: '2020-04-09T09:43:51.778Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + version: '', + }); + }); + + it('returns an empty object when there is no configuration', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'get', + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: [], + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(200); + expect(res.payload).toEqual({}); + }); + + it('returns an error if find throws an error', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'get', + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: [{ ...mockCaseConfigure[0], id: 'throw-error-find' }], + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(404); + expect(res.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts new file mode 100644 index 0000000000000..62edaa0a4792a --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts @@ -0,0 +1,63 @@ +/* + * 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, +} from '../../__fixtures__'; + +import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects'; +import { initCaseConfigureGetActionConnector } from './get_connectors'; +import { getActions } from '../../__mocks__/request_responses'; + +describe('GET connectors', () => { + let routeHandler: RequestHandler<any, any, any>; + beforeAll(async () => { + routeHandler = await createRoute(initCaseConfigureGetActionConnector, 'get'); + }); + + it('returns the connectors', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure/connectors/_find', + method: 'get', + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(200); + expect(res.payload).toEqual( + getActions().filter(action => action.actionTypeId === '.servicenow') + ); + }); + + it('it throws an error when actions client is null', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure/connectors/_find', + method: 'get', + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + context.actions = undefined; + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(404); + expect(res.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.test.ts new file mode 100644 index 0000000000000..5b3d68a258664 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.test.ts @@ -0,0 +1,156 @@ +/* + * 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, +} from '../../__fixtures__'; + +import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects'; +import { initPatchCaseConfigure } from './patch_configure'; + +describe('PATCH configuration', () => { + let routeHandler: RequestHandler<any, any, any>; + + beforeAll(async () => { + routeHandler = await createRoute(initPatchCaseConfigure, 'patch'); + const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; + spyOnDate.mockImplementation(() => ({ + toISOString: jest.fn().mockReturnValue('2020-04-09T09:43:51.778Z'), + })); + }); + + it('patch configuration', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'patch', + body: { + closure_type: 'close-by-pushing', + version: mockCaseConfigure[0].version, + }, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + + expect(res.status).toEqual(200); + expect(res.payload).toEqual( + expect.objectContaining({ + ...mockCaseConfigure[0].attributes, + closure_type: 'close-by-pushing', + updated_at: '2020-04-09T09:43:51.778Z', + updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, + version: 'WzE3LDFd', + }) + ); + }); + + it('patch configuration without authentication', async () => { + routeHandler = await createRoute(initPatchCaseConfigure, 'patch', true); + + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'patch', + body: { + closure_type: 'close-by-pushing', + version: mockCaseConfigure[0].version, + }, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + + expect(res.status).toEqual(200); + expect(res.payload).toEqual( + expect.objectContaining({ + ...mockCaseConfigure[0].attributes, + closure_type: 'close-by-pushing', + updated_at: '2020-04-09T09:43:51.778Z', + updated_by: { email: null, full_name: null, username: null }, + version: 'WzE3LDFd', + }) + ); + }); + + it('throw error when configuration have not being created', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'patch', + body: { + closure_type: 'close-by-pushing', + version: mockCaseConfigure[0].version, + }, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: [], + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + + expect(res.status).toEqual(409); + expect(res.payload.isBoom).toEqual(true); + }); + + it('throw error when the versions are different', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'patch', + body: { + closure_type: 'close-by-pushing', + version: 'different-version', + }, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + + expect(res.status).toEqual(409); + expect(res.payload.isBoom).toEqual(true); + }); + + it('handles undefined version correctly', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'patch', + body: { connector_id: 'no-version', version: mockCaseConfigure[0].version }, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.payload).toEqual( + expect.objectContaining({ + version: '', + }) + ); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.test.ts new file mode 100644 index 0000000000000..7e40cad5b1298 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.test.ts @@ -0,0 +1,294 @@ +/* + * 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, +} from '../../__fixtures__'; + +import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects'; +import { initPostCaseConfigure } from './post_configure'; +import { newConfiguration } from '../../__mocks__/request_responses'; + +describe('POST configuration', () => { + let routeHandler: RequestHandler<any, any, any>; + + beforeAll(async () => { + routeHandler = await createRoute(initPostCaseConfigure, 'post'); + const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; + spyOnDate.mockImplementation(() => ({ + toISOString: jest.fn().mockReturnValue('2020-04-09T09:43:51.778Z'), + })); + }); + + it('create configuration', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'post', + body: newConfiguration, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + + expect(res.status).toEqual(200); + expect(res.payload).toEqual( + expect.objectContaining({ + connector_id: '456', + connector_name: 'My connector 2', + closure_type: 'close-by-pushing', + created_at: '2020-04-09T09:43:51.778Z', + created_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, + updated_at: null, + updated_by: null, + }) + ); + }); + + it('create configuration without authentication', async () => { + routeHandler = await createRoute(initPostCaseConfigure, 'post', true); + + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'post', + body: newConfiguration, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + + expect(res.status).toEqual(200); + expect(res.payload).toEqual( + expect.objectContaining({ + connector_id: '456', + connector_name: 'My connector 2', + closure_type: 'close-by-pushing', + created_at: '2020-04-09T09:43:51.778Z', + created_by: { email: null, full_name: null, username: null }, + updated_at: null, + updated_by: null, + }) + ); + }); + + it('throws when missing connector_id', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'post', + body: { + connector_name: 'My connector 2', + closure_type: 'close-by-pushing', + }, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(400); + expect(res.payload.isBoom).toEqual(true); + }); + + it('throws when missing connector_name', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'post', + body: { + connector_id: '456', + closure_type: 'close-by-pushing', + }, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(400); + expect(res.payload.isBoom).toEqual(true); + }); + + it('throws when missing closure_type', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'post', + body: { + connector_id: '456', + connector_name: 'My connector 2', + }, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(400); + expect(res.payload.isBoom).toEqual(true); + }); + + it('it deletes the previous configuration', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'post', + body: newConfiguration, + }); + + const savedObjectRepository = createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }); + + const context = createRouteContext(savedObjectRepository); + + const res = await routeHandler(context, req, kibanaResponseFactory); + + expect(res.status).toEqual(200); + expect(savedObjectRepository.delete.mock.calls[0][1]).toBe(mockCaseConfigure[0].id); + }); + + it('it does NOT delete when not found', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'post', + body: newConfiguration, + }); + + const savedObjectRepository = createMockSavedObjectsRepository({ + caseConfigureSavedObject: [], + }); + + const context = createRouteContext(savedObjectRepository); + + const res = await routeHandler(context, req, kibanaResponseFactory); + + expect(res.status).toEqual(200); + expect(savedObjectRepository.delete).not.toHaveBeenCalled(); + }); + + it('it deletes all configuration', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'post', + body: newConfiguration, + }); + + const savedObjectRepository = createMockSavedObjectsRepository({ + caseConfigureSavedObject: [ + mockCaseConfigure[0], + { ...mockCaseConfigure[0], id: 'mock-configuration-2' }, + ], + }); + + const context = createRouteContext(savedObjectRepository); + + const res = await routeHandler(context, req, kibanaResponseFactory); + + expect(res.status).toEqual(200); + expect(savedObjectRepository.delete.mock.calls[0][1]).toBe(mockCaseConfigure[0].id); + expect(savedObjectRepository.delete.mock.calls[1][1]).toBe('mock-configuration-2'); + }); + + it('returns an error if find throws an error', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'post', + body: newConfiguration, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: [{ ...mockCaseConfigure[0], id: 'throw-error-find' }], + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(404); + expect(res.payload.isBoom).toEqual(true); + }); + + it('returns an error if delete throws an error', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'post', + body: newConfiguration, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: [{ ...mockCaseConfigure[0], id: 'throw-error-delete' }], + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(500); + expect(res.payload.isBoom).toEqual(true); + }); + + it('returns an error if post throws an error', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'post', + body: { + connector_id: 'throw-error-create', + connector_name: 'My connector 2', + closure_type: 'close-by-pushing', + }, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(400); + expect(res.payload.isBoom).toEqual(true); + }); + + it('handles undefined version correctly', async () => { + const req = httpServerMock.createKibanaRequest({ + path: '/api/cases/configure', + method: 'post', + body: { ...newConfiguration, connector_id: 'no-version' }, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(200); + expect(res.payload).toEqual( + expect.objectContaining({ + version: '', + }) + ); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.ts index e947118a39e8e..ac32b20541a9c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -40,6 +40,10 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { const theComments = await caseService.getAllCaseComments({ client, caseId: request.params.case_id, + options: { + sortField: 'created_at', + sortOrder: 'asc', + }, }); return response.ok({ diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts new file mode 100644 index 0000000000000..a22f4db30bf8d --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -0,0 +1,418 @@ +/* + * 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 { + transformNewCase, + transformNewComment, + wrapError, + transformCases, + flattenCaseSavedObjects, + flattenCaseSavedObject, + flattenCommentSavedObjects, + transformComments, + flattenCommentSavedObject, + sortToSnake, +} from './utils'; +import { newCase } from './__mocks__/request_responses'; +import { isBoom, boomify } from 'boom'; +import { mockCases, mockCaseComments } from './__fixtures__/mock_saved_objects'; + +describe('Utils', () => { + describe('transformNewCase', () => { + it('transform correctly', () => { + const myCase = { + newCase, + createdDate: '2020-04-09T09:43:51.778Z', + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }; + + const res = transformNewCase(myCase); + + expect(res).toEqual({ + ...myCase.newCase, + closed_at: null, + closed_by: null, + created_at: '2020-04-09T09:43:51.778Z', + created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' }, + external_service: null, + status: 'open', + updated_at: null, + updated_by: null, + }); + }); + + it('transform correctly without optional fields', () => { + const myCase = { + newCase, + createdDate: '2020-04-09T09:43:51.778Z', + }; + + const res = transformNewCase(myCase); + + expect(res).toEqual({ + ...myCase.newCase, + closed_at: null, + closed_by: null, + created_at: '2020-04-09T09:43:51.778Z', + created_by: { email: undefined, full_name: undefined, username: undefined }, + external_service: null, + status: 'open', + updated_at: null, + updated_by: null, + }); + }); + + it('transform correctly with optional fields as null', () => { + const myCase = { + newCase, + createdDate: '2020-04-09T09:43:51.778Z', + email: null, + full_name: null, + username: null, + }; + + const res = transformNewCase(myCase); + + expect(res).toEqual({ + ...myCase.newCase, + closed_at: null, + closed_by: null, + created_at: '2020-04-09T09:43:51.778Z', + created_by: { email: null, full_name: null, username: null }, + external_service: null, + status: 'open', + updated_at: null, + updated_by: null, + }); + }); + }); + + describe('transformNewComment', () => { + it('transforms correctly', () => { + const comment = { + comment: 'A comment', + createdDate: '2020-04-09T09:43:51.778Z', + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }; + + const res = transformNewComment(comment); + expect(res).toEqual({ + comment: 'A comment', + created_at: '2020-04-09T09:43:51.778Z', + created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }); + }); + + it('transform correctly without optional fields', () => { + const comment = { + comment: 'A comment', + createdDate: '2020-04-09T09:43:51.778Z', + }; + + const res = transformNewComment(comment); + + expect(res).toEqual({ + comment: 'A comment', + created_at: '2020-04-09T09:43:51.778Z', + created_by: { email: undefined, full_name: undefined, username: undefined }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }); + }); + + it('transform correctly with optional fields as null', () => { + const comment = { + comment: 'A comment', + createdDate: '2020-04-09T09:43:51.778Z', + email: null, + full_name: null, + username: null, + }; + + const res = transformNewComment(comment); + + expect(res).toEqual({ + comment: 'A comment', + created_at: '2020-04-09T09:43:51.778Z', + created_by: { email: null, full_name: null, username: null }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }); + }); + }); + + describe('wrapError', () => { + it('wraps an error', () => { + const error = new Error('Something happened'); + const res = wrapError(error); + + expect(isBoom(res.body as Error)).toBe(true); + }); + + it('it set statusCode to 500', () => { + const error = new Error('Something happened'); + const res = wrapError(error); + + expect(res.statusCode).toBe(500); + }); + + it('it set statusCode to errors status code', () => { + const error = new Error('Something happened') as any; + error.statusCode = 404; + const res = wrapError(error); + + expect(res.statusCode).toBe(404); + }); + + it('it accepts a boom error', () => { + const error = boomify(new Error('Something happened')); + const res = wrapError(error); + + // Utils returns the same boom error as body + expect(res.body).toBe(error); + }); + + it('it accepts a boom error with status code', () => { + const error = boomify(new Error('Something happened'), { statusCode: 404 }); + const res = wrapError(error); + + expect(res.statusCode).toBe(404); + }); + + it('it returns empty headers', () => { + const error = new Error('Something happened'); + const res = wrapError(error); + + expect(res.headers).toEqual({}); + }); + }); + + describe('transformCases', () => { + it('transforms correctly', () => { + const totalCommentsByCase = [ + { caseId: mockCases[0].id, totalComments: 2 }, + { caseId: mockCases[1].id, totalComments: 2 }, + { caseId: mockCases[2].id, totalComments: 2 }, + { caseId: mockCases[3].id, totalComments: 2 }, + ]; + + const res = transformCases( + { saved_objects: mockCases, total: mockCases.length, per_page: 10, page: 1 }, + 2, + 2, + totalCommentsByCase + ); + expect(res).toEqual({ + page: 1, + per_page: 10, + total: mockCases.length, + cases: flattenCaseSavedObjects(mockCases, totalCommentsByCase), + count_open_cases: 2, + count_closed_cases: 2, + }); + }); + }); + + describe('flattenCaseSavedObjects', () => { + it('flattens correctly', () => { + const totalCommentsByCase = [{ caseId: mockCases[0].id, totalComments: 2 }]; + + const res = flattenCaseSavedObjects([mockCases[0]], totalCommentsByCase); + expect(res).toEqual([ + { + id: 'mock-id-1', + closed_at: null, + closed_by: null, + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comments: [], + totalComment: 2, + version: 'WzAsMV0=', + }, + ]); + }); + + it('it handles total comments correctly', () => { + const totalCommentsByCase = [{ caseId: 'not-exist', totalComments: 2 }]; + + const res = flattenCaseSavedObjects([mockCases[0]], totalCommentsByCase); + + expect(res).toEqual([ + { + id: 'mock-id-1', + closed_at: null, + closed_by: null, + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comments: [], + totalComment: 0, + version: 'WzAsMV0=', + }, + ]); + }); + }); + + describe('flattenCaseSavedObject', () => { + it('flattens correctly', () => { + const myCase = { ...mockCases[0] }; + const res = flattenCaseSavedObject(myCase, [], 2); + expect(res).toEqual({ + id: myCase.id, + version: myCase.version, + comments: [], + totalComment: 2, + ...myCase.attributes, + }); + }); + + it('flattens correctly without version', () => { + const myCase = { ...mockCases[0] }; + myCase.version = undefined; + const res = flattenCaseSavedObject(myCase, [], 2); + expect(res).toEqual({ + id: myCase.id, + version: '0', + comments: [], + totalComment: 2, + ...myCase.attributes, + }); + }); + + it('flattens correctly with comments', () => { + const myCase = { ...mockCases[0] }; + const comments = [{ ...mockCaseComments[0] }]; + const res = flattenCaseSavedObject(myCase, comments, 2); + expect(res).toEqual({ + id: myCase.id, + version: myCase.version, + comments: flattenCommentSavedObjects(comments), + totalComment: 2, + ...myCase.attributes, + }); + }); + }); + + describe('transformComments', () => { + it('transforms correctly', () => { + const comments = { + saved_objects: mockCaseComments, + total: mockCaseComments.length, + per_page: 10, + page: 1, + }; + + const res = transformComments(comments); + expect(res).toEqual({ + page: 1, + per_page: 10, + total: mockCaseComments.length, + comments: flattenCommentSavedObjects(comments.saved_objects), + }); + }); + }); + + describe('flattenCommentSavedObjects', () => { + it('flattens correctly', () => { + const comments = [{ ...mockCaseComments[0] }, { ...mockCaseComments[1] }]; + const res = flattenCommentSavedObjects(comments); + expect(res).toEqual([ + flattenCommentSavedObject(comments[0]), + flattenCommentSavedObject(comments[1]), + ]); + }); + }); + + describe('flattenCommentSavedObject', () => { + it('flattens correctly', () => { + const comment = { ...mockCaseComments[0] }; + const res = flattenCommentSavedObject(comment); + expect(res).toEqual({ + id: comment.id, + version: comment.version, + ...comment.attributes, + }); + }); + + it('flattens correctly without version', () => { + const comment = { ...mockCaseComments[0] }; + comment.version = undefined; + const res = flattenCommentSavedObject(comment); + expect(res).toEqual({ + id: comment.id, + version: '0', + ...comment.attributes, + }); + }); + }); + + describe('sortToSnake', () => { + it('it transforms status correctly', () => { + expect(sortToSnake('status')).toBe('status'); + }); + + it('it transforms createdAt correctly', () => { + expect(sortToSnake('createdAt')).toBe('created_at'); + }); + + it('it transforms created_at correctly', () => { + expect(sortToSnake('created_at')).toBe('created_at'); + }); + + it('it transforms closedAt correctly', () => { + expect(sortToSnake('closedAt')).toBe('closed_at'); + }); + + it('it transforms closed_at correctly', () => { + expect(sortToSnake('closed_at')).toBe('closed_at'); + }); + + it('it transforms default correctly', () => { + expect(sortToSnake('not-exist')).toBe('created_at'); + }); + }); +}); diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index a4c5dab0feeb7..cc2b1e74b38c4 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -11,7 +11,7 @@ export const CASE_SAVED_OBJECT = 'cases'; export const caseSavedObjectType: SavedObjectsType = { name: CASE_SAVED_OBJECT, hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', mappings: { properties: { closed_at: { diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts index 73b1852bafe58..8b69f272d5b0d 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -11,7 +11,7 @@ export const CASE_COMMENT_SAVED_OBJECT = 'cases-comments'; export const caseCommentSavedObjectType: SavedObjectsType = { name: CASE_COMMENT_SAVED_OBJECT, hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', mappings: { properties: { comment: { diff --git a/x-pack/plugins/case/server/saved_object_types/configure.ts b/x-pack/plugins/case/server/saved_object_types/configure.ts index d66c38b6ea8ff..d6bc3c9f2e227 100644 --- a/x-pack/plugins/case/server/saved_object_types/configure.ts +++ b/x-pack/plugins/case/server/saved_object_types/configure.ts @@ -11,7 +11,7 @@ export const CASE_CONFIGURE_SAVED_OBJECT = 'cases-configure'; export const caseConfigureSavedObjectType: SavedObjectsType = { name: CASE_CONFIGURE_SAVED_OBJECT, hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', mappings: { properties: { created_at: { diff --git a/x-pack/plugins/case/server/saved_object_types/user_actions.ts b/x-pack/plugins/case/server/saved_object_types/user_actions.ts index b61bfafc3b33c..826c6907efea6 100644 --- a/x-pack/plugins/case/server/saved_object_types/user_actions.ts +++ b/x-pack/plugins/case/server/saved_object_types/user_actions.ts @@ -11,7 +11,7 @@ export const CASE_USER_ACTION_SAVED_OBJECT = 'cases-user-actions'; export const caseUserActionSavedObjectType: SavedObjectsType = { name: CASE_USER_ACTION_SAVED_OBJECT, hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', mappings: { properties: { action_field: { diff --git a/x-pack/plugins/case/server/scripts/generate_case_and_comment_data.sh b/x-pack/plugins/case/server/scripts/generate_case_and_comment_data.sh index 9b6f472d798e0..7ec7dc5a70e92 100755 --- a/x-pack/plugins/case/server/scripts/generate_case_and_comment_data.sh +++ b/x-pack/plugins/case/server/scripts/generate_case_and_comment_data.sh @@ -22,9 +22,9 @@ POSTED_COMMENT="$(curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X POST "${KIBANA_URL}${SPACE_URL}/api/cases/$CASE_ID/comments" \ -d @${COMMENT} \ - | jq '{ commentId: .id, commentVersion: .version }' -)" + | jq '{ commentId: .comments[0].id, commentVersion: .comments[0].version }' \ +-j)" POSTED_CASE=$(./get_case.sh $CASE_ID | jq '{ caseId: .id, caseVersion: .version }' -j) echo ${POSTED_COMMENT} ${POSTED_CASE} \ - | jq -s add; \ No newline at end of file +| jq -s add; diff --git a/x-pack/plugins/case/server/scripts/generate_case_data.sh b/x-pack/plugins/case/server/scripts/generate_case_data.sh index f8f6142a5d733..d3a4d3833ad2e 100755 --- a/x-pack/plugins/case/server/scripts/generate_case_data.sh +++ b/x-pack/plugins/case/server/scripts/generate_case_data.sh @@ -11,6 +11,6 @@ # ./generate_case_data.sh set -e -./check_env_variables.sh -./post_case.sh | jq '{ id: .id, version: .version }' -j; + ./check_env_variables.sh + ./post_case.sh | jq '{ id: .id, version: .version }'; diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index f691e62b9352a..2caf3a7055df9 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -10,14 +10,16 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { EncryptedSavedObjectsService } from '../crypto'; import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; -import { savedObjectsClientMock } from 'src/core/server/mocks'; +import { savedObjectsClientMock, savedObjectsTypeRegistryMock } from 'src/core/server/mocks'; import { encryptedSavedObjectsServiceMock } from '../crypto/index.mock'; let wrapper: EncryptedSavedObjectsClientWrapper; let mockBaseClient: jest.Mocked<SavedObjectsClientContract>; +let mockBaseTypeRegistry: ReturnType<typeof savedObjectsTypeRegistryMock.create>; let encryptedSavedObjectsServiceMockInstance: jest.Mocked<EncryptedSavedObjectsService>; beforeEach(() => { mockBaseClient = savedObjectsClientMock.create(); + mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create(); encryptedSavedObjectsServiceMockInstance = encryptedSavedObjectsServiceMock.create([ { type: 'known-type', @@ -28,6 +30,7 @@ beforeEach(() => { wrapper = new EncryptedSavedObjectsClientWrapper({ service: encryptedSavedObjectsServiceMockInstance, baseClient: mockBaseClient, + baseTypeRegistry: mockBaseTypeRegistry, } as any); }); @@ -91,35 +94,50 @@ describe('#create', () => { ); }); - it('uses `namespace` to encrypt attributes if it is specified', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const options = { overwrite: true, namespace: 'some-namespace' }; - const mockedResponse = { - id: 'uuid-v4-id', - type: 'known-type', - attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - references: [], - }; + describe('namespace', () => { + const doTest = async (namespace: string, expectNamespaceInDescriptor: boolean) => { + const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; + const options = { overwrite: true, namespace }; + const mockedResponse = { + id: 'uuid-v4-id', + type: 'known-type', + attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, + references: [], + }; - mockBaseClient.create.mockResolvedValue(mockedResponse); + mockBaseClient.create.mockResolvedValue(mockedResponse); - expect(await wrapper.create('known-type', attributes, options)).toEqual({ - ...mockedResponse, - attributes: { attrOne: 'one', attrThree: 'three' }, - }); + expect(await wrapper.create('known-type', attributes, options)).toEqual({ + ...mockedResponse, + attributes: { attrOne: 'one', attrThree: 'three' }, + }); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'uuid-v4-id', namespace: 'some-namespace' }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } - ); + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( + { + type: 'known-type', + id: 'uuid-v4-id', + namespace: expectNamespaceInDescriptor ? namespace : undefined, + }, + { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } + ); - expect(mockBaseClient.create).toHaveBeenCalledTimes(1); - expect(mockBaseClient.create).toHaveBeenCalledWith( - 'known-type', - { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - { id: 'uuid-v4-id', overwrite: true, namespace: 'some-namespace' } - ); + expect(mockBaseClient.create).toHaveBeenCalledTimes(1); + expect(mockBaseClient.create).toHaveBeenCalledWith( + 'known-type', + { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, + { id: 'uuid-v4-id', overwrite: true, namespace } + ); + }; + + it('uses `namespace` to encrypt attributes if it is specified when type is single-namespace', async () => { + await doTest('some-namespace', true); + }); + + it('does not use `namespace` to encrypt attributes if it is specified when type is not single-namespace', async () => { + mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false); + await doTest('some-namespace', false); + }); }); it('fails if base client fails', async () => { @@ -190,14 +208,13 @@ describe('#bulkCreate', () => { it('fails if ID is specified for registered type', async () => { const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const options = { namespace: 'some-namespace' }; const bulkCreateParams = [ { id: 'some-id', type: 'known-type', attributes }, { type: 'unknown-type', attributes }, ]; - await expect(wrapper.bulkCreate(bulkCreateParams, options)).rejects.toThrowError( + await expect(wrapper.bulkCreate(bulkCreateParams)).rejects.toThrowError( 'Predefined IDs are not allowed for saved objects with encrypted attributes.' ); @@ -257,39 +274,57 @@ describe('#bulkCreate', () => { ); }); - it('uses `namespace` to encrypt attributes if it is specified', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const options = { namespace: 'some-namespace' }; - const mockedResponse = { - saved_objects: [{ id: 'uuid-v4-id', type: 'known-type', attributes, references: [] }], - }; + describe('namespace', () => { + const doTest = async (namespace: string, expectNamespaceInDescriptor: boolean) => { + const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; + const options = { namespace }; + const mockedResponse = { + saved_objects: [{ id: 'uuid-v4-id', type: 'known-type', attributes, references: [] }], + }; + + mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse); + + const bulkCreateParams = [{ type: 'known-type', attributes }]; + await expect(wrapper.bulkCreate(bulkCreateParams, options)).resolves.toEqual({ + saved_objects: [ + { + ...mockedResponse.saved_objects[0], + attributes: { attrOne: 'one', attrThree: 'three' }, + }, + ], + }); - mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse); + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( + { + type: 'known-type', + id: 'uuid-v4-id', + namespace: expectNamespaceInDescriptor ? namespace : undefined, + }, + { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } + ); + + expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith( + [ + { + ...bulkCreateParams[0], + id: 'uuid-v4-id', + attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, + }, + ], + options + ); + }; - const bulkCreateParams = [{ type: 'known-type', attributes }]; - await expect(wrapper.bulkCreate(bulkCreateParams, options)).resolves.toEqual({ - saved_objects: [ - { ...mockedResponse.saved_objects[0], attributes: { attrOne: 'one', attrThree: 'three' } }, - ], + it('uses `namespace` to encrypt attributes if it is specified when type is single-namespace', async () => { + await doTest('some-namespace', true); }); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'uuid-v4-id', namespace: 'some-namespace' }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } - ); - - expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith( - [ - { - ...bulkCreateParams[0], - id: 'uuid-v4-id', - attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - }, - ], - options - ); + it('does not use `namespace` to encrypt attributes if it is specified when type is not single-namespace', async () => { + mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false); + await doTest('some-namespace', false); + }); }); it('fails if base client fails', async () => { @@ -432,63 +467,79 @@ describe('#bulkUpdate', () => { ); }); - it('uses `namespace` to encrypt attributes if it is specified', async () => { - const docs = [ - { - id: 'some-id', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: 'secret', - attrThree: 'three', - }, - version: 'some-version', - }, - ]; - - mockBaseClient.bulkUpdate.mockResolvedValue({ - saved_objects: docs.map(doc => ({ ...doc, references: undefined })), - }); - - await expect(wrapper.bulkUpdate(docs, { namespace: 'some-namespace' })).resolves.toEqual({ - saved_objects: [ + describe('namespace', () => { + const doTest = async (namespace: string, expectNamespaceInDescriptor: boolean) => { + const docs = [ { id: 'some-id', type: 'known-type', attributes: { attrOne: 'one', + attrSecret: 'secret', attrThree: 'three', }, version: 'some-version', - references: undefined, }, - ], - }); - - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id', namespace: 'some-namespace' }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } - ); + ]; + const options = { namespace }; + + mockBaseClient.bulkUpdate.mockResolvedValue({ + saved_objects: docs.map(doc => ({ ...doc, references: undefined })), + }); + + await expect(wrapper.bulkUpdate(docs, options)).resolves.toEqual({ + saved_objects: [ + { + id: 'some-id', + type: 'known-type', + attributes: { + attrOne: 'one', + attrThree: 'three', + }, + version: 'some-version', + references: undefined, + }, + ], + }); - expect(mockBaseClient.bulkUpdate).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkUpdate).toHaveBeenCalledWith( - [ + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( { - id: 'some-id', type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrThree: 'three', + id: 'some-id', + namespace: expectNamespaceInDescriptor ? namespace : undefined, + }, + { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } + ); + + expect(mockBaseClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(mockBaseClient.bulkUpdate).toHaveBeenCalledWith( + [ + { + id: 'some-id', + type: 'known-type', + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrThree: 'three', + }, + version: 'some-version', + + references: undefined, }, - version: 'some-version', + ], + options + ); + }; - references: undefined, - }, - ], - { namespace: 'some-namespace' } - ); + it('uses `namespace` to encrypt attributes if it is specified when type is single-namespace', async () => { + await doTest('some-namespace', true); + }); + + it('does not use `namespace` to encrypt attributes if it is specified when type is not single-namespace', async () => { + mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false); + await doTest('some-namespace', false); + }); }); it('fails if base client fails', async () => { @@ -871,31 +922,46 @@ describe('#update', () => { ); }); - it('uses `namespace` to encrypt attributes if it is specified', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const options = { version: 'some-version', namespace: 'some-namespace' }; - const mockedResponse = { id: 'some-id', type: 'known-type', attributes, references: [] }; + describe('namespace', () => { + const doTest = async (namespace: string, expectNamespaceInDescriptor: boolean) => { + const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; + const options = { version: 'some-version', namespace }; + const mockedResponse = { id: 'some-id', type: 'known-type', attributes, references: [] }; - mockBaseClient.update.mockResolvedValue(mockedResponse); + mockBaseClient.update.mockResolvedValue(mockedResponse); - await expect(wrapper.update('known-type', 'some-id', attributes, options)).resolves.toEqual({ - ...mockedResponse, - attributes: { attrOne: 'one', attrThree: 'three' }, - }); + await expect(wrapper.update('known-type', 'some-id', attributes, options)).resolves.toEqual({ + ...mockedResponse, + attributes: { attrOne: 'one', attrThree: 'three' }, + }); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id', namespace: 'some-namespace' }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } - ); + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( + { + type: 'known-type', + id: 'some-id', + namespace: expectNamespaceInDescriptor ? namespace : undefined, + }, + { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } + ); + + expect(mockBaseClient.update).toHaveBeenCalledTimes(1); + expect(mockBaseClient.update).toHaveBeenCalledWith( + 'known-type', + 'some-id', + { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, + options + ); + }; - expect(mockBaseClient.update).toHaveBeenCalledTimes(1); - expect(mockBaseClient.update).toHaveBeenCalledWith( - 'known-type', - 'some-id', - { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - options - ); + it('uses `namespace` to encrypt attributes if it is specified when type is single-namespace', async () => { + await doTest('some-namespace', true); + }); + + it('does not use `namespace` to encrypt attributes if it is specified when type is not single-namespace', async () => { + mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false); + await doTest('some-namespace', false); + }); }); it('fails if base client fails', async () => { diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index b4f1de52c9ce3..e8197536d29d9 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -19,11 +19,15 @@ import { SavedObjectsFindResponse, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, + SavedObjectsAddToNamespacesOptions, + SavedObjectsDeleteFromNamespacesOptions, + ISavedObjectTypeRegistry, } from 'src/core/server'; import { EncryptedSavedObjectsService } from '../crypto'; interface EncryptedSavedObjectsClientOptions { baseClient: SavedObjectsClientContract; + baseTypeRegistry: ISavedObjectTypeRegistry; service: Readonly<EncryptedSavedObjectsService>; } @@ -41,6 +45,10 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon public readonly errors = options.baseClient.errors ) {} + // only include namespace in AAD descriptor if the specified type is single-namespace + private getDescriptorNamespace = (type: string, namespace?: string) => + this.options.baseTypeRegistry.isSingleNamespace(type) ? namespace : undefined; + public async create<T = unknown>( type: string, attributes: T = {} as T, @@ -60,11 +68,12 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon } const id = generateID(); + const namespace = this.getDescriptorNamespace(type, options.namespace); return this.stripEncryptedAttributesFromResponse( await this.options.baseClient.create( type, await this.options.service.encryptAttributes( - { type, id, namespace: options.namespace }, + { type, id, namespace }, attributes as Record<string, unknown> ), { ...options, id } @@ -95,11 +104,12 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon } const id = generateID(); + const namespace = this.getDescriptorNamespace(object.type, options?.namespace); return { ...object, id, attributes: await this.options.service.encryptAttributes( - { type: object.type, id, namespace: options && options.namespace }, + { type: object.type, id, namespace }, object.attributes as Record<string, unknown> ), } as SavedObjectsBulkCreateObject<T>; @@ -124,10 +134,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon if (!this.options.service.isRegistered(type)) { return object; } + const namespace = this.getDescriptorNamespace(type, options?.namespace); return { ...object, attributes: await this.options.service.encryptAttributes( - { type, id, namespace: options && options.namespace }, + { type, id, namespace }, attributes ), }; @@ -173,20 +184,35 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon if (!this.options.service.isRegistered(type)) { return await this.options.baseClient.update(type, id, attributes, options); } - + const namespace = this.getDescriptorNamespace(type, options?.namespace); return this.stripEncryptedAttributesFromResponse( await this.options.baseClient.update( type, id, - await this.options.service.encryptAttributes( - { type, id, namespace: options && options.namespace }, - attributes - ), + await this.options.service.encryptAttributes({ type, id, namespace }, attributes), options ) ); } + public async addToNamespaces( + type: string, + id: string, + namespaces: string[], + options?: SavedObjectsAddToNamespacesOptions + ) { + return await this.options.baseClient.addToNamespaces(type, id, namespaces, options); + } + + public async deleteFromNamespaces( + type: string, + id: string, + namespaces: string[], + options?: SavedObjectsDeleteFromNamespacesOptions + ) { + return await this.options.baseClient.deleteFromNamespaces(type, id, namespaces, options); + } + /** * Strips encrypted attributes from any non-bulk Saved Objects API response. If type isn't * registered, response is returned as is. diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts index c76477cd8da43..10599ae3a1798 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts @@ -40,7 +40,8 @@ export function setupSavedObjects({ savedObjects.addClientWrapper( Number.MAX_SAFE_INTEGER, 'encryptedSavedObjects', - ({ client: baseClient }) => new EncryptedSavedObjectsClientWrapper({ baseClient, service }) + ({ client: baseClient, typeRegistry: baseTypeRegistry }) => + new EncryptedSavedObjectsClientWrapper({ baseClient, baseTypeRegistry, service }) ); const internalRepositoryPromise = getStartServices().then(([core]) => diff --git a/x-pack/plugins/endpoint/common/generate_data.ts b/x-pack/plugins/endpoint/common/generate_data.ts index 7c24bd9d77148..1978d780f54f5 100644 --- a/x-pack/plugins/endpoint/common/generate_data.ts +++ b/x-pack/plugins/endpoint/common/generate_data.ts @@ -6,12 +6,8 @@ import uuid from 'uuid'; import seedrandom from 'seedrandom'; -import { AlertEvent, EndpointEvent, HostMetadata, OSFields, HostFields } from './types'; -// FIXME: move types/model to top-level -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { PolicyData } from '../public/applications/endpoint/types'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { generatePolicy } from '../public/applications/endpoint/models/policy'; +import { AlertEvent, EndpointEvent, HostMetadata, OSFields, HostFields, PolicyData } from './types'; +import { factory as policyFactory } from './models/policy_config'; export type Event = AlertEvent | EndpointEvent; @@ -475,7 +471,7 @@ export class EndpointDocGenerator { streams: [], config: { policy: { - value: generatePolicy(), + value: policyFactory(), }, }, }, diff --git a/x-pack/plugins/endpoint/common/models/policy_config.ts b/x-pack/plugins/endpoint/common/models/policy_config.ts new file mode 100644 index 0000000000000..199b8a91e4307 --- /dev/null +++ b/x-pack/plugins/endpoint/common/models/policy_config.ts @@ -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 { PolicyConfig, ProtectionModes } from '../types'; + +/** + * Return a new default `PolicyConfig`. + */ +export const factory = (): PolicyConfig => { + return { + windows: { + events: { + dll_and_driver_load: true, + dns: true, + file: true, + network: true, + process: true, + registry: true, + security: true, + }, + malware: { + mode: ProtectionModes.prevent, + }, + logging: { + stdout: 'debug', + file: 'info', + }, + advanced: { + elasticsearch: { + indices: { + control: 'control-index', + event: 'event-index', + logging: 'logging-index', + }, + kernel: { + connect: true, + process: true, + }, + }, + }, + }, + mac: { + events: { + process: true, + file: true, + network: true, + }, + malware: { + mode: ProtectionModes.detect, + }, + logging: { + stdout: 'debug', + file: 'info', + }, + advanced: { + elasticsearch: { + indices: { + control: 'control-index', + event: 'event-index', + logging: 'logging-index', + }, + kernel: { + connect: true, + process: true, + }, + }, + }, + }, + linux: { + events: { + process: true, + file: true, + network: true, + }, + logging: { + stdout: 'debug', + file: 'info', + }, + advanced: { + elasticsearch: { + indices: { + control: 'control-index', + event: 'event-index', + logging: 'logging-index', + }, + kernel: { + connect: true, + process: true, + }, + }, + }, + }, + }; +}; diff --git a/x-pack/plugins/endpoint/common/schema/index_pattern.ts b/x-pack/plugins/endpoint/common/schema/index_pattern.ts new file mode 100644 index 0000000000000..2809004f88c6e --- /dev/null +++ b/x-pack/plugins/endpoint/common/schema/index_pattern.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 { schema } from '@kbn/config-schema'; + +export const indexPatternGetParamsSchema = schema.object({ datasetPath: schema.string() }); diff --git a/x-pack/plugins/endpoint/common/types.ts b/x-pack/plugins/endpoint/common/types.ts index a614526d92a3f..49f8ebbd580d8 100644 --- a/x-pack/plugins/endpoint/common/types.ts +++ b/x-pack/plugins/endpoint/common/types.ts @@ -7,12 +7,16 @@ import { SearchResponse } from 'elasticsearch'; import { TypeOf } from '@kbn/config-schema'; import { alertingIndexGetQuerySchema } from './schema/alert_index'; +import { indexPatternGetParamsSchema } from './schema/index_pattern'; +import { Datasource, NewDatasource } from '../../ingest_manager/common'; /** * A deep readonly type that will make all children of a given object readonly recursively */ export type Immutable<T> = T extends undefined | null | boolean | string | number ? T + : unknown extends T + ? unknown : T extends Array<infer U> ? ImmutableArray<U> : T extends Map<infer K, infer V> @@ -30,9 +34,9 @@ export type Direction = 'asc' | 'desc'; export class EndpointAppConstants { static BASE_API_URL = '/api/endpoint'; - static ENDPOINT_INDEX_NAME = 'endpoint-agent*'; + static INDEX_PATTERN_ROUTE = `${EndpointAppConstants.BASE_API_URL}/index_pattern`; static ALERT_INDEX_NAME = 'events-endpoint-1'; - static EVENT_INDEX_NAME = 'events-endpoint-*'; + static EVENT_DATASET = 'events'; static DEFAULT_TOTAL_HITS = 10000; /** * Legacy events are stored in indices with endgame-* prefix @@ -375,11 +379,6 @@ export interface EndpointEvent { export type ResolverEvent = EndpointEvent | LegacyEndpointEvent; -/** - * The PageId type is used for the payload when firing userNavigatedToPage actions - */ -export type PageId = 'alertsPage' | 'managementPage' | 'policyListPage'; - /** * Takes a @kbn/config-schema 'schema' type and returns a type that represents valid inputs. * Similar to `TypeOf`, but allows strings as input for `schema.number()` (which is inline @@ -447,3 +446,130 @@ export type AlertingIndexGetQueryInput = KbnConfigSchemaInputTypeOf< * Result of the validated query params when handling alert index requests. */ export type AlertingIndexGetQueryResult = TypeOf<typeof alertingIndexGetQuerySchema>; + +/** + * Result of the validated params when handling an index pattern request. + */ +export type IndexPatternGetParamsResult = TypeOf<typeof indexPatternGetParamsSchema>; + +/** + * Endpoint Policy configuration + */ +export interface PolicyConfig { + windows: { + events: { + dll_and_driver_load: boolean; + dns: boolean; + file: boolean; + network: boolean; + process: boolean; + registry: boolean; + security: boolean; + }; + malware: MalwareFields; + logging: { + stdout: string; + file: string; + }; + advanced: PolicyConfigAdvancedOptions; + }; + mac: { + events: { + file: boolean; + process: boolean; + network: boolean; + }; + malware: MalwareFields; + logging: { + stdout: string; + file: string; + }; + advanced: PolicyConfigAdvancedOptions; + }; + linux: { + events: { + file: boolean; + process: boolean; + network: boolean; + }; + logging: { + stdout: string; + file: string; + }; + advanced: PolicyConfigAdvancedOptions; + }; +} + +/** + * Windows-specific policy configuration that is supported via the UI + */ +type WindowsPolicyConfig = Pick<PolicyConfig['windows'], 'events' | 'malware'>; + +/** + * Mac-specific policy configuration that is supported via the UI + */ +type MacPolicyConfig = Pick<PolicyConfig['mac'], 'malware' | 'events'>; + +/** + * Linux-specific policy configuration that is supported via the UI + */ +type LinuxPolicyConfig = Pick<PolicyConfig['linux'], 'events'>; + +/** + * The set of Policy configuration settings that are show/edited via the UI + */ +export interface UIPolicyConfig { + windows: WindowsPolicyConfig; + mac: MacPolicyConfig; + linux: LinuxPolicyConfig; +} + +interface PolicyConfigAdvancedOptions { + elasticsearch: { + indices: { + control: string; + event: string; + logging: string; + }; + kernel: { + connect: boolean; + process: boolean; + }; + }; +} + +/** Policy: Malware protection fields */ +export interface MalwareFields { + mode: ProtectionModes; +} + +/** Policy protection mode options */ +export enum ProtectionModes { + detect = 'detect', + prevent = 'prevent', + preventNotify = 'preventNotify', + off = 'off', +} + +/** + * Endpoint Policy data, which extends Ingest's `Datasource` type + */ +export type PolicyData = Datasource & NewPolicyData; + +/** + * New policy data. Used when updating the policy record via ingest APIs + */ +export type NewPolicyData = NewDatasource & { + inputs: [ + { + type: 'endpoint'; + enabled: boolean; + streams: []; + config: { + policy: { + value: PolicyConfig; + }; + }; + } + ]; +}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/README.md b/x-pack/plugins/endpoint/public/applications/endpoint/README.md new file mode 100644 index 0000000000000..25bfd615d1d2c --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/README.md @@ -0,0 +1,28 @@ +# Endpoint application +This application provides the user interface for the Elastic Endpoint + +# Architecture +The application consists of a _view_ written in React and a _model_ written in Redux. + +# Modules +We structure the modules to match the architecture. `view` contains the _view_ (all React) code. `store` contains the _model_. + +This section covers the conventions of each top level module. + +# `mocks` +This contains helper code for unit tests. + +## `models` +This contains domain models. By convention, each submodule here contains methods for a single type. Domain model classes would also live here. + +## `store` +This contains the _model_ of the application. All Redux and Redux middleware code (including API interactions) happen here. This module also contains the types and interfaces defining Redux actions. Each action type or interface should be commented and if it has fields, each field should be commented. Comments should be of `tsdoc` style. + +## `view` +This contains the code which renders elements to the DOM. All React code goes here. + +## `index.tsx` +This exports `renderApp` which instantiates the React view with the _model_. + +## `types.ts` +This contains the types and interfaces. All `export`ed types or interfaces (except ones defining Redux actions, which live in `store`) should be here. Each type or interface should have a `tsdoc` style comment. Interfaces should have `tsdoc` comments on each field and types which have fields should do the same. diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/components/header_nav.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/components/header_nav.tsx deleted file mode 100644 index 3fb06d6b4a56e..0000000000000 --- a/x-pack/plugins/endpoint/public/applications/endpoint/components/header_nav.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { MouseEvent, useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiTabs, EuiTab } from '@elastic/eui'; -import { useHistory, useLocation } from 'react-router-dom'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; - -export interface NavTabs { - name: string; - id: string; - href: string; -} - -export const navTabs: NavTabs[] = [ - { - id: 'home', - name: i18n.translate('xpack.endpoint.headerNav.home', { - defaultMessage: 'Home', - }), - href: '/', - }, - { - id: 'hosts', - name: i18n.translate('xpack.endpoint.headerNav.hosts', { - defaultMessage: 'Hosts', - }), - href: '/hosts', - }, - { - id: 'alerts', - name: i18n.translate('xpack.endpoint.headerNav.alerts', { - defaultMessage: 'Alerts', - }), - href: '/alerts', - }, - { - id: 'policies', - name: i18n.translate('xpack.endpoint.headerNav.policies', { - defaultMessage: 'Policies', - }), - href: '/policy', - }, -]; - -export const HeaderNavigation: React.FunctionComponent = React.memo(() => { - const history = useHistory(); - const location = useLocation(); - const { services } = useKibana(); - const BASE_PATH = services.application.getUrlForApp('endpoint'); - - const tabList = useMemo(() => { - return navTabs.map((tab, index) => { - return ( - <EuiTab - data-test-subj={`${tab.id}EndpointTab`} - key={index} - href={`${BASE_PATH}${tab.href}`} - onClick={(event: MouseEvent) => { - event.preventDefault(); - history.push(tab.href); - }} - isSelected={ - tab.href === location.pathname || - (tab.href !== '/' && location.pathname.startsWith(tab.href)) - } - > - {tab.name} - </EuiTab> - ); - }); - }, [BASE_PATH, history, location.pathname]); - - return <EuiTabs>{tabList}</EuiTabs>; -}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/components/truncate_text.ts b/x-pack/plugins/endpoint/public/applications/endpoint/components/truncate_text.ts deleted file mode 100644 index 83f4bc1e79317..0000000000000 --- a/x-pack/plugins/endpoint/public/applications/endpoint/components/truncate_text.ts +++ /dev/null @@ -1,13 +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 styled from 'styled-components'; - -export const TruncateText = styled.div` - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -`; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx index 82ac95160519c..a1999c056bf59 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx @@ -6,19 +6,10 @@ import * as React from 'react'; import ReactDOM from 'react-dom'; -import { CoreStart, AppMountParameters, ScopedHistory } from 'kibana/public'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { Route, Switch } from 'react-router-dom'; -import { Store } from 'redux'; +import { CoreStart, AppMountParameters } from 'kibana/public'; import { EndpointPluginStartDependencies } from '../../plugin'; import { appStoreFactory } from './store'; -import { AlertIndex } from './view/alerts'; -import { HostList } from './view/hosts'; -import { PolicyList } from './view/policy'; -import { PolicyDetails } from './view/policy'; -import { HeaderNavigation } from './components/header_nav'; -import { AppRootProvider } from './view/app_root_provider'; -import { Setup } from './view/setup'; +import { AppRoot } from './view/app_root'; /** * This module will be loaded asynchronously to reduce the bundle size of your plugin's main bundle. @@ -37,41 +28,3 @@ export function renderApp( ReactDOM.unmountComponentAtNode(element); }; } - -interface RouterProps { - history: ScopedHistory; - store: Store; - coreStart: CoreStart; - depsStart: EndpointPluginStartDependencies; -} - -const AppRoot: React.FunctionComponent<RouterProps> = React.memo( - ({ history, store, coreStart, depsStart }) => { - return ( - <AppRootProvider store={store} history={history} coreStart={coreStart} depsStart={depsStart}> - <Setup ingestManager={depsStart.ingestManager} notifications={coreStart.notifications} /> - <HeaderNavigation /> - <Switch> - <Route - exact - path="/" - render={() => ( - <h1 data-test-subj="welcomeTitle"> - <FormattedMessage id="xpack.endpoint.welcomeTitle" defaultMessage="Hello World" /> - </h1> - )} - /> - <Route path="/hosts" component={HostList} /> - <Route path="/alerts" component={AlertIndex} /> - <Route path="/policy" exact component={PolicyList} /> - <Route path="/policy/:id" exact component={PolicyDetails} /> - <Route - render={() => ( - <FormattedMessage id="xpack.endpoint.notFound" defaultMessage="Page Not Found" /> - )} - /> - </Switch> - </AppRootProvider> - ); - } -); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/lib/index.ts b/x-pack/plugins/endpoint/public/applications/endpoint/lib/index.ts deleted file mode 100644 index ba2e1ce8f9fe6..0000000000000 --- a/x-pack/plugins/endpoint/public/applications/endpoint/lib/index.ts +++ /dev/null @@ -1,7 +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 * from './saga'; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.test.ts deleted file mode 100644 index 7c06681184085..0000000000000 --- a/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.test.ts +++ /dev/null @@ -1,114 +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 { createSagaMiddleware, SagaContext, SagaMiddleware } from './index'; -import { applyMiddleware, createStore, Reducer, Store } from 'redux'; - -describe('saga', () => { - const INCREMENT_COUNTER = 'INCREMENT'; - const DELAYED_INCREMENT_COUNTER = 'DELAYED INCREMENT COUNTER'; - const STOP_SAGA_PROCESSING = 'BREAK ASYNC ITERATOR'; - - const sleep = (ms = 100) => new Promise(resolve => setTimeout(resolve, ms)); - let store: Store; - let reducerA: Reducer; - let sideAffect: (a: unknown, s: unknown) => void; - let sagaExe: (sagaContext: SagaContext) => Promise<void>; - let sagaExeReduxMiddleware: SagaMiddleware; - - beforeEach(() => { - reducerA = jest.fn((prevState = { count: 0 }, { type }) => { - switch (type) { - case INCREMENT_COUNTER: - return { ...prevState, count: prevState.count + 1 }; - default: - return prevState; - } - }); - - sideAffect = jest.fn(); - - sagaExe = jest.fn(async ({ actionsAndState, dispatch }: SagaContext) => { - for await (const { action, state } of actionsAndState()) { - expect(action).toBeDefined(); - expect(state).toBeDefined(); - - if (action.type === STOP_SAGA_PROCESSING) { - break; - } - - sideAffect(action, state); - - if (action.type === DELAYED_INCREMENT_COUNTER) { - await sleep(1); - dispatch({ - type: INCREMENT_COUNTER, - }); - } - } - }); - - sagaExeReduxMiddleware = createSagaMiddleware(sagaExe); - store = createStore(reducerA, applyMiddleware(sagaExeReduxMiddleware)); - }); - - afterEach(() => { - sagaExeReduxMiddleware.stop(); - }); - - test('it does nothing if saga is not started', () => { - expect(sagaExe).not.toHaveBeenCalled(); - }); - - test('it can dispatch store actions once running', async () => { - sagaExeReduxMiddleware.start(); - expect(store.getState()).toEqual({ count: 0 }); - expect(sagaExe).toHaveBeenCalled(); - - store.dispatch({ type: DELAYED_INCREMENT_COUNTER }); - expect(store.getState()).toEqual({ count: 0 }); - - await sleep(); - - expect(sideAffect).toHaveBeenCalled(); - expect(store.getState()).toEqual({ count: 1 }); - }); - - test('it stops processing if break out of loop', async () => { - sagaExeReduxMiddleware.start(); - store.dispatch({ type: DELAYED_INCREMENT_COUNTER }); - await sleep(); - - expect(store.getState()).toEqual({ count: 1 }); - expect(sideAffect).toHaveBeenCalledTimes(2); - - store.dispatch({ type: STOP_SAGA_PROCESSING }); - await sleep(); - - store.dispatch({ type: DELAYED_INCREMENT_COUNTER }); - await sleep(); - - expect(store.getState()).toEqual({ count: 1 }); - expect(sideAffect).toHaveBeenCalledTimes(2); - }); - - test('it stops saga middleware when stop() is called', async () => { - sagaExeReduxMiddleware.start(); - store.dispatch({ type: DELAYED_INCREMENT_COUNTER }); - await sleep(); - - expect(store.getState()).toEqual({ count: 1 }); - expect(sideAffect).toHaveBeenCalledTimes(2); - - sagaExeReduxMiddleware.stop(); - - store.dispatch({ type: DELAYED_INCREMENT_COUNTER }); - await sleep(); - - expect(store.getState()).toEqual({ count: 1 }); - expect(sideAffect).toHaveBeenCalledTimes(2); - }); -}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.ts b/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.ts deleted file mode 100644 index 2a79827847f2e..0000000000000 --- a/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.ts +++ /dev/null @@ -1,159 +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 { AnyAction, Dispatch, Middleware, MiddlewareAPI } from 'redux'; -import { GlobalState } from '../types'; - -interface QueuedAction<TAction = AnyAction> { - /** - * The Redux action that was dispatched - */ - action: TAction; - /** - * The Global state at the time the action was dispatched - */ - state: GlobalState; -} - -interface IteratorInstance { - queue: QueuedAction[]; - nextResolve: null | ((inst: QueuedAction) => void); -} - -type Saga = (storeContext: SagaContext) => Promise<void>; - -type StoreActionsAndState<TAction = AnyAction> = AsyncIterableIterator<QueuedAction<TAction>>; - -export interface SagaContext<TAction extends AnyAction = AnyAction> { - /** - * A generator function that will `yield` `Promise`s that resolve with a `QueuedAction` - */ - actionsAndState: () => StoreActionsAndState<TAction>; - dispatch: Dispatch<TAction>; -} - -export interface SagaMiddleware extends Middleware { - /** - * Start the saga. Should be called after the `store` has been created - */ - start: () => void; - - /** - * Stop the saga by exiting the internal generator `for await...of` loop. - */ - stop: () => void; -} - -const noop = () => {}; -const STOP = Symbol('STOP'); - -/** - * Creates Saga Middleware for use with Redux. - * - * @param {Saga} saga The `saga` should initialize a long-running `for await...of` loop against - * the return value of the `actionsAndState()` method provided by the `SagaContext`. - * - * @return {SagaMiddleware} - * - * @example - * - * type TPossibleActions = { type: 'add', payload: any[] }; - * //... - * const endpointsSaga = async ({ actionsAndState, dispatch }: SagaContext<TPossibleActions>) => { - * for await (const { action, state } of actionsAndState()) { - * if (action.type === "userRequestedResource") { - * const resourceData = await doApiFetch('of/some/resource'); - * dispatch({ - * type: 'add', - * payload: [ resourceData ] - * }); - * } - * } - * } - * const endpointsSagaMiddleware = createSagaMiddleware(endpointsSaga); - * //.... - * const store = createStore(reducers, [ endpointsSagaMiddleware ]); - */ -export function createSagaMiddleware(saga: Saga): SagaMiddleware { - const iteratorInstances = new Set<IteratorInstance>(); - let runSaga: () => void = noop; - let stopSaga: () => void = noop; - let runningPromise: Promise<symbol>; - - async function* getActionsAndStateIterator(): StoreActionsAndState { - const instance: IteratorInstance = { queue: [], nextResolve: null }; - iteratorInstances.add(instance); - - try { - while (true) { - const actionAndState = await Promise.race([nextActionAndState(), runningPromise]); - - if (actionAndState === STOP) { - break; - } - - yield actionAndState as QueuedAction; - } - } finally { - // If the consumer stops consuming this (e.g. `break` or `return` is called in the `for await` - // then this `finally` block will run and unregister this instance and reset `runSaga` - iteratorInstances.delete(instance); - runSaga = stopSaga = noop; - } - - function nextActionAndState() { - if (instance.queue.length) { - return Promise.resolve(instance.queue.shift() as QueuedAction); - } else { - return new Promise<QueuedAction>(function(resolve) { - instance.nextResolve = resolve; - }); - } - } - } - - function enqueue(value: QueuedAction) { - for (const iteratorInstance of iteratorInstances) { - iteratorInstance.queue.push(value); - if (iteratorInstance.nextResolve !== null) { - iteratorInstance.nextResolve(iteratorInstance.queue.shift() as QueuedAction); - iteratorInstance.nextResolve = null; - } - } - } - - function middleware({ getState, dispatch }: MiddlewareAPI) { - if (runSaga === noop) { - runSaga = saga.bind<null, SagaContext, any[], Promise<void>>(null, { - actionsAndState: getActionsAndStateIterator, - dispatch, - }); - } - return (next: Dispatch<AnyAction>) => (action: AnyAction) => { - // Call the next dispatch method in the middleware chain. - const returnValue = next(action); - - enqueue({ - action, - state: getState(), - }); - - // This will likely be the action itself, unless a middleware further in chain changed it. - return returnValue; - }; - } - - middleware.start = () => { - runningPromise = new Promise(resolve => (stopSaga = () => resolve(STOP))); - runSaga(); - }; - - middleware.stop = () => { - stopSaga(); - }; - - return middleware; -} diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/mocks/app_context_render.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/mocks/app_context_render.tsx index 7cb1031ef9a09..639b1f7252d7f 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/mocks/app_context_render.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/mocks/app_context_render.tsx @@ -12,6 +12,7 @@ import { coreMock } from '../../../../../../../src/core/public/mocks'; import { EndpointPluginStartDependencies } from '../../../plugin'; import { depsStartMock } from './dependencies_start_mock'; import { AppRootProvider } from '../view/app_root_provider'; +import { createSpyMiddleware, MiddlewareActionSpyHelper } from '../store/test_utils'; type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; @@ -23,6 +24,7 @@ export interface AppContextTestRender { history: ReturnType<typeof createMemoryHistory>; coreStart: ReturnType<typeof coreMock.createStart>; depsStart: EndpointPluginStartDependencies; + middlewareSpy: MiddlewareActionSpyHelper; /** * A wrapper around `AppRootContext` component. Uses the mocked modules as input to the * `AppRootContext` @@ -45,7 +47,12 @@ export const createAppRootMockRenderer = (): AppContextTestRender => { const history = createMemoryHistory<never>(); const coreStart = coreMock.createStart({ basePath: '/mock' }); const depsStart = depsStartMock(); - const store = appStoreFactory({ coreStart, depsStart }); + const middlewareSpy = createSpyMiddleware(); + const store = appStoreFactory({ + coreStart, + depsStart, + additionalMiddleware: [middlewareSpy.actionSpyMiddleware], + }); const AppWrapper: React.FunctionComponent<{ children: React.ReactElement }> = ({ children }) => ( <AppRootProvider store={store} history={history} coreStart={coreStart} depsStart={depsStart}> {children} @@ -64,6 +71,7 @@ export const createAppRootMockRenderer = (): AppContextTestRender => { history, coreStart, depsStart, + middlewareSpy, AppWrapper, render, }; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/models/index_pattern.ts b/x-pack/plugins/endpoint/public/applications/endpoint/models/index_pattern.ts new file mode 100644 index 0000000000000..0cae054432f96 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/models/index_pattern.ts @@ -0,0 +1,16 @@ +/* + * 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 { all } from 'deepmerge'; +import { Immutable } from '../../../../common/types'; +import { IIndexPattern } from '../../../../../../../src/plugins/data/common'; + +/** + * Model for the `IIndexPattern` interface exported by the `data` plugin. + */ +export function clone(value: IIndexPattern | Immutable<IIndexPattern>): IIndexPattern { + return all([value]) as IIndexPattern; +} diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/models/policy.ts b/x-pack/plugins/endpoint/public/applications/endpoint/models/policy.ts deleted file mode 100644 index 9ac53f9be609f..0000000000000 --- a/x-pack/plugins/endpoint/public/applications/endpoint/models/policy.ts +++ /dev/null @@ -1,90 +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 { PolicyConfig, ProtectionModes } from '../types'; - -/** - * Generate a new Policy model. - * NOTE: in the near future, this will likely be removed and an API call to EPM will be used to retrieve - * the latest from the Endpoint package - */ -export const generatePolicy = (): PolicyConfig => { - return { - windows: { - events: { - process: true, - network: true, - }, - malware: { - mode: ProtectionModes.prevent, - }, - logging: { - stdout: 'debug', - file: 'info', - }, - advanced: { - elasticsearch: { - indices: { - control: 'control-index', - event: 'event-index', - logging: 'logging-index', - }, - kernel: { - connect: true, - process: true, - }, - }, - }, - }, - mac: { - events: { - process: true, - }, - malware: { - mode: ProtectionModes.detect, - }, - logging: { - stdout: 'debug', - file: 'info', - }, - advanced: { - elasticsearch: { - indices: { - control: 'control-index', - event: 'event-index', - logging: 'logging-index', - }, - kernel: { - connect: true, - process: true, - }, - }, - }, - }, - linux: { - events: { - process: true, - }, - logging: { - stdout: 'debug', - file: 'info', - }, - advanced: { - elasticsearch: { - indices: { - control: 'control-index', - event: 'event-index', - logging: 'logging-index', - }, - kernel: { - connect: true, - process: true, - }, - }, - }, - }, - }; -}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/models/policy_details_config.ts b/x-pack/plugins/endpoint/public/applications/endpoint/models/policy_details_config.ts index 1145d1d19242a..3e56b1ff14d65 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/models/policy_details_config.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/models/policy_details_config.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { UIPolicyConfig } from '../types'; +import { UIPolicyConfig } from '../../../../common/types'; /** * A typed Object.entries() function where the keys and values are typed based on the given object @@ -43,3 +43,33 @@ export function clone(policyDetailsConfig: UIPolicyConfig): UIPolicyConfig { */ return clonedConfig as UIPolicyConfig; } + +/** + * Returns value from `configuration` + */ +export const getIn = (a: UIPolicyConfig) => <Key extends keyof UIPolicyConfig>(key: Key) => < + subKey extends keyof UIPolicyConfig[Key] +>( + subKey: subKey +) => <LeafKey extends keyof UIPolicyConfig[Key][subKey]>( + leafKey: LeafKey +): UIPolicyConfig[Key][subKey][LeafKey] => { + return a[key][subKey][leafKey]; +}; + +/** + * Returns cloned `configuration` with `value` set by the `keyPath`. + */ +export const setIn = (a: UIPolicyConfig) => <Key extends keyof UIPolicyConfig>(key: Key) => < + subKey extends keyof UIPolicyConfig[Key] +>( + subKey: subKey +) => <LeafKey extends keyof UIPolicyConfig[Key][subKey]>(leafKey: LeafKey) => < + V extends UIPolicyConfig[Key][subKey][LeafKey] +>( + v: V +): UIPolicyConfig => { + const c = clone(a); + c[key][subKey][leafKey] = v; + return c; +}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/services/ingest.ts b/x-pack/plugins/endpoint/public/applications/endpoint/services/ingest.ts deleted file mode 100644 index 583ebc55d896b..0000000000000 --- a/x-pack/plugins/endpoint/public/applications/endpoint/services/ingest.ts +++ /dev/null @@ -1,114 +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 { HttpFetchOptions, HttpStart } from 'kibana/public'; -import { - CreateDatasourceResponse, - GetAgentStatusResponse, - GetDatasourcesRequest, -} from '../../../../../ingest_manager/common/types/rest_spec'; -import { NewPolicyData, PolicyData } from '../types'; - -const INGEST_API_ROOT = `/api/ingest_manager`; -const INGEST_API_DATASOURCES = `${INGEST_API_ROOT}/datasources`; -const INGEST_API_FLEET = `${INGEST_API_ROOT}/fleet`; -const INGEST_API_FLEET_AGENT_STATUS = `${INGEST_API_FLEET}/agent-status`; - -// FIXME: Import from ingest after - https://github.com/elastic/kibana/issues/60677 -export interface GetDatasourcesResponse { - items: PolicyData[]; - total: number; - page: number; - perPage: number; - success: boolean; -} - -// FIXME: Import from Ingest after - https://github.com/elastic/kibana/issues/60677 -export interface GetDatasourceResponse { - item: PolicyData; - success: boolean; -} - -// FIXME: Import from Ingest after - https://github.com/elastic/kibana/issues/60677 -export type UpdateDatasourceResponse = CreateDatasourceResponse & { - item: PolicyData; -}; - -/** - * Retrieves a list of endpoint specific datasources (those created with a `package.name` of - * `endpoint`) from Ingest - * @param http - * @param options - */ -export const sendGetEndpointSpecificDatasources = ( - http: HttpStart, - options: HttpFetchOptions & Partial<GetDatasourcesRequest> = {} -): Promise<GetDatasourcesResponse> => { - return http.get<GetDatasourcesResponse>(INGEST_API_DATASOURCES, { - ...options, - query: { - ...options.query, - kuery: `${ - options?.query?.kuery ? options.query.kuery + ' and ' : '' - }datasources.package.name: endpoint`, - }, - }); -}; - -/** - * Retrieves a single datasource based on ID from ingest - * @param http - * @param datasourceId - * @param options - */ -export const sendGetDatasource = ( - http: HttpStart, - datasourceId: string, - options?: HttpFetchOptions -) => { - return http.get<GetDatasourceResponse>(`${INGEST_API_DATASOURCES}/${datasourceId}`, options); -}; - -/** - * Updates a datasources - * - * @param http - * @param datasourceId - * @param datasource - * @param options - */ -export const sendPutDatasource = ( - http: HttpStart, - datasourceId: string, - datasource: NewPolicyData, - options: Exclude<HttpFetchOptions, 'body'> = {} -): Promise<UpdateDatasourceResponse> => { - return http.put(`${INGEST_API_DATASOURCES}/${datasourceId}`, { - ...options, - body: JSON.stringify(datasource), - }); -}; - -/** - * Get a status summary for all Agents that are currently assigned to a given agent configuration - * - * @param http - * @param configId - * @param options - */ -export const sendGetFleetAgentStatusForConfig = ( - http: HttpStart, - /** the Agent (fleet) configuration id */ - configId: string, - options: Exclude<HttpFetchOptions, 'query'> = {} -): Promise<GetAgentStatusResponse> => { - return http.get(INGEST_API_FLEET_AGENT_STATUS, { - ...options, - query: { - configId, - }, - }); -}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_details.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_details.test.ts index 79e9de9c67352..feac8944f476b 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_details.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_details.test.ts @@ -14,16 +14,17 @@ import { coreMock } from 'src/core/public/mocks'; import { DepsStartMock, depsStartMock } from '../../mocks'; import { createBrowserHistory } from 'history'; import { mockAlertResultList } from './mock_alert_result_list'; +import { Immutable } from '../../../../../common/types'; describe('alert details tests', () => { - let store: Store<AlertListState, AppAction>; + let store: Store<Immutable<AlertListState>, Immutable<AppAction>>; let coreStart: ReturnType<typeof coreMock.createStart>; let depsStart: DepsStartMock; let history: History<never>; /** * A function that waits until a selector returns true. */ - let selectorIsTrue: (selector: (state: AlertListState) => boolean) => Promise<void>; + let selectorIsTrue: (selector: (state: Immutable<AlertListState>) => boolean) => Promise<void>; beforeEach(() => { coreStart = coreMock.createStart(); depsStart = depsStartMock(); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list.test.ts index b1cc2d46f614a..84281813312e0 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list.test.ts @@ -12,20 +12,20 @@ import { alertMiddlewareFactory } from './middleware'; import { AppAction } from '../action'; import { coreMock } from 'src/core/public/mocks'; import { DepsStartMock, depsStartMock } from '../../mocks'; -import { AlertResultList } from '../../../../../common/types'; +import { AlertResultList, Immutable } from '../../../../../common/types'; import { isOnAlertPage } from './selectors'; import { createBrowserHistory } from 'history'; import { mockAlertResultList } from './mock_alert_result_list'; describe('alert list tests', () => { - let store: Store<AlertListState, AppAction>; + let store: Store<Immutable<AlertListState>, Immutable<AppAction>>; let coreStart: ReturnType<typeof coreMock.createStart>; let depsStart: DepsStartMock; let history: History<never>; /** * A function that waits until a selector returns true. */ - let selectorIsTrue: (selector: (state: AlertListState) => boolean) => Promise<void>; + let selectorIsTrue: (selector: (state: Immutable<AlertListState>) => boolean) => Promise<void>; beforeEach(() => { coreStart = coreMock.createStart(); depsStart = depsStartMock(); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list_pagination.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list_pagination.test.ts index bb5893f14287b..4cc86e9c0449c 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list_pagination.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list_pagination.test.ts @@ -15,9 +15,10 @@ import { DepsStartMock, depsStartMock } from '../../mocks'; import { createBrowserHistory } from 'history'; import { uiQueryParams } from './selectors'; import { urlFromQueryParams } from '../../view/alerts/url_from_query_params'; +import { Immutable } from '../../../../../common/types'; describe('alert list pagination', () => { - let store: Store<AlertListState, AppAction>; + let store: Store<Immutable<AlertListState>, Immutable<AppAction>>; let coreStart: ReturnType<typeof coreMock.createStart>; let depsStart: DepsStartMock; let history: History<never>; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/index.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/index.ts index f63910a1c305e..5545218d9abd6 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/index.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/index.ts @@ -6,4 +6,3 @@ export { alertListReducer } from './reducer'; export { AlertAction } from './action'; -export * from '../../types'; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts index 2c6ebf52189f5..52d72c6631443 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts @@ -15,10 +15,14 @@ import { EndpointAppConstants } from '../../../../../common/types'; export const alertMiddlewareFactory: MiddlewareFactory<AlertListState> = (coreStart, depsStart) => { async function fetchIndexPatterns(): Promise<IIndexPattern[]> { const { indexPatterns } = depsStart.data; - const indexName = EndpointAppConstants.ALERT_INDEX_NAME; - const fields = await indexPatterns.getFieldsForWildcard({ pattern: indexName }); + const eventsPattern: { indexPattern: string } = await coreStart.http.get( + `${EndpointAppConstants.INDEX_PATTERN_ROUTE}/${EndpointAppConstants.EVENT_DATASET}` + ); + const fields = await indexPatterns.getFieldsForWildcard({ + pattern: eventsPattern.indexPattern, + }); const indexPattern: IIndexPattern = { - title: indexName, + title: eventsPattern.indexPattern, fields, }; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/reducer.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/reducer.ts index 4430a4d39cf4a..52b91dcae7d70 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/reducer.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/reducer.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Reducer } from 'redux'; -import { AlertListState } from '../../types'; +import { AlertListState, ImmutableReducer } from '../../types'; import { AppAction } from '../action'; +import { Immutable } from '../../../../../common/types'; -const initialState = (): AlertListState => { +const initialState = (): Immutable<AlertListState> => { return { alerts: [], alertDetails: undefined, @@ -22,7 +22,7 @@ const initialState = (): AlertListState => { }; }; -export const alertListReducer: Reducer<AlertListState, AppAction> = ( +export const alertListReducer: ImmutableReducer<AlertListState, AppAction> = ( state = initialState(), action ) => { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts index 5e9b08c09c2c7..cc362c3701956 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts @@ -19,23 +19,23 @@ const createStructuredSelector: CreateStructuredSelector = createStructuredSelec /** * Returns the Alert Data array from state */ -export const alertListData = (state: AlertListState) => state.alerts; +export const alertListData = (state: Immutable<AlertListState>) => state.alerts; -export const selectedAlertDetailsData = (state: AlertListState) => state.alertDetails; +export const selectedAlertDetailsData = (state: Immutable<AlertListState>) => state.alertDetails; /** * Returns the alert list pagination data from state */ export const alertListPagination = createStructuredSelector({ - pageIndex: (state: AlertListState) => state.pageIndex, - pageSize: (state: AlertListState) => state.pageSize, - total: (state: AlertListState) => state.total, + pageIndex: (state: Immutable<AlertListState>) => state.pageIndex, + pageSize: (state: Immutable<AlertListState>) => state.pageSize, + total: (state: Immutable<AlertListState>) => state.total, }); /** * Returns a boolean based on whether or not the user is on the alerts page */ -export const isOnAlertPage = (state: AlertListState): boolean => { +export const isOnAlertPage = (state: Immutable<AlertListState>): boolean => { return state.location ? state.location.pathname === '/alerts' : false; }; @@ -44,10 +44,10 @@ export const isOnAlertPage = (state: AlertListState): boolean => { * Used to calculate urls for links and such. */ export const uiQueryParams: ( - state: AlertListState + state: Immutable<AlertListState> ) => Immutable<AlertingIndexUIQueryParams> = createSelector( - (state: AlertListState) => state.location, - (location: AlertListState['location']) => { + state => state.location, + (location: Immutable<AlertListState>['location']) => { const data: AlertingIndexUIQueryParams = {}; if (location) { // Removes the `?` from the beginning of query string if it exists @@ -82,7 +82,7 @@ export const uiQueryParams: ( * Parses the ui query params and returns a object that represents the query used by the SearchBar component. * If the query url param is undefined, a default is returned. */ -export const searchBarQuery: (state: AlertListState) => Query = createSelector( +export const searchBarQuery: (state: Immutable<AlertListState>) => Query = createSelector( uiQueryParams, ({ query }) => { if (query !== undefined) { @@ -97,21 +97,20 @@ export const searchBarQuery: (state: AlertListState) => Query = createSelector( * Parses the ui query params and returns a rison encoded string that represents the search bar's date range. * A default is provided if 'date_range' is not present in the url params. */ -export const encodedSearchBarDateRange: (state: AlertListState) => string = createSelector( - uiQueryParams, - ({ date_range: dateRange }) => { - if (dateRange === undefined) { - return encode({ from: 'now-24h', to: 'now' }); - } else { - return dateRange; - } +export const encodedSearchBarDateRange: ( + state: Immutable<AlertListState> +) => string = createSelector(uiQueryParams, ({ date_range: dateRange }) => { + if (dateRange === undefined) { + return encode({ from: 'now-24h', to: 'now' }); + } else { + return dateRange; } -); +}); /** * Parses the ui query params and returns a object that represents the dateRange used by the SearchBar component. */ -export const searchBarDateRange: (state: AlertListState) => TimeRange = createSelector( +export const searchBarDateRange: (state: Immutable<AlertListState>) => TimeRange = createSelector( encodedSearchBarDateRange, encodedDateRange => { return (decode(encodedDateRange) as unknown) as TimeRange; @@ -122,7 +121,7 @@ export const searchBarDateRange: (state: AlertListState) => TimeRange = createSe * Parses the ui query params and returns an array of filters used by the SearchBar component. * If the 'filters' param is not present, a default is returned. */ -export const searchBarFilters: (state: AlertListState) => Filter[] = createSelector( +export const searchBarFilters: (state: Immutable<AlertListState>) => Filter[] = createSelector( uiQueryParams, ({ filters }) => { if (filters !== undefined) { @@ -136,13 +135,14 @@ export const searchBarFilters: (state: AlertListState) => Filter[] = createSelec /** * Returns the indexPatterns used by the SearchBar component */ -export const searchBarIndexPatterns = (state: AlertListState) => state.searchBar.patterns; +export const searchBarIndexPatterns = (state: Immutable<AlertListState>) => + state.searchBar.patterns; /** * query params to use when requesting alert data. */ export const apiQueryParams: ( - state: AlertListState + state: Immutable<AlertListState> ) => Immutable<AlertingIndexGetQueryInput> = createSelector( uiQueryParams, encodedSearchBarDateRange, @@ -161,7 +161,7 @@ export const apiQueryParams: ( * True if the user has selected an alert to see details about. * Populated via the browsers query params. */ -export const hasSelectedAlert: (state: AlertListState) => boolean = createSelector( +export const hasSelectedAlert: (state: Immutable<AlertListState>) => boolean = createSelector( uiQueryParams, ({ selected_alert: selectedAlert }) => selectedAlert !== undefined ); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/action.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/action.ts index 4dafa68ddb647..21871ec8ca849 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/action.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/action.ts @@ -27,14 +27,8 @@ interface UserPaginatedHostList { payload: HostListPagination; } -// Why is FakeActionWithNoPayload here, see: https://github.com/elastic/endpoint-app-team/issues/273 -interface FakeActionWithNoPayload { - type: 'fakeActionWithNoPayLoad'; -} - export type HostAction = | ServerReturnedHostList | ServerReturnedHostDetails | ServerFailedToReturnHostDetails - | UserPaginatedHostList - | FakeActionWithNoPayload; + | UserPaginatedHostList; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.test.ts index 8c8578426aa29..8f39baddda00e 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.test.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ import { CoreStart, HttpSetup } from 'kibana/public'; -import { applyMiddleware, createStore, Dispatch, Store } from 'redux'; +import { applyMiddleware, createStore, Store } from 'redux'; import { coreMock } from '../../../../../../../../src/core/public/mocks'; import { History, createBrowserHistory } from 'history'; import { hostListReducer, hostMiddlewareFactory } from './index'; -import { HostResultList } from '../../../../../common/types'; +import { HostResultList, Immutable } from '../../../../../common/types'; import { HostListState } from '../../types'; import { AppAction } from '../action'; import { listData } from './selectors'; @@ -20,9 +20,10 @@ describe('host list middleware', () => { let fakeCoreStart: jest.Mocked<CoreStart>; let depsStart: DepsStartMock; let fakeHttpServices: jest.Mocked<HttpSetup>; - let store: Store<HostListState>; - let getState: typeof store['getState']; - let dispatch: Dispatch<AppAction>; + type HostListStore = Store<Immutable<HostListState>, Immutable<AppAction>>; + let store: HostListStore; + let getState: HostListStore['getState']; + let dispatch: HostListStore['dispatch']; let history: History<never>; const getEndpointListApiResponse = (): HostResultList => { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts index ad6741dab7be7..298e819645dbe 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Reducer } from 'redux'; -import { HostListState } from '../../types'; +import { HostListState, ImmutableReducer } from '../../types'; import { AppAction } from '../action'; const initialState = (): HostListState => { @@ -21,7 +20,7 @@ const initialState = (): HostListState => { }; }; -export const hostListReducer: Reducer<HostListState, AppAction> = ( +export const hostListReducer: ImmutableReducer<HostListState, AppAction> = ( state = initialState(), action ) => { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts index ebe310cb51190..03cdba8505800 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts @@ -8,36 +8,36 @@ import { createSelector } from 'reselect'; import { Immutable } from '../../../../../common/types'; import { HostListState, HostIndexUIQueryParams } from '../../types'; -export const listData = (state: HostListState) => state.hosts; +export const listData = (state: Immutable<HostListState>) => state.hosts; -export const pageIndex = (state: HostListState) => state.pageIndex; +export const pageIndex = (state: Immutable<HostListState>) => state.pageIndex; -export const pageSize = (state: HostListState) => state.pageSize; +export const pageSize = (state: Immutable<HostListState>) => state.pageSize; -export const totalHits = (state: HostListState) => state.total; +export const totalHits = (state: Immutable<HostListState>) => state.total; -export const isLoading = (state: HostListState) => state.loading; +export const isLoading = (state: Immutable<HostListState>) => state.loading; -export const detailsError = (state: HostListState) => state.detailsError; +export const detailsError = (state: Immutable<HostListState>) => state.detailsError; -export const detailsData = (state: HostListState) => { +export const detailsData = (state: Immutable<HostListState>) => { return state.details; }; -export const isOnHostPage = (state: HostListState) => +export const isOnHostPage = (state: Immutable<HostListState>) => state.location ? state.location.pathname === '/hosts' : false; export const uiQueryParams: ( - state: HostListState + state: Immutable<HostListState> ) => Immutable<HostIndexUIQueryParams> = createSelector( - (state: HostListState) => state.location, - (location: HostListState['location']) => { + (state: Immutable<HostListState>) => state.location, + (location: Immutable<HostListState>['location']) => { const data: HostIndexUIQueryParams = {}; if (location) { // Removes the `?` from the beginning of query string if it exists const query = querystring.parse(location.search.slice(1)); - const keys: Array<keyof HostIndexUIQueryParams> = ['selected_host']; + const keys: Array<keyof HostIndexUIQueryParams> = ['selected_host', 'show']; for (const key of keys) { const value = query[key]; @@ -52,9 +52,17 @@ export const uiQueryParams: ( } ); -export const hasSelectedHost: (state: HostListState) => boolean = createSelector( +export const hasSelectedHost: (state: Immutable<HostListState>) => boolean = createSelector( uiQueryParams, ({ selected_host: selectedHost }) => { return selectedHost !== undefined; } ); + +/** What policy details panel view to show */ +export const showView: (state: HostListState) => 'policy_response' | 'details' = createSelector( + uiQueryParams, + searchParams => { + return searchParams.show === 'policy_response' ? 'policy_response' : 'details'; + } +); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/immutable_combine_reducers.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/immutable_combine_reducers.ts new file mode 100644 index 0000000000000..6895f0106fb5d --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/immutable_combine_reducers.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 { combineReducers } from 'redux'; +import { ImmutableCombineReducers } from '../types'; + +/** + * Works the same as `combineReducers` from `redux`, but uses the `ImmutableCombineReducers` type. + */ +export const immutableCombineReducers: ImmutableCombineReducers = combineReducers; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts index efa79b163d3b6..60758f0f5fea0 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts @@ -19,7 +19,7 @@ import { alertMiddlewareFactory } from './alerts/middleware'; import { hostMiddlewareFactory } from './hosts'; import { policyListMiddlewareFactory } from './policy_list'; import { policyDetailsMiddlewareFactory } from './policy_details'; -import { GlobalState } from '../types'; +import { GlobalState, MiddlewareFactory } from '../types'; import { AppAction } from './action'; import { EndpointPluginStartDependencies } from '../../../plugin'; @@ -62,10 +62,15 @@ export const appStoreFactory: (middlewareDeps?: { * Give middleware access to plugin start dependencies. */ depsStart: EndpointPluginStartDependencies; + /** + * Any additional Redux Middlewares + * (should only be used for testing - example: to inject the action spy middleware) + */ + additionalMiddleware?: Array<ReturnType<MiddlewareFactory>>; }) => Store = middlewareDeps => { let middleware; if (middlewareDeps) { - const { coreStart, depsStart } = middlewareDeps; + const { coreStart, depsStart, additionalMiddleware = [] } = middlewareDeps; middleware = composeWithReduxDevTools( applyMiddleware( substateMiddlewareFactory( @@ -83,7 +88,9 @@ export const appStoreFactory: (middlewareDeps?: { substateMiddlewareFactory( globalState => globalState.alertList, alertMiddlewareFactory(coreStart, depsStart) - ) + ), + // Additional Middleware should go last + ...additionalMiddleware ) ); } else { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/action.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/action.ts index 9905145048a8a..4de3dac02a8ec 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/action.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/action.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PolicyData, PolicyDetailsState, ServerApiError, UIPolicyConfig } from '../../types'; +import { PolicyDetailsState, ServerApiError } from '../../types'; import { GetAgentStatusResponse } from '../../../../../../ingest_manager/common/types/rest_spec'; +import { PolicyData, UIPolicyConfig } from '../../../../../common/types'; interface ServerReturnedPolicyDetailsData { type: 'serverReturnedPolicyDetailsData'; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/index.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/index.test.ts index cf14092953227..a24687ebbcbbc 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/index.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/index.test.ts @@ -7,9 +7,9 @@ import { PolicyDetailsState } from '../../types'; import { createStore, Dispatch, Store } from 'redux'; import { policyDetailsReducer, PolicyDetailsAction } from './index'; -import { policyConfig, windowsEventing } from './selectors'; +import { policyConfig } from './selectors'; import { clone } from '../../models/policy_details_config'; -import { generatePolicy } from '../../models/policy'; +import { factory as policyConfigFactory } from '../../../../../common/models/policy_config'; describe('policy details: ', () => { let store: Store<PolicyDetailsState>; @@ -38,7 +38,7 @@ describe('policy details: ', () => { streams: [], config: { policy: { - value: generatePolicy(), + value: policyConfigFactory(), }, }, }, @@ -55,7 +55,7 @@ describe('policy details: ', () => { }); }); - describe('when the user has enabled windows process eventing', () => { + describe('when the user has enabled windows process events', () => { beforeEach(() => { const config = policyConfig(getState()); if (!config) { @@ -71,8 +71,53 @@ describe('policy details: ', () => { }); }); - it('windows process eventing is enabled', async () => { - expect(windowsEventing(getState())!.process).toEqual(true); + it('windows process events is enabled', () => { + const config = policyConfig(getState()); + expect(config!.windows.events.process).toEqual(true); + }); + }); + + describe('when the user has enabled mac file events', () => { + beforeEach(() => { + const config = policyConfig(getState()); + if (!config) { + throw new Error(); + } + + const newPayload1 = clone(config); + newPayload1.mac.events.file = true; + + dispatch({ + type: 'userChangedPolicyConfig', + payload: { policyConfig: newPayload1 }, + }); + }); + + it('mac file events is enabled', () => { + const config = policyConfig(getState()); + expect(config!.mac.events.file).toEqual(true); + }); + }); + + describe('when the user has enabled linux process events', () => { + beforeEach(() => { + const config = policyConfig(getState()); + if (!config) { + throw new Error(); + } + + const newPayload1 = clone(config); + newPayload1.linux.events.file = true; + + dispatch({ + type: 'userChangedPolicyConfig', + payload: { policyConfig: newPayload1 }, + }); + }); + + it('linux file events is enabled', () => { + const config = policyConfig(getState()); + expect(config!.linux.events.file).toEqual(true); }); }); }); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/middleware.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/middleware.ts index 18248e272aada..7a3fbe7f23b9b 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/middleware.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/middleware.ts @@ -4,15 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MiddlewareFactory, PolicyData, PolicyDetailsState } from '../../types'; +import { MiddlewareFactory, PolicyDetailsState, UpdatePolicyResponse } from '../../types'; import { policyIdFromParams, isOnPolicyDetailsPage, policyDetails } from './selectors'; import { sendGetDatasource, sendGetFleetAgentStatusForConfig, sendPutDatasource, - UpdateDatasourceResponse, -} from '../../services/ingest'; -import { generatePolicy } from '../../models/policy'; +} from '../policy_list/services/ingest'; +import { PolicyData } from '../../../../../common/types'; +import { factory as policyConfigFactory } from '../../../../../common/models/policy_config'; export const policyDetailsMiddlewareFactory: MiddlewareFactory<PolicyDetailsState> = coreStart => { const http = coreStart.http; @@ -35,7 +35,6 @@ export const policyDetailsMiddlewareFactory: MiddlewareFactory<PolicyDetailsStat return; } - // FIXME: remove this code once the Default Policy is available in the endpoint package - see: https://github.com/elastic/endpoint-app-team/issues/295 // Until we get the Default configuration into the Endpoint package so that the datasource has // the expected data structure, we will add it here manually. if (!policyItem.inputs.length) { @@ -46,7 +45,7 @@ export const policyDetailsMiddlewareFactory: MiddlewareFactory<PolicyDetailsStat streams: [], config: { policy: { - value: generatePolicy(), + value: policyConfigFactory(), }, }, }, @@ -62,7 +61,6 @@ export const policyDetailsMiddlewareFactory: MiddlewareFactory<PolicyDetailsStat // Agent summary is secondary data, so its ok for it to come after the details // page is populated with the main content - // FIXME: need to only do this IF fleet is enabled - see: https://github.com/elastic/endpoint-app-team/issues/296 if (policyItem.config_id) { const { results } = await sendGetFleetAgentStatusForConfig(http, policyItem.config_id); dispatch({ @@ -75,7 +73,7 @@ export const policyDetailsMiddlewareFactory: MiddlewareFactory<PolicyDetailsStat } else if (action.type === 'userClickedPolicyDetailsSaveButton') { const { id, revision, ...updatedPolicyItem } = policyDetails(state) as PolicyData; - let apiResponse: UpdateDatasourceResponse; + let apiResponse: UpdatePolicyResponse; try { apiResponse = await sendPutDatasource(http, id, updatedPolicyItem); } catch (error) { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/reducer.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/reducer.ts index fb3e26157ef32..9778f23d083a2 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/reducer.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/reducer.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Reducer } from 'redux'; -import { PolicyData, PolicyDetailsState, UIPolicyConfig } from '../../types'; import { AppAction } from '../action'; import { fullPolicy, isOnPolicyDetailsPage } from './selectors'; +import { PolicyDetailsState, ImmutableReducer } from '../../types'; +import { Immutable, PolicyConfig, UIPolicyConfig } from '../../../../../common/types'; const initialPolicyDetailsState = (): PolicyDetailsState => { return { @@ -23,7 +23,7 @@ const initialPolicyDetailsState = (): PolicyDetailsState => { }; }; -export const policyDetailsReducer: Reducer<PolicyDetailsState, AppAction> = ( +export const policyDetailsReducer: ImmutableReducer<PolicyDetailsState, AppAction> = ( state = initialPolicyDetailsState(), action ) => { @@ -70,7 +70,7 @@ export const policyDetailsReducer: Reducer<PolicyDetailsState, AppAction> = ( } if (action.type === 'userChangedUrl') { - const newState = { + const newState: Immutable<PolicyDetailsState> = { ...state, location: action.payload, }; @@ -79,8 +79,10 @@ export const policyDetailsReducer: Reducer<PolicyDetailsState, AppAction> = ( // Did user just enter the Detail page? if so, then set the loading indicator and return new state if (isCurrentlyOnDetailsPage && !wasPreviouslyOnDetailsPage) { - newState.isLoading = true; - return newState; + return { + ...newState, + isLoading: true, + }; } return { ...initialPolicyDetailsState(), @@ -89,12 +91,23 @@ export const policyDetailsReducer: Reducer<PolicyDetailsState, AppAction> = ( } if (action.type === 'userChangedPolicyConfig') { - const newState = { ...state, policyItem: { ...(state.policyItem as PolicyData) } }; - const newPolicy = (newState.policyItem.inputs[0].config.policy.value = { - ...fullPolicy(state), - }); + if (!state.policyItem) { + return state; + } + const newState = { ...state, policyItem: { ...state.policyItem } }; + const newPolicy: PolicyConfig = { ...fullPolicy(state) }; + + /** + * This is directly changing redux state because `policyItem.inputs` was copied over and not cloned. + */ + // @ts-ignore + newState.policyItem.inputs[0].config.policy.value = newPolicy; Object.entries(action.payload.policyConfig).forEach(([section, newSettings]) => { + /** + * this is not safe because `action.payload.policyConfig` may have excess keys + */ + // @ts-ignore newPolicy[section as keyof UIPolicyConfig] = { ...newPolicy[section as keyof UIPolicyConfig], ...newSettings, diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/selectors.ts index 0d505931c9ec5..98e129132e1cb 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/selectors.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/selectors.ts @@ -5,14 +5,15 @@ */ import { createSelector } from 'reselect'; -import { PolicyConfig, PolicyDetailsState, UIPolicyConfig } from '../../types'; -import { generatePolicy } from '../../models/policy'; +import { PolicyDetailsState } from '../../types'; +import { Immutable, PolicyConfig, UIPolicyConfig } from '../../../../../common/types'; +import { factory as policyConfigFactory } from '../../../../../common/models/policy_config'; /** Returns the policy details */ -export const policyDetails = (state: PolicyDetailsState) => state.policyItem; +export const policyDetails = (state: Immutable<PolicyDetailsState>) => state.policyItem; /** Returns a boolean of whether the user is on the policy details page or not */ -export const isOnPolicyDetailsPage = (state: PolicyDetailsState) => { +export const isOnPolicyDetailsPage = (state: Immutable<PolicyDetailsState>) => { if (state.location) { const pathnameParts = state.location.pathname.split('/'); return pathnameParts[1] === 'policy' && pathnameParts[2]; @@ -32,14 +33,16 @@ export const policyIdFromParams: (state: PolicyDetailsState) => string = createS } ); +const defaultFullPolicy: Immutable<PolicyConfig> = policyConfigFactory(); + /** * Returns the full Endpoint Policy, which will include private settings not shown on the UI. * Note: this will return a default full policy if the `policyItem` is `undefined` */ -export const fullPolicy: (s: PolicyDetailsState) => PolicyConfig = createSelector( +export const fullPolicy: (s: Immutable<PolicyDetailsState>) => PolicyConfig = createSelector( policyDetails, policyData => { - return policyData?.inputs[0]?.config?.policy?.value ?? generatePolicy(); + return policyData?.inputs[0]?.config?.policy?.value ?? defaultFullPolicy; } ); @@ -79,14 +82,8 @@ export const policyConfig: (s: PolicyDetailsState) => UIPolicyConfig = createSel } ); -/** Returns an object of all the windows eventing configuration */ -export const windowsEventing = (state: PolicyDetailsState) => { - const config = policyConfig(state); - return config && config.windows.events; -}; - /** Returns the total number of possible windows eventing configurations */ -export const totalWindowsEventing = (state: PolicyDetailsState): number => { +export const totalWindowsEvents = (state: PolicyDetailsState): number => { const config = policyConfig(state); if (config) { return Object.keys(config.windows.events).length; @@ -95,7 +92,7 @@ export const totalWindowsEventing = (state: PolicyDetailsState): number => { }; /** Returns the number of selected windows eventing configurations */ -export const selectedWindowsEventing = (state: PolicyDetailsState): number => { +export const selectedWindowsEvents = (state: PolicyDetailsState): number => { const config = policyConfig(state); if (config) { return Object.values(config.windows.events).reduce((count, event) => { @@ -105,6 +102,46 @@ export const selectedWindowsEventing = (state: PolicyDetailsState): number => { return 0; }; +/** Returns the total number of possible mac eventing configurations */ +export const totalMacEvents = (state: PolicyDetailsState): number => { + const config = policyConfig(state); + if (config) { + return Object.keys(config.mac.events).length; + } + return 0; +}; + +/** Returns the number of selected mac eventing configurations */ +export const selectedMacEvents = (state: PolicyDetailsState): number => { + const config = policyConfig(state); + if (config) { + return Object.values(config.mac.events).reduce((count, event) => { + return event === true ? count + 1 : count; + }, 0); + } + return 0; +}; + +/** Returns the total number of possible linux eventing configurations */ +export const totalLinuxEvents = (state: PolicyDetailsState): number => { + const config = policyConfig(state); + if (config) { + return Object.keys(config.linux.events).length; + } + return 0; +}; + +/** Returns the number of selected liinux eventing configurations */ +export const selectedLinuxEvents = (state: PolicyDetailsState): number => { + const config = policyConfig(state); + if (config) { + return Object.values(config.linux.events).reduce((count, event) => { + return event === true ? count + 1 : count; + }, 0); + } + return 0; +}; + /** is there an api call in flight */ export const isLoading = (state: PolicyDetailsState) => state.isLoading; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/action.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/action.ts index 3f4f3f39e9be0..4c379b7426461 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/action.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/action.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PolicyData, ServerApiError } from '../../types'; +import { ServerApiError } from '../../types'; +import { PolicyData } from '../../../../../common/types'; interface ServerReturnedPolicyListData { type: 'serverReturnedPolicyListData'; @@ -21,15 +22,4 @@ interface ServerFailedToReturnPolicyListData { payload: ServerApiError; } -interface UserPaginatedPolicyListTable { - type: 'userPaginatedPolicyListTable'; - payload: { - pageSize: number; - pageIndex: number; - }; -} - -export type PolicyListAction = - | ServerReturnedPolicyListData - | UserPaginatedPolicyListTable - | ServerFailedToReturnPolicyListData; +export type PolicyListAction = ServerReturnedPolicyListData | ServerFailedToReturnPolicyListData; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/index.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/index.test.ts index 0cf0eb8bfa3cd..69b11fb3c1f0e 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/index.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/index.test.ts @@ -4,71 +4,105 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PolicyListState } from '../../types'; -import { applyMiddleware, createStore, Dispatch, Store } from 'redux'; +import { EndpointAppLocation, PolicyListState } from '../../types'; +import { applyMiddleware, createStore, Store } from 'redux'; import { AppAction } from '../action'; import { policyListReducer } from './reducer'; import { policyListMiddlewareFactory } from './middleware'; import { coreMock } from '../../../../../../../../src/core/public/mocks'; -import { CoreStart } from 'kibana/public'; -import { selectIsLoading } from './selectors'; +import { isOnPolicyListPage, selectIsLoading, urlSearchParams } from './selectors'; import { DepsStartMock, depsStartMock } from '../../mocks'; +import { setPolicyListApiMockImplementation } from './test_mock_utils'; +import { INGEST_API_DATASOURCES } from './services/ingest'; +import { Immutable } from '../../../../../common/types'; +import { createSpyMiddleware, MiddlewareActionSpyHelper } from '../test_utils'; describe('policy list store concerns', () => { - const sleep = () => new Promise(resolve => setTimeout(resolve, 1000)); - let fakeCoreStart: jest.Mocked<CoreStart>; + let fakeCoreStart: ReturnType<typeof coreMock.createStart>; let depsStart: DepsStartMock; - let store: Store<PolicyListState>; - let getState: typeof store['getState']; - let dispatch: Dispatch<AppAction>; + type PolicyListStore = Store<Immutable<PolicyListState>, Immutable<AppAction>>; + let store: PolicyListStore; + let getState: PolicyListStore['getState']; + let dispatch: PolicyListStore['dispatch']; + let waitForAction: MiddlewareActionSpyHelper['waitForAction']; beforeEach(() => { fakeCoreStart = coreMock.createStart({ basePath: '/mock' }); depsStart = depsStartMock(); + setPolicyListApiMockImplementation(fakeCoreStart.http); + let actionSpyMiddleware; + ({ actionSpyMiddleware, waitForAction } = createSpyMiddleware<PolicyListState>()); + store = createStore( policyListReducer, - applyMiddleware(policyListMiddlewareFactory(fakeCoreStart, depsStart)) + applyMiddleware(policyListMiddlewareFactory(fakeCoreStart, depsStart), actionSpyMiddleware) ); getState = store.getState; dispatch = store.dispatch; }); - // https://github.com/elastic/kibana/issues/58972 - test.skip('it sets `isLoading` when `userNavigatedToPage`', async () => { - expect(selectIsLoading(getState())).toBe(false); - dispatch({ type: 'userNavigatedToPage', payload: 'policyListPage' }); - expect(selectIsLoading(getState())).toBe(true); - await sleep(); - expect(selectIsLoading(getState())).toBe(false); + it('it does nothing on `userChangedUrl` if pathname is NOT `/policy`', async () => { + const state = getState(); + expect(isOnPolicyListPage(state)).toBe(false); + dispatch({ + type: 'userChangedUrl', + payload: { + pathname: '/foo', + search: '', + hash: '', + } as EndpointAppLocation, + }); + expect(getState()).toEqual(state); }); - // https://github.com/elastic/kibana/issues/58896 - test.skip('it sets `isLoading` when `userPaginatedPolicyListTable`', async () => { + it('it reports `isOnPolicyListPage` correctly when router pathname is `/policy`', async () => { + dispatch({ + type: 'userChangedUrl', + payload: { + pathname: '/policy', + search: '', + hash: '', + }, + }); + expect(isOnPolicyListPage(getState())).toBe(true); + }); + + it('it sets `isLoading` when `userChangedUrl`', async () => { expect(selectIsLoading(getState())).toBe(false); dispatch({ - type: 'userPaginatedPolicyListTable', + type: 'userChangedUrl', payload: { - pageSize: 10, - pageIndex: 1, + pathname: '/policy', + search: '', + hash: '', }, }); expect(selectIsLoading(getState())).toBe(true); - await sleep(); + await waitForAction('serverReturnedPolicyListData'); expect(selectIsLoading(getState())).toBe(false); }); - test('it resets state on `userNavigatedFromPage` action', async () => { + it('it resets state on `userChangedUrl` and pathname is NOT `/policy`', async () => { + dispatch({ + type: 'userChangedUrl', + payload: { + pathname: '/policy', + search: '', + hash: '', + }, + }); + await waitForAction('serverReturnedPolicyListData'); dispatch({ - type: 'serverReturnedPolicyListData', + type: 'userChangedUrl', payload: { - policyItems: [], - pageIndex: 20, - pageSize: 50, - total: 200, + pathname: '/foo', + search: '', + hash: '', }, }); - dispatch({ type: 'userNavigatedFromPage', payload: 'policyListPage' }); expect(getState()).toEqual({ + apiError: undefined, + location: undefined, policyItems: [], isLoading: false, pageIndex: 0, @@ -76,4 +110,85 @@ describe('policy list store concerns', () => { total: 0, }); }); + it('uses default pagination params when not included in url', async () => { + dispatch({ + type: 'userChangedUrl', + payload: { + pathname: '/policy', + search: '', + hash: '', + }, + }); + await waitForAction('serverReturnedPolicyListData'); + expect(fakeCoreStart.http.get).toHaveBeenCalledWith(INGEST_API_DATASOURCES, { + query: { kuery: 'datasources.package.name: endpoint', page: 1, perPage: 10 }, + }); + }); + + describe('when url contains search params', () => { + const dispatchUserChangedUrl = (searchParams: string = '') => + dispatch({ + type: 'userChangedUrl', + payload: { + pathname: '/policy', + search: searchParams, + hash: '', + }, + }); + + it('uses pagination params from url', async () => { + dispatchUserChangedUrl('?page_size=50&page_index=0'); + await waitForAction('serverReturnedPolicyListData'); + expect(fakeCoreStart.http.get).toHaveBeenCalledWith(INGEST_API_DATASOURCES, { + query: { kuery: 'datasources.package.name: endpoint', page: 1, perPage: 50 }, + }); + }); + it('uses defaults for params not in url', async () => { + dispatchUserChangedUrl('?page_index=99'); + expect(urlSearchParams(getState())).toEqual({ + page_index: 99, + page_size: 10, + }); + dispatchUserChangedUrl('?page_size=50'); + expect(urlSearchParams(getState())).toEqual({ + page_index: 0, + page_size: 50, + }); + }); + it('accepts only positive numbers for page_index and page_size', async () => { + dispatchUserChangedUrl('?page_size=-50&page_index=-99'); + await waitForAction('serverReturnedPolicyListData'); + expect(fakeCoreStart.http.get).toHaveBeenCalledWith(INGEST_API_DATASOURCES, { + query: { kuery: 'datasources.package.name: endpoint', page: 1, perPage: 10 }, + }); + }); + it('it ignores non-numeric values for page_index and page_size', async () => { + dispatchUserChangedUrl('?page_size=fifty&page_index=ten'); + await waitForAction('serverReturnedPolicyListData'); + expect(fakeCoreStart.http.get).toHaveBeenCalledWith(INGEST_API_DATASOURCES, { + query: { kuery: 'datasources.package.name: endpoint', page: 1, perPage: 10 }, + }); + }); + it('accepts only known values for `page_size`', async () => { + dispatchUserChangedUrl('?page_size=300&page_index=10'); + await waitForAction('serverReturnedPolicyListData'); + expect(fakeCoreStart.http.get).toHaveBeenCalledWith(INGEST_API_DATASOURCES, { + query: { kuery: 'datasources.package.name: endpoint', page: 11, perPage: 10 }, + }); + }); + it(`ignores unknown url search params`, async () => { + dispatchUserChangedUrl('?page_size=20&page_index=10&foo=bar'); + expect(urlSearchParams(getState())).toEqual({ + page_index: 10, + page_size: 20, + }); + }); + it(`uses last param value if param is defined multiple times`, async () => { + dispatchUserChangedUrl('?page_size=20&page_size=50&page_index=20&page_index=40'); + expect(urlSearchParams(getState())).toEqual({ + page_index: 20, + page_size: 20, + }); + }); + }); }); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/middleware.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/middleware.ts index ebfee5dbe6a7e..cc771a4619e78 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/middleware.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/middleware.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MiddlewareFactory, PolicyListState } from '../../types'; -import { GetDatasourcesResponse, sendGetEndpointSpecificDatasources } from '../../services/ingest'; +import { GetPolicyListResponse, MiddlewareFactory, PolicyListState } from '../../types'; +import { sendGetEndpointSpecificDatasources } from './services/ingest'; +import { isOnPolicyListPage, urlSearchParams } from './selectors'; export const policyListMiddlewareFactory: MiddlewareFactory<PolicyListState> = coreStart => { const http = coreStart.http; @@ -13,23 +14,11 @@ export const policyListMiddlewareFactory: MiddlewareFactory<PolicyListState> = c return ({ getState, dispatch }) => next => async action => { next(action); - if ( - (action.type === 'userNavigatedToPage' && action.payload === 'policyListPage') || - action.type === 'userPaginatedPolicyListTable' - ) { - const state = getState(); - let pageSize: number; - let pageIndex: number; - - if (action.type === 'userPaginatedPolicyListTable') { - pageSize = action.payload.pageSize; - pageIndex = action.payload.pageIndex; - } else { - pageSize = state.pageSize; - pageIndex = state.pageIndex; - } + const state = getState(); - let response: GetDatasourcesResponse; + if (action.type === 'userChangedUrl' && isOnPolicyListPage(state)) { + const { page_index: pageIndex, page_size: pageSize } = urlSearchParams(state); + let response: GetPolicyListResponse; try { response = await sendGetEndpointSpecificDatasources(http, { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/reducer.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/reducer.ts index b964f4f023866..ccd3f84dd060c 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/reducer.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/reducer.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Reducer } from 'redux'; -import { PolicyListState } from '../../types'; +import { PolicyListState, ImmutableReducer } from '../../types'; import { AppAction } from '../action'; +import { isOnPolicyListPage } from './selectors'; +import { Immutable } from '../../../../../common/types'; const initialPolicyListState = (): PolicyListState => { return { @@ -16,10 +17,11 @@ const initialPolicyListState = (): PolicyListState => { pageIndex: 0, pageSize: 10, total: 0, + location: undefined, }; }; -export const policyListReducer: Reducer<PolicyListState, AppAction> = ( +export const policyListReducer: ImmutableReducer<PolicyListState, AppAction> = ( state = initialPolicyListState(), action ) => { @@ -39,18 +41,26 @@ export const policyListReducer: Reducer<PolicyListState, AppAction> = ( }; } - if ( - action.type === 'userPaginatedPolicyListTable' || - (action.type === 'userNavigatedToPage' && action.payload === 'policyListPage') - ) { - return { + if (action.type === 'userChangedUrl') { + const newState: Immutable<PolicyListState> = { ...state, - apiError: undefined, - isLoading: true, + location: action.payload, }; - } + const isCurrentlyOnListPage = isOnPolicyListPage(newState); + const wasPreviouslyOnListPage = isOnPolicyListPage(state); - if (action.type === 'userNavigatedFromPage' && action.payload === 'policyListPage') { + // If on the current page, then return new state with location information + // Also adjust some state if user is just entering the policy list view + if (isCurrentlyOnListPage) { + if (!wasPreviouslyOnListPage) { + return { + ...newState, + apiError: undefined, + isLoading: true, + }; + } + return newState; + } return initialPolicyListState(); } diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/selectors.ts index 7ca25e81ce75a..6d2e952fa07bb 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/selectors.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/selectors.ts @@ -4,16 +4,65 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PolicyListState } from '../../types'; +import { createSelector } from 'reselect'; +import { parse } from 'query-string'; +import { PolicyListState, PolicyListUrlSearchParams } from '../../types'; +import { Immutable } from '../../../../../common/types'; -export const selectPolicyItems = (state: PolicyListState) => state.policyItems; +const PAGE_SIZES = Object.freeze([10, 20, 50]); -export const selectPageIndex = (state: PolicyListState) => state.pageIndex; +export const selectPolicyItems = (state: Immutable<PolicyListState>) => state.policyItems; -export const selectPageSize = (state: PolicyListState) => state.pageSize; +export const selectPageIndex = (state: Immutable<PolicyListState>) => state.pageIndex; -export const selectTotal = (state: PolicyListState) => state.total; +export const selectPageSize = (state: Immutable<PolicyListState>) => state.pageSize; -export const selectIsLoading = (state: PolicyListState) => state.isLoading; +export const selectTotal = (state: Immutable<PolicyListState>) => state.total; -export const selectApiError = (state: PolicyListState) => state.apiError; +export const selectIsLoading = (state: Immutable<PolicyListState>) => state.isLoading; + +export const selectApiError = (state: Immutable<PolicyListState>) => state.apiError; + +export const isOnPolicyListPage = (state: Immutable<PolicyListState>) => { + return state.location?.pathname === '/policy'; +}; + +const routeLocation = (state: Immutable<PolicyListState>) => state.location; + +/** + * Returns the supported URL search params, populated with defaults if none where present in the URL + */ +export const urlSearchParams: ( + state: Immutable<PolicyListState> +) => PolicyListUrlSearchParams = createSelector(routeLocation, location => { + const searchParams = { + page_index: 0, + page_size: 10, + }; + if (!location) { + return searchParams; + } + + const query = parse(location.search); + + // Search params can appear multiple times in the URL, in which case the value for them, + // once parsed, would be an array. In these case, we take the first value defined + searchParams.page_index = Number( + (Array.isArray(query.page_index) ? query.page_index[0] : query.page_index) ?? 0 + ); + searchParams.page_size = Number( + (Array.isArray(query.page_size) ? query.page_size[0] : query.page_size) ?? 10 + ); + + // If pageIndex is not a valid positive integer, set it to 0 + if (!Number.isFinite(searchParams.page_index) || searchParams.page_index < 0) { + searchParams.page_index = 0; + } + + // if pageSize is not one of the expected page sizes, reset it to 10 + if (!PAGE_SIZES.includes(searchParams.page_size)) { + searchParams.page_size = 10; + } + + return searchParams; +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/services/ingest.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/services/ingest.test.ts similarity index 95% rename from x-pack/plugins/endpoint/public/applications/endpoint/services/ingest.test.ts rename to x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/services/ingest.test.ts index a2c1dfbe09a48..c2865d36c95f2 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/services/ingest.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/services/ingest.test.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { httpServiceMock } from '../../../../../../../src/core/public/mocks'; import { sendGetDatasource, sendGetEndpointSpecificDatasources } from './ingest'; +import { httpServiceMock } from '../../../../../../../../../src/core/public/mocks'; describe('ingest service', () => { let http: ReturnType<typeof httpServiceMock.createStartContract>; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/services/ingest.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/services/ingest.ts new file mode 100644 index 0000000000000..4356517e43c2c --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/services/ingest.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpFetchOptions, HttpStart } from 'kibana/public'; +import { + GetDatasourcesRequest, + GetAgentStatusResponse, +} from '../../../../../../../ingest_manager/common'; +import { GetPolicyListResponse, GetPolicyResponse, UpdatePolicyResponse } from '../../../types'; +import { NewPolicyData } from '../../../../../../common/types'; + +const INGEST_API_ROOT = `/api/ingest_manager`; +export const INGEST_API_DATASOURCES = `${INGEST_API_ROOT}/datasources`; +const INGEST_API_FLEET = `${INGEST_API_ROOT}/fleet`; +const INGEST_API_FLEET_AGENT_STATUS = `${INGEST_API_FLEET}/agent-status`; + +/** + * Retrieves a list of endpoint specific datasources (those created with a `package.name` of + * `endpoint`) from Ingest + * @param http + * @param options + */ +export const sendGetEndpointSpecificDatasources = ( + http: HttpStart, + options: HttpFetchOptions & Partial<GetDatasourcesRequest> = {} +): Promise<GetPolicyListResponse> => { + return http.get<GetPolicyListResponse>(INGEST_API_DATASOURCES, { + ...options, + query: { + ...options.query, + kuery: `${ + options?.query?.kuery ? options.query.kuery + ' and ' : '' + }datasources.package.name: endpoint`, + }, + }); +}; + +/** + * Retrieves a single datasource based on ID from ingest + * @param http + * @param datasourceId + * @param options + */ +export const sendGetDatasource = ( + http: HttpStart, + datasourceId: string, + options?: HttpFetchOptions +) => { + return http.get<GetPolicyResponse>(`${INGEST_API_DATASOURCES}/${datasourceId}`, options); +}; + +/** + * Updates a datasources + * + * @param http + * @param datasourceId + * @param datasource + * @param options + */ +export const sendPutDatasource = ( + http: HttpStart, + datasourceId: string, + datasource: NewPolicyData, + options: Exclude<HttpFetchOptions, 'body'> = {} +): Promise<UpdatePolicyResponse> => { + return http.put(`${INGEST_API_DATASOURCES}/${datasourceId}`, { + ...options, + body: JSON.stringify(datasource), + }); +}; + +/** + * Get a status summary for all Agents that are currently assigned to a given agent configuration + * + * @param http + * @param configId + * @param options + */ +export const sendGetFleetAgentStatusForConfig = ( + http: HttpStart, + /** the Agent (fleet) configuration id */ + configId: string, + options: Exclude<HttpFetchOptions, 'query'> = {} +): Promise<GetAgentStatusResponse> => { + return http.get(INGEST_API_FLEET_AGENT_STATUS, { + ...options, + query: { + configId, + }, + }); +}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/test_mock_utils.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/test_mock_utils.ts new file mode 100644 index 0000000000000..a1788b8f8021d --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/test_mock_utils.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpStart } from 'kibana/public'; +import { INGEST_API_DATASOURCES } from './services/ingest'; +import { EndpointDocGenerator } from '../../../../../common/generate_data'; +import { GetPolicyListResponse } from '../../types'; + +const generator = new EndpointDocGenerator('policy-list'); + +/** + * It sets the mock implementation on the necessary http methods to support the policy list view + * @param mockedHttpService + * @param responseItems + */ +export const setPolicyListApiMockImplementation = ( + mockedHttpService: jest.Mocked<HttpStart>, + responseItems: GetPolicyListResponse['items'] = [generator.generatePolicyDatasource()] +): void => { + mockedHttpService.get.mockImplementation((...args) => { + const [path] = args; + if (typeof path === 'string') { + if (path === INGEST_API_DATASOURCES) { + return Promise.resolve<GetPolicyListResponse>({ + items: responseItems, + total: 10, + page: 1, + perPage: 10, + success: true, + }); + } + } + return Promise.reject(new Error(`MOCK: unknown policy list api: ${path}`)); + }); +}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/reducer.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/reducer.ts index c8b2d08676724..2f77c380d9387 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/reducer.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/reducer.ts @@ -3,15 +3,16 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { combineReducers, Reducer } from 'redux'; + import { hostListReducer } from './hosts'; import { AppAction } from './action'; import { alertListReducer } from './alerts'; -import { GlobalState } from '../types'; +import { GlobalState, ImmutableReducer } from '../types'; import { policyListReducer } from './policy_list'; import { policyDetailsReducer } from './policy_details'; +import { immutableCombineReducers } from './immutable_combine_reducers'; -export const appReducer: Reducer<GlobalState, AppAction> = combineReducers({ +export const appReducer: ImmutableReducer<GlobalState, AppAction> = immutableCombineReducers({ hostList: hostListReducer, alertList: alertListReducer, policyList: policyListReducer, diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/routing/action.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/routing/action.ts index c7e9970e58c30..fd72a02b33588 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/routing/action.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/routing/action.ts @@ -4,22 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PageId, Immutable } from '../../../../../common/types'; -import { EndpointAppLocation } from '../alerts'; - -interface UserNavigatedToPage { - readonly type: 'userNavigatedToPage'; - readonly payload: PageId; -} - -interface UserNavigatedFromPage { - readonly type: 'userNavigatedFromPage'; - readonly payload: PageId; -} +import { Immutable } from '../../../../../common/types'; +import { EndpointAppLocation } from '../../types'; interface UserChangedUrl { readonly type: 'userChangedUrl'; readonly payload: Immutable<EndpointAppLocation>; } -export type RoutingAction = UserNavigatedToPage | UserNavigatedFromPage | UserChangedUrl; +export type RoutingAction = UserChangedUrl; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/test_utils.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/test_utils.ts new file mode 100644 index 0000000000000..99e14cef73e8b --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/test_utils.ts @@ -0,0 +1,126 @@ +/* + * 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 { Dispatch } from 'redux'; +import { AppAction, GlobalState, MiddlewareFactory } from '../types'; + +/** + * Utilities for testing Redux middleware + */ +export interface MiddlewareActionSpyHelper<S = GlobalState, A extends AppAction = AppAction> { + /** + * Returns a promise that is fulfilled when the given action is dispatched or a timeout occurs. + * The `action` will given to the promise `resolve` thus allowing for checks to be done. + * The use of this method instead of a `sleep()` type of delay should avoid test case instability + * especially when run in a CI environment. + * + * @param actionType + */ + waitForAction: <T extends A['type']>(actionType: T) => Promise<A extends { type: T } ? A : never>; + /** + * A property holding the information around the calls that were processed by the internal + * `actionSpyMiddelware`. This property holds the information typically found in Jets's mocked + * function `mock` property - [see here for more information](https://jestjs.io/docs/en/mock-functions#mock-property) + * + * **Note**: this property will only be set **after* the `actionSpyMiddlware` has been + * initialized (ex. via `createStore()`. Attempting to reference this property before that time + * will throw an error. + * Also - do not hold on to references to this property value if `jest.clearAllMocks()` or + * `jest.resetAllMocks()` is called between usages of the value. + */ + dispatchSpy: jest.Mock<Dispatch<A>>['mock']; + /** + * Redux middleware that enables spying on the action that are dispatched through the store + */ + actionSpyMiddleware: ReturnType<MiddlewareFactory<S>>; +} + +/** + * Creates a new instance of middleware action helpers + * Note: in most cases (testing concern specific middleware) this function should be given + * the state type definition, else, the global state will be used. + * + * @example + * // Use in Policy List middleware testing + * const middlewareSpyUtils = createSpyMiddleware<PolicyListState>(); + * store = createStore( + * policyListReducer, + * applyMiddleware( + * policyListMiddlewareFactory(fakeCoreStart, depsStart), + * middlewareSpyUtils.actionSpyMiddleware + * ) + * ); + * // Reference `dispatchSpy` ONLY after creating the store that includes `actionSpyMiddleware` + * const { waitForAction, dispatchSpy } = middlewareSpyUtils; + * // + * // later in test + * // + * it('...', async () => { + * //... + * await waitForAction('serverReturnedPolicyListData'); + * // do assertions + * // or check how action was called + * expect(dispatchSpy.calls.length).toBe(2) + * }); + */ +export const createSpyMiddleware = < + S = GlobalState, + A extends AppAction = AppAction +>(): MiddlewareActionSpyHelper<S, A> => { + type ActionWatcher = (action: A) => void; + + const watchers = new Set<ActionWatcher>(); + let spyDispatch: jest.Mock<Dispatch<A>>; + + return { + waitForAction: async actionType => { + type ResolvedAction = A extends { type: typeof actionType } ? A : never; + + // Error is defined here so that we get a better stack trace that points to the test from where it was used + const err = new Error(`action '${actionType}' was not dispatched within the allocated time`); + + return new Promise<ResolvedAction>((resolve, reject) => { + const watch: ActionWatcher = action => { + if (action.type === actionType) { + watchers.delete(watch); + clearTimeout(timeout); + resolve(action as ResolvedAction); + } + }; + + // We timeout before jest's default 5s, so that a better error stack is returned + const timeout = setTimeout(() => { + watchers.delete(watch); + reject(err); + }, 4500); + watchers.add(watch); + }); + }, + + get dispatchSpy() { + if (!spyDispatch) { + throw new Error( + 'Spy Middleware has not been initialized. Access this property only after using `actionSpyMiddleware` in a redux store' + ); + } + return spyDispatch.mock; + }, + + actionSpyMiddleware: api => { + return next => { + spyDispatch = jest.fn(action => { + next(action); + // loop through the list of watcher (if any) and call them with this action + for (const watch of watchers) { + watch(action); + } + return action; + }); + return spyDispatch; + }; + }, + }; +}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts index d4f6d2457254e..59cd8f806e5b0 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Dispatch, MiddlewareAPI } from 'redux'; +import { Dispatch, MiddlewareAPI, Action as ReduxAction, AnyAction as ReduxAnyAction } from 'redux'; import { IIndexPattern } from 'src/plugins/data/public'; import { HostMetadata, @@ -13,12 +13,19 @@ import { Immutable, ImmutableArray, AlertDetails, + MalwareFields, + UIPolicyConfig, + PolicyData, } from '../../../common/types'; import { EndpointPluginStartDependencies } from '../../plugin'; import { AppAction } from './store/action'; import { CoreStart } from '../../../../../../src/core/public'; -import { Datasource, NewDatasource } from '../../../../ingest_manager/common/types/models'; -import { GetAgentStatusResponse } from '../../../../ingest_manager/common/types/rest_spec'; +import { + GetAgentStatusResponse, + GetDatasourcesResponse, + GetOneDatasourceResponse, + UpdateDatasourceResponse, +} from '../../../../ingest_manager/common'; export { AppAction }; export type MiddlewareFactory<S = GlobalState> = ( @@ -45,6 +52,7 @@ export interface HostListPagination { } export interface HostIndexUIQueryParams { selected_host?: string; + show?: string; } export interface ServerApiError { @@ -53,29 +61,6 @@ export interface ServerApiError { message: string; } -/** - * New policy data. Used when updating the policy record via ingest APIs - */ -export type NewPolicyData = NewDatasource & { - inputs: [ - { - type: 'endpoint'; - enabled: boolean; - streams: []; - config: { - policy: { - value: PolicyConfig; - }; - }; - } - ]; -}; - -/** - * Endpoint Policy data, which extends Ingest's `Datasource` type - */ -export type PolicyData = Datasource & NewPolicyData; - /** * Policy list store state */ @@ -92,6 +77,8 @@ export interface PolicyListState { pageIndex: number; /** data is being retrieved from server */ isLoading: boolean; + /** current location information */ + location?: Immutable<EndpointAppLocation>; } /** @@ -114,16 +101,28 @@ export interface PolicyDetailsState { }; } +/** + * The URL search params that are supported by the Policy List page view + */ +export interface PolicyListUrlSearchParams { + page_index: number; + page_size: number; +} + /** * Endpoint Policy configuration */ export interface PolicyConfig { windows: { events: { - process: boolean; + dll_and_driver_load: boolean; + dns: boolean; + file: boolean; network: boolean; + process: boolean; + registry: boolean; + security: boolean; }; - /** malware mode can be off, detect, prevent or prevent and notify user */ malware: MalwareFields; logging: { stdout: string; @@ -133,7 +132,9 @@ export interface PolicyConfig { }; mac: { events: { + file: boolean; process: boolean; + network: boolean; }; malware: MalwareFields; logging: { @@ -144,7 +145,9 @@ export interface PolicyConfig { }; linux: { events: { + file: boolean; process: boolean; + network: boolean; }; logging: { stdout: string; @@ -168,30 +171,6 @@ interface PolicyConfigAdvancedOptions { }; } -/** - * Windows-specific policy configuration that is supported via the UI - */ -type WindowsPolicyConfig = Pick<PolicyConfig['windows'], 'events' | 'malware'>; - -/** - * Mac-specific policy configuration that is supported via the UI - */ -type MacPolicyConfig = Pick<PolicyConfig['mac'], 'malware' | 'events'>; - -/** - * Linux-specific policy configuration that is supported via the UI - */ -type LinuxPolicyConfig = Pick<PolicyConfig['linux'], 'events'>; - -/** - * The set of Policy configuration settings that are show/edited via the UI - */ -export interface UIPolicyConfig { - windows: WindowsPolicyConfig; - mac: MacPolicyConfig; - linux: LinuxPolicyConfig; -} - /** OS used in Policy */ export enum OS { windows = 'windows', @@ -199,12 +178,6 @@ export enum OS { linux = 'linux', } -/** Used in Policy */ -export enum EventingFields { - process = 'process', - network = 'network', -} - /** * Returns the keys of an object whose values meet a criteria. * Ex) interface largeNestedObject = { @@ -228,20 +201,7 @@ export type KeysByValueCriteria<O, Criteria> = { }[keyof O]; /** Returns an array of the policy OSes that have a malware protection field */ - export type MalwareProtectionOSes = KeysByValueCriteria<UIPolicyConfig, { malware: MalwareFields }>; -/** Policy: Malware protection fields */ -export interface MalwareFields { - mode: ProtectionModes; -} - -/** Policy protection mode options */ -export enum ProtectionModes { - detect = 'detect', - prevent = 'prevent', - preventNotify = 'preventNotify', - off = 'off', -} export interface GlobalState { readonly hostList: HostListState; @@ -319,3 +279,40 @@ export interface AlertingIndexUIQueryParams { date_range?: string; filters?: string; } + +export interface GetPolicyListResponse extends GetDatasourcesResponse { + items: PolicyData[]; +} + +export interface GetPolicyResponse extends GetOneDatasourceResponse { + item: PolicyData; +} + +export interface UpdatePolicyResponse extends UpdateDatasourceResponse { + item: PolicyData; +} + +/** + * Like `Reducer` from `redux` but it accepts immutable versions of `state` and `action`. + * Use this type for all Reducers in order to help enforce our pattern of immutable state. + */ +export type ImmutableReducer<State, Action> = ( + state: Immutable<State> | undefined, + action: Immutable<Action> +) => State | Immutable<State>; + +/** + * A alternate interface for `redux`'s `combineReducers`. Will work with the same underlying implementation, + * but will enforce that `Immutable` versions of `state` and `action` are received. + */ +export type ImmutableCombineReducers = <S, A extends ReduxAction = ReduxAnyAction>( + reducers: ImmutableReducersMapObject<S, A> +) => ImmutableReducer<S, A>; + +/** + * Like `redux`'s `ReducersMapObject` (which is used by `combineReducers`) but enforces that + * the `state` and `action` received are `Immutable` versions. + */ +type ImmutableReducersMapObject<S, A extends ReduxAction = ReduxAction> = { + [K in keyof S]: ImmutableReducer<S[K], A>; +}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index_search_bar.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index_search_bar.tsx index 5b872962a5dc0..1ede06c086517 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index_search_bar.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index_search_bar.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { memo, useEffect, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import { encode, RisonValue } from 'rison-node'; @@ -14,11 +14,18 @@ import { urlFromQueryParams } from './url_from_query_params'; import { useAlertListSelector } from './hooks/use_alerts_selector'; import * as selectors from '../../store/alerts/selectors'; import { EndpointPluginServices } from '../../../../plugin'; +import { clone } from '../../models/index_pattern'; export const AlertIndexSearchBar = memo(() => { const history = useHistory(); const queryParams = useAlertListSelector(selectors.uiQueryParams); const searchBarIndexPatterns = useAlertListSelector(selectors.searchBarIndexPatterns); + + // Deeply clone the search bar index patterns as the receiving component may mutate them + const clonedSearchBarIndexPatterns = useMemo( + () => searchBarIndexPatterns.map(pattern => clone(pattern)), + [searchBarIndexPatterns] + ); const searchBarQuery = useAlertListSelector(selectors.searchBarQuery); const searchBarDateRange = useAlertListSelector(selectors.searchBarDateRange); const searchBarFilters = useAlertListSelector(selectors.searchBarFilters); @@ -68,7 +75,7 @@ export const AlertIndexSearchBar = memo(() => { dataTestSubj="alertsSearchBar" appName="endpoint" isLoading={false} - indexPatterns={searchBarIndexPatterns} + indexPatterns={clonedSearchBarIndexPatterns} query={searchBarQuery} dateRangeFrom={searchBarDateRange.from} dateRangeTo={searchBarDateRange.to} diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/app_root.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/app_root.tsx new file mode 100644 index 0000000000000..f9634c63deefb --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/app_root.tsx @@ -0,0 +1,59 @@ +/* + * 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 * as React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Route, Switch } from 'react-router-dom'; +import { Store } from 'redux'; +import { AlertIndex } from './alerts'; +import { HostList } from './hosts'; +import { PolicyList } from './policy'; +import { PolicyDetails } from './policy'; +import { HeaderNavigation } from './components/header_navigation'; +import { AppRootProvider } from './app_root_provider'; +import { Setup } from './setup'; +import { EndpointPluginStartDependencies } from '../../../plugin'; +import { ScopedHistory, CoreStart } from '../../../../../../../src/core/public'; + +interface RouterProps { + history: ScopedHistory; + store: Store; + coreStart: CoreStart; + depsStart: EndpointPluginStartDependencies; +} + +/** + * The root of the Endpoint application react view. + */ +export const AppRoot: React.FunctionComponent<RouterProps> = React.memo( + ({ history, store, coreStart, depsStart }) => { + return ( + <AppRootProvider store={store} history={history} coreStart={coreStart} depsStart={depsStart}> + <Setup ingestManager={depsStart.ingestManager} notifications={coreStart.notifications} /> + <HeaderNavigation /> + <Switch> + <Route + exact + path="/" + render={() => ( + <h1 data-test-subj="welcomeTitle"> + <FormattedMessage id="xpack.endpoint.welcomeTitle" defaultMessage="Hello World" /> + </h1> + )} + /> + <Route path="/hosts" component={HostList} /> + <Route path="/alerts" component={AlertIndex} /> + <Route path="/policy" exact component={PolicyList} /> + <Route path="/policy/:id" exact component={PolicyDetails} /> + <Route + render={() => ( + <FormattedMessage id="xpack.endpoint.notFound" defaultMessage="Page Not Found" /> + )} + /> + </Switch> + </AppRootProvider> + ); + } +); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/components/__snapshots__/link_to_app.test.tsx.snap b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/__snapshots__/link_to_app.test.tsx.snap similarity index 100% rename from x-pack/plugins/endpoint/public/applications/endpoint/components/__snapshots__/link_to_app.test.tsx.snap rename to x-pack/plugins/endpoint/public/applications/endpoint/view/components/__snapshots__/link_to_app.test.tsx.snap diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/components/__snapshots__/page_view.test.tsx.snap b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/__snapshots__/page_view.test.tsx.snap similarity index 99% rename from x-pack/plugins/endpoint/public/applications/endpoint/components/__snapshots__/page_view.test.tsx.snap rename to x-pack/plugins/endpoint/public/applications/endpoint/view/components/__snapshots__/page_view.test.tsx.snap index dfc69fc46ebdc..36b602a1e6784 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/components/__snapshots__/page_view.test.tsx.snap +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/__snapshots__/page_view.test.tsx.snap @@ -7,6 +7,7 @@ exports[`PageView component should display body header custom element 1`] = ` .c0.endpoint--isListView .endpoint-header { padding: 24px; + margin-bottom: 0; } .c0.endpoint--isListView .endpoint-page-content { @@ -97,6 +98,7 @@ exports[`PageView component should display body header wrapped in EuiTitle 1`] = .c0.endpoint--isListView .endpoint-header { padding: 24px; + margin-bottom: 0; } .c0.endpoint--isListView .endpoint-page-content { @@ -190,6 +192,7 @@ exports[`PageView component should display header left and right 1`] = ` .c0.endpoint--isListView .endpoint-header { padding: 24px; + margin-bottom: 0; } .c0.endpoint--isListView .endpoint-page-content { @@ -298,6 +301,7 @@ exports[`PageView component should display only body if not header props used 1` .c0.endpoint--isListView .endpoint-header { padding: 24px; + margin-bottom: 0; } .c0.endpoint--isListView .endpoint-page-content { @@ -365,6 +369,7 @@ exports[`PageView component should display only header left 1`] = ` .c0.endpoint--isListView .endpoint-header { padding: 24px; + margin-bottom: 0; } .c0.endpoint--isListView .endpoint-page-content { @@ -462,6 +467,7 @@ exports[`PageView component should display only header right but include an empt .c0.endpoint--isListView .endpoint-header { padding: 24px; + margin-bottom: 0; } .c0.endpoint--isListView .endpoint-page-content { @@ -556,6 +562,7 @@ exports[`PageView component should pass through EuiPage props 1`] = ` .c0.endpoint--isListView .endpoint-header { padding: 24px; + margin-bottom: 0; } .c0.endpoint--isListView .endpoint-page-content { @@ -640,6 +647,7 @@ exports[`PageView component should use custom element for header left and not wr .c0.endpoint--isListView .endpoint-header { padding: 24px; + margin-bottom: 0; } .c0.endpoint--isListView .endpoint-page-content { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/components/header_navigation.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/header_navigation.tsx new file mode 100644 index 0000000000000..6c294d9c86548 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/header_navigation.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { MouseEvent, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiTabs, EuiTab } from '@elastic/eui'; +import { useHistory, useLocation } from 'react-router-dom'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { Immutable } from '../../../../../common/types'; + +interface NavTabs { + name: string; + id: string; + href: string; +} + +const navTabs: Immutable<NavTabs[]> = [ + { + id: 'home', + name: i18n.translate('xpack.endpoint.headerNav.home', { + defaultMessage: 'Home', + }), + href: '/', + }, + { + id: 'hosts', + name: i18n.translate('xpack.endpoint.headerNav.hosts', { + defaultMessage: 'Hosts', + }), + href: '/hosts', + }, + { + id: 'alerts', + name: i18n.translate('xpack.endpoint.headerNav.alerts', { + defaultMessage: 'Alerts', + }), + href: '/alerts', + }, + { + id: 'policies', + name: i18n.translate('xpack.endpoint.headerNav.policies', { + defaultMessage: 'Policies', + }), + href: '/policy', + }, +]; + +export const HeaderNavigation: React.FunctionComponent = React.memo(() => { + const history = useHistory(); + const location = useLocation(); + const { services } = useKibana(); + const BASE_PATH = services.application.getUrlForApp('endpoint'); + + const tabList = useMemo(() => { + return navTabs.map((tab, index) => { + return ( + <EuiTab + data-test-subj={`${tab.id}EndpointTab`} + key={index} + href={`${BASE_PATH}${tab.href}`} + onClick={(event: MouseEvent) => { + event.preventDefault(); + history.push(tab.href); + }} + isSelected={ + tab.href === location.pathname || + (tab.href !== '/' && location.pathname.startsWith(tab.href)) + } + > + {tab.name} + </EuiTab> + ); + }); + }, [BASE_PATH, history, location.pathname]); + + return <EuiTabs>{tabList}</EuiTabs>; +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/components/link_to_app.test.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/link_to_app.test.tsx similarity index 86% rename from x-pack/plugins/endpoint/public/applications/endpoint/components/link_to_app.test.tsx rename to x-pack/plugins/endpoint/public/applications/endpoint/view/components/link_to_app.test.tsx index 902c215434aac..d0a8f9690dafb 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/components/link_to_app.test.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/link_to_app.test.tsx @@ -7,9 +7,14 @@ import React from 'react'; import { mount } from 'enzyme'; import { LinkToApp } from './link_to_app'; -import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; -import { coreMock } from '../../../../../../../src/core/public/mocks'; import { CoreStart } from 'kibana/public'; +import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; + +type LinkToAppOnClickMock<Return = void> = jest.Mock< + Return, + [React.MouseEvent<HTMLAnchorElement, MouseEvent>] +>; describe('LinkToApp component', () => { let fakeCoreStart: jest.Mocked<CoreStart>; @@ -38,7 +43,8 @@ describe('LinkToApp component', () => { ).toMatchSnapshot(); }); it('should support onClick prop', () => { - const spyOnClickHandler = jest.fn(); + // Take `_event` (even though it is not used) so that `jest.fn` will have a type that expects to be called with an event + const spyOnClickHandler: LinkToAppOnClickMock = jest.fn(_event => {}); const renderResult = render( <LinkToApp appId="ingestManager" href="/app/ingest" onClick={spyOnClickHandler}> link @@ -91,7 +97,8 @@ describe('LinkToApp component', () => { }); }); it('should still preventDefault if onClick callback throws', () => { - const spyOnClickHandler = jest.fn(ev => { + // Take `_event` (even though it is not used) so that `jest.fn` will have a type that expects to be called with an event + const spyOnClickHandler: LinkToAppOnClickMock<never> = jest.fn(_event => { throw new Error('test'); }); const renderResult = render( @@ -104,7 +111,7 @@ describe('LinkToApp component', () => { expect(clickEventArg.isDefaultPrevented()).toBe(true); }); it('should not navigate if onClick callback prevents defalut', () => { - const spyOnClickHandler = jest.fn(ev => { + const spyOnClickHandler: LinkToAppOnClickMock = jest.fn(ev => { ev.preventDefault(); }); const renderResult = render( diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/components/link_to_app.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/link_to_app.tsx similarity index 96% rename from x-pack/plugins/endpoint/public/applications/endpoint/components/link_to_app.tsx rename to x-pack/plugins/endpoint/public/applications/endpoint/view/components/link_to_app.tsx index 858dac864b58a..6a3cf5e78f4bf 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/components/link_to_app.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/link_to_app.tsx @@ -9,7 +9,7 @@ import { EuiLink } from '@elastic/eui'; import { EuiLinkProps } from '@elastic/eui'; import { useNavigateToAppEventHandler } from '../hooks/use_navigate_to_app_event_handler'; -export type LinkToAppProps = EuiLinkProps & { +type LinkToAppProps = EuiLinkProps & { /** the app id - normally the value of the `id` in that plugin's `kibana.json` */ appId: string; /** Any app specific path (route) */ diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/components/page_view.test.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/page_view.test.tsx similarity index 96% rename from x-pack/plugins/endpoint/public/applications/endpoint/components/page_view.test.tsx rename to x-pack/plugins/endpoint/public/applications/endpoint/view/components/page_view.test.tsx index 0d4d26737d355..4007477a088fa 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/components/page_view.test.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/page_view.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { PageView } from './page_view'; -import { EuiThemeProvider } from '../../../../../../legacy/common/eui_styled_components'; +import { EuiThemeProvider } from '../../../../../../../legacy/common/eui_styled_components'; describe('PageView component', () => { const render = (ui: Parameters<typeof mount>[0]) => diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/components/page_view.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/page_view.tsx similarity index 99% rename from x-pack/plugins/endpoint/public/applications/endpoint/components/page_view.tsx rename to x-pack/plugins/endpoint/public/applications/endpoint/view/components/page_view.tsx index 561d671e18e07..6da352b68f890 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/components/page_view.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/page_view.tsx @@ -25,6 +25,7 @@ const StyledEuiPage = styled(EuiPage)` .endpoint-header { padding: ${props => props.theme.eui.euiSizeL}; + margin-bottom: 0; } .endpoint-page-content { border-left: none; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/hooks/use_navigate_to_app_event_handler.ts b/x-pack/plugins/endpoint/public/applications/endpoint/view/hooks/use_navigate_to_app_event_handler.ts similarity index 96% rename from x-pack/plugins/endpoint/public/applications/endpoint/hooks/use_navigate_to_app_event_handler.ts rename to x-pack/plugins/endpoint/public/applications/endpoint/view/hooks/use_navigate_to_app_event_handler.ts index 5fbfa5e0e58a8..ec9a8691c481e 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/hooks/use_navigate_to_app_event_handler.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hooks/use_navigate_to_app_event_handler.ts @@ -6,7 +6,7 @@ import { MouseEventHandler, useCallback } from 'react'; import { ApplicationStart } from 'kibana/public'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; type NavigateToAppHandlerProps = Parameters<ApplicationStart['navigateToApp']>; type EventHandlerCallback = MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details.tsx deleted file mode 100644 index 90829f7ad4cbe..0000000000000 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details.tsx +++ /dev/null @@ -1,200 +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 React, { useCallback, useMemo, memo, useEffect } from 'react'; -import { - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiTitle, - EuiDescriptionList, - EuiLoadingContent, - EuiHorizontalRule, - EuiHealth, - EuiSpacer, - EuiListGroup, - EuiListGroupItem, -} from '@elastic/eui'; -import { useHistory } from 'react-router-dom'; -import styled from 'styled-components'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { HostMetadata } from '../../../../../common/types'; -import { useHostListSelector } from './hooks'; -import { urlFromQueryParams } from './url_from_query_params'; -import { FormattedDateAndTime } from '../formatted_date_time'; -import { uiQueryParams, detailsData, detailsError } from './../../store/hosts/selectors'; -import { LinkToApp } from '../../components/link_to_app'; - -const HostIds = styled(EuiListGroupItem)` - margin-top: 0; - .euiListGroupItem__text { - padding: 0; - } -`; - -const HostDetails = memo(({ details }: { details: HostMetadata }) => { - const { appId, appPath, url } = useHostLogsUrl(details.host.id); - const detailsResultsUpper = useMemo(() => { - return [ - { - title: i18n.translate('xpack.endpoint.host.details.os', { - defaultMessage: 'OS', - }), - description: details.host.os.full, - }, - { - title: i18n.translate('xpack.endpoint.host.details.lastSeen', { - defaultMessage: 'Last Seen', - }), - description: <FormattedDateAndTime date={new Date(details['@timestamp'])} />, - }, - { - title: i18n.translate('xpack.endpoint.host.details.alerts', { - defaultMessage: 'Alerts', - }), - description: '0', - }, - ]; - }, [details]); - - const detailsResultsLower = useMemo(() => { - return [ - { - title: i18n.translate('xpack.endpoint.host.details.policy', { - defaultMessage: 'Policy', - }), - description: details.endpoint.policy.id, - }, - { - title: i18n.translate('xpack.endpoint.host.details.policyStatus', { - defaultMessage: 'Policy Status', - }), - description: <EuiHealth color="success">active</EuiHealth>, - }, - { - title: i18n.translate('xpack.endpoint.host.details.ipAddress', { - defaultMessage: 'IP Address', - }), - description: ( - <EuiListGroup flush> - {details.host.ip.map((ip: string, index: number) => ( - <HostIds key={index} label={ip} /> - ))} - </EuiListGroup> - ), - }, - { - title: i18n.translate('xpack.endpoint.host.details.hostname', { - defaultMessage: 'Hostname', - }), - description: details.host.hostname, - }, - { - title: i18n.translate('xpack.endpoint.host.details.sensorVersion', { - defaultMessage: 'Sensor Version', - }), - description: details.agent.version, - }, - ]; - }, [details.agent.version, details.endpoint.policy.id, details.host.hostname, details.host.ip]); - return ( - <> - <EuiDescriptionList - type="column" - listItems={detailsResultsUpper} - data-test-subj="hostDetailsUpperList" - /> - <EuiHorizontalRule margin="s" /> - <EuiDescriptionList - type="column" - listItems={detailsResultsLower} - data-test-subj="hostDetailsLowerList" - /> - <EuiHorizontalRule margin="s" /> - <p> - <LinkToApp - appId={appId} - appPath={appPath} - href={url} - data-test-subj="hostDetailsLinkToLogs" - > - <FormattedMessage - id="xpack.endpoint.host.details.linkToLogsTitle" - defaultMessage="Endpoint Logs" - /> - </LinkToApp> - </p> - </> - ); -}); - -export const HostDetailsFlyout = () => { - const history = useHistory(); - const { notifications } = useKibana(); - const queryParams = useHostListSelector(uiQueryParams); - const { selected_host: selectedHost, ...queryParamsWithoutSelectedHost } = queryParams; - const details = useHostListSelector(detailsData); - const error = useHostListSelector(detailsError); - - const handleFlyoutClose = useCallback(() => { - history.push(urlFromQueryParams(queryParamsWithoutSelectedHost)); - }, [history, queryParamsWithoutSelectedHost]); - - useEffect(() => { - if (error !== undefined) { - notifications.toasts.danger({ - title: ( - <FormattedMessage - id="xpack.endpoint.host.details.errorTitle" - defaultMessage="Could not find host" - /> - ), - body: ( - <FormattedMessage - id="xpack.endpoint.host.details.errorBody" - defaultMessage="Please exit the flyout and select an available host." - /> - ), - toastLifeTimeMs: 10000, - }); - } - }, [error, notifications.toasts]); - - return ( - <EuiFlyout onClose={handleFlyoutClose} data-test-subj="hostDetailsFlyout"> - <EuiFlyoutHeader hasBorder> - <EuiTitle size="s"> - <h2 data-test-subj="hostDetailsFlyoutTitle"> - {details === undefined ? <EuiLoadingContent lines={1} /> : details.host.hostname} - </h2> - </EuiTitle> - </EuiFlyoutHeader> - <EuiFlyoutBody> - {details === undefined ? ( - <> - <EuiLoadingContent lines={3} /> <EuiSpacer size="l" /> <EuiLoadingContent lines={3} /> - </> - ) : ( - <HostDetails details={details} /> - )} - </EuiFlyoutBody> - </EuiFlyout> - ); -}; - -const useHostLogsUrl = (hostId: string): { url: string; appId: string; appPath: string } => { - const { services } = useKibana(); - return useMemo(() => { - const appPath = `/stream?logFilter=(expression:'host.id:${hostId}',kind:kuery)`; - return { - url: `${services.application.getUrlForApp('logs')}${appPath}`, - appId: 'logs', - appPath, - }; - }, [hostId, services.application]); -}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/components/flyout_sub_header.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/components/flyout_sub_header.tsx new file mode 100644 index 0000000000000..26f2203790a9e --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/components/flyout_sub_header.tsx @@ -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 React, { memo } from 'react'; +import { EuiFlyoutHeader, CommonProps, EuiButtonEmpty } from '@elastic/eui'; +import styled from 'styled-components'; + +export type FlyoutSubHeaderProps = CommonProps & { + children: React.ReactNode; + backButton?: { + title: string; + onClick: (event: React.MouseEvent) => void; + href?: string; + }; +}; + +const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)` + padding: ${props => props.theme.eui.paddingSizes.s}; + + &.hasButtons { + .buttons { + padding-bottom: ${props => props.theme.eui.paddingSizes.s}; + } + + .back-button-content { + padding-left: 0; + &-text { + margin-left: 0; + } + } + } + + .flyout-content { + padding-left: ${props => props.theme.eui.paddingSizes.m}; + } +`; + +const BUTTON_CONTENT_PROPS = Object.freeze({ className: 'back-button-content' }); +const BUTTON_TEXT_PROPS = Object.freeze({ className: 'back-button-content-text' }); + +/** + * A Eui Flyout Header component that has its styles adjusted to display a panel sub-header. + * Component also provides a way to display a "back" button above the header title. + */ +export const FlyoutSubHeader = memo<FlyoutSubHeaderProps>( + ({ children, backButton, ...otherProps }) => { + return ( + <StyledEuiFlyoutHeader hasBorder {...otherProps} className={backButton && `hasButtons`}> + {backButton && ( + <div className="buttons"> + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + <EuiButtonEmpty + data-test-subj="flyoutSubHeaderBackButton" + iconType="arrowLeft" + contentProps={BUTTON_CONTENT_PROPS} + textProps={BUTTON_TEXT_PROPS} + size="xs" + href={backButton?.href ?? ''} + onClick={backButton?.onClick} + > + {backButton?.title} + </EuiButtonEmpty> + </div> + )} + <div className={'flyout-content'}>{children}</div> + </StyledEuiFlyoutHeader> + ); + } +); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx new file mode 100644 index 0000000000000..32c69426b03f3 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import styled from 'styled-components'; +import { + EuiDescriptionList, + EuiHealth, + EuiHorizontalRule, + EuiLink, + EuiListGroup, + EuiListGroupItem, +} from '@elastic/eui'; +import React, { memo, useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { useHistory } from 'react-router-dom'; +import { HostMetadata } from '../../../../../../common/types'; +import { FormattedDateAndTime } from '../../formatted_date_time'; +import { LinkToApp } from '../../components/link_to_app'; +import { useHostListSelector, useHostLogsUrl } from '../hooks'; +import { urlFromQueryParams } from '../url_from_query_params'; +import { uiQueryParams } from '../../../store/hosts/selectors'; + +const HostIds = styled(EuiListGroupItem)` + margin-top: 0; + .euiListGroupItem__text { + padding: 0; + } +`; + +export const HostDetails = memo(({ details }: { details: HostMetadata }) => { + const { appId, appPath, url } = useHostLogsUrl(details.host.id); + const queryParams = useHostListSelector(uiQueryParams); + const history = useHistory(); + const detailsResultsUpper = useMemo(() => { + return [ + { + title: i18n.translate('xpack.endpoint.host.details.os', { + defaultMessage: 'OS', + }), + description: details.host.os.full, + }, + { + title: i18n.translate('xpack.endpoint.host.details.lastSeen', { + defaultMessage: 'Last Seen', + }), + description: <FormattedDateAndTime date={new Date(details['@timestamp'])} />, + }, + { + title: i18n.translate('xpack.endpoint.host.details.alerts', { + defaultMessage: 'Alerts', + }), + description: '0', + }, + ]; + }, [details]); + + const policyResponseUri = useMemo(() => { + return urlFromQueryParams({ + ...queryParams, + selected_host: details.host.id, + show: 'policy_response', + }); + }, [details.host.id, queryParams]); + + const detailsResultsLower = useMemo(() => { + return [ + { + title: i18n.translate('xpack.endpoint.host.details.policy', { + defaultMessage: 'Policy', + }), + description: details.endpoint.policy.id, + }, + { + title: i18n.translate('xpack.endpoint.host.details.policyStatus', { + defaultMessage: 'Policy Status', + }), + description: ( + <EuiHealth color="success"> + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + <EuiLink + data-test-subj="policyStatusValue" + href={'?' + policyResponseUri.search} + onClick={(ev: React.MouseEvent) => { + ev.preventDefault(); + history.push(policyResponseUri); + }} + > + <FormattedMessage + id="xpack.endpoint.host.details.policyStatus.success" + defaultMessage="Successful" + /> + </EuiLink> + </EuiHealth> + ), + }, + { + title: i18n.translate('xpack.endpoint.host.details.ipAddress', { + defaultMessage: 'IP Address', + }), + description: ( + <EuiListGroup flush> + {details.host.ip.map((ip: string, index: number) => ( + <HostIds key={index} label={ip} /> + ))} + </EuiListGroup> + ), + }, + { + title: i18n.translate('xpack.endpoint.host.details.hostname', { + defaultMessage: 'Hostname', + }), + description: details.host.hostname, + }, + { + title: i18n.translate('xpack.endpoint.host.details.sensorVersion', { + defaultMessage: 'Sensor Version', + }), + description: details.agent.version, + }, + ]; + }, [ + details.agent.version, + details.endpoint.policy.id, + details.host.hostname, + details.host.ip, + history, + policyResponseUri, + ]); + + return ( + <> + <EuiDescriptionList + type="column" + listItems={detailsResultsUpper} + data-test-subj="hostDetailsUpperList" + /> + <EuiHorizontalRule margin="s" /> + <EuiDescriptionList + type="column" + listItems={detailsResultsLower} + data-test-subj="hostDetailsLowerList" + /> + <EuiHorizontalRule margin="s" /> + <p> + <LinkToApp + appId={appId} + appPath={appPath} + href={url} + data-test-subj="hostDetailsLinkToLogs" + > + <FormattedMessage + id="xpack.endpoint.host.details.linkToLogsTitle" + defaultMessage="Endpoint Logs" + /> + </LinkToApp> + </p> + </> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/index.tsx new file mode 100644 index 0000000000000..a41d4a968f177 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/index.tsx @@ -0,0 +1,134 @@ +/* + * 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, { useCallback, useEffect, memo, useMemo } from 'react'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiLoadingContent, + EuiSpacer, +} from '@elastic/eui'; +import { useHistory } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; +import { useHostListSelector } from '../hooks'; +import { urlFromQueryParams } from '../url_from_query_params'; +import { uiQueryParams, detailsData, detailsError, showView } from '../../../store/hosts/selectors'; +import { HostDetails } from './host_details'; +import { PolicyResponse } from './policy_response'; +import { HostMetadata } from '../../../../../../common/types'; +import { FlyoutSubHeader, FlyoutSubHeaderProps } from './components/flyout_sub_header'; + +export const HostDetailsFlyout = memo(() => { + const history = useHistory(); + const { notifications } = useKibana(); + const queryParams = useHostListSelector(uiQueryParams); + const { selected_host: selectedHost, ...queryParamsWithoutSelectedHost } = queryParams; + const details = useHostListSelector(detailsData); + const error = useHostListSelector(detailsError); + const show = useHostListSelector(showView); + + const handleFlyoutClose = useCallback(() => { + history.push(urlFromQueryParams(queryParamsWithoutSelectedHost)); + }, [history, queryParamsWithoutSelectedHost]); + + useEffect(() => { + if (error !== undefined) { + notifications.toasts.danger({ + title: ( + <FormattedMessage + id="xpack.endpoint.host.details.errorTitle" + defaultMessage="Could not find host" + /> + ), + body: ( + <FormattedMessage + id="xpack.endpoint.host.details.errorBody" + defaultMessage="Please exit the flyout and select an available host." + /> + ), + toastLifeTimeMs: 10000, + }); + } + }, [error, notifications.toasts]); + + return ( + <EuiFlyout onClose={handleFlyoutClose} data-test-subj="hostDetailsFlyout" size="s"> + <EuiFlyoutHeader hasBorder> + <EuiTitle size="s"> + <h2 data-test-subj="hostDetailsFlyoutTitle"> + {details === undefined ? <EuiLoadingContent lines={1} /> : details.host.hostname} + </h2> + </EuiTitle> + </EuiFlyoutHeader> + {details === undefined ? ( + <> + <EuiFlyoutBody> + <EuiLoadingContent lines={3} /> <EuiSpacer size="l" /> <EuiLoadingContent lines={3} /> + </EuiFlyoutBody> + </> + ) : ( + <> + {show === 'details' && ( + <> + <EuiFlyoutBody data-test-subj="hostDetailsFlyoutBody"> + <HostDetails details={details} /> + </EuiFlyoutBody> + </> + )} + {show === 'policy_response' && <PolicyResponseFlyoutPanel hostMeta={details} />} + </> + )} + </EuiFlyout> + ); +}); + +const PolicyResponseFlyoutPanel = memo<{ + hostMeta: HostMetadata; +}>(({ hostMeta }) => { + const history = useHistory(); + const { show, ...queryParams } = useHostListSelector(uiQueryParams); + const backButtonProp = useMemo((): FlyoutSubHeaderProps['backButton'] => { + const detailsUri = urlFromQueryParams({ + ...queryParams, + selected_host: hostMeta.host.id, + }); + return { + title: i18n.translate('xpack.endpoint.host.policyResponse.backLinkTitle', { + defaultMessage: 'Endpoint Details', + }), + href: '?' + detailsUri.search, + onClick: ev => { + ev.preventDefault(); + history.push(detailsUri); + }, + }; + }, [history, hostMeta.host.id, queryParams]); + + return ( + <> + <FlyoutSubHeader + backButton={backButtonProp} + data-test-subj="hostDetailsPolicyResponseFlyoutHeader" + > + <EuiTitle size="xxs" data-test-subj="hostDetailsPolicyResponseFlyoutTitle"> + <h3> + <FormattedMessage + id="xpack.endpoint.host.policyResponse.title" + defaultMessage="Policy Response" + /> + </h3> + </EuiTitle> + </FlyoutSubHeader> + <EuiFlyoutBody data-test-subj="hostDetailsPolicyResponseFlyoutBody"> + <PolicyResponse /> + </EuiFlyoutBody> + </> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response.tsx new file mode 100644 index 0000000000000..eacb6a52d3184 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response.tsx @@ -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 React, { memo } from 'react'; + +export const PolicyResponse = memo(() => { + return <div>Policy Status to be displayed here soon.</div>; +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/hooks.ts b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/hooks.ts index 99a0073f46c74..7eb51f3a7b294 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/hooks.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/hooks.ts @@ -5,10 +5,28 @@ */ import { useSelector } from 'react-redux'; +import { useMemo } from 'react'; import { GlobalState, HostListState } from '../../types'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; export function useHostListSelector<TSelected>(selector: (state: HostListState) => TSelected) { return useSelector(function(state: GlobalState) { return selector(state.hostList); }); } + +/** + * Returns an object that contains Kibana Logs app and URL information for a given host id + * @param hostId + */ +export const useHostLogsUrl = (hostId: string): { url: string; appId: string; appPath: string } => { + const { services } = useKibana(); + return useMemo(() => { + const appPath = `/stream?logFilter=(expression:'host.id:${hostId}',kind:kuery)`; + return { + url: `${services.application.getUrlForApp('logs')}${appPath}`, + appId: 'logs', + appPath, + }; + }, [hostId, services.application]); +}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx index c3ff41268e3db..88416b577ed0c 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx @@ -21,10 +21,11 @@ describe('when on the hosts page', () => { let history: AppContextTestRender['history']; let store: AppContextTestRender['store']; let coreStart: AppContextTestRender['coreStart']; + let middlewareSpy: AppContextTestRender['middlewareSpy']; beforeEach(async () => { const mockedContext = createAppRootMockRenderer(); - ({ history, store, coreStart } = mockedContext); + ({ history, store, coreStart, middlewareSpy } = mockedContext); render = () => mockedContext.render(<HostList />); }); @@ -132,6 +133,25 @@ describe('when on the hosts page', () => { expect(flyout).not.toBeNull(); }); }); + it('should display policy status value as a link', async () => { + const renderResult = render(); + const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); + expect(policyStatusLink).not.toBeNull(); + expect(policyStatusLink.textContent).toEqual('Successful'); + expect(policyStatusLink.getAttribute('href')).toEqual( + '?selected_host=1&show=policy_response' + ); + }); + it('should update the URL when policy status link is clicked', async () => { + const renderResult = render(); + const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); + const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl'); + reactTestingLibrary.act(() => { + fireEvent.click(policyStatusLink); + }); + const changedUrlAction = await userChangedUrlChecker; + expect(changedUrlAction.payload.search).toEqual('?selected_host=1&show=policy_response'); + }); it('should include the link to logs', async () => { const renderResult = render(); const linkToLogs = await renderResult.findByTestId('hostDetailsLinkToLogs'); @@ -151,9 +171,51 @@ describe('when on the hosts page', () => { }); it('should navigate to logs without full page refresh', async () => { - // FIXME: this is not working :( expect(coreStart.application.navigateToApp.mock.calls).toHaveLength(1); }); }); + describe('when showing host Policy Response', () => { + let renderResult: ReturnType<typeof render>; + beforeEach(async () => { + renderResult = render(); + const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); + const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl'); + reactTestingLibrary.act(() => { + fireEvent.click(policyStatusLink); + }); + await userChangedUrlChecker; + }); + it('should hide the host details panel', async () => { + const hostDetailsFlyout = await renderResult.queryByTestId('hostDetailsFlyoutBody'); + expect(hostDetailsFlyout).toBeNull(); + }); + it('should display policy response sub-panel', async () => { + expect( + await renderResult.findByTestId('hostDetailsPolicyResponseFlyoutHeader') + ).not.toBeNull(); + expect( + await renderResult.findByTestId('hostDetailsPolicyResponseFlyoutBody') + ).not.toBeNull(); + }); + it('should include the sub-panel title', async () => { + expect( + (await renderResult.findByTestId('hostDetailsPolicyResponseFlyoutTitle')).textContent + ).toBe('Policy Response'); + }); + it('should include the back to details link', async () => { + const subHeaderBackLink = await renderResult.findByTestId('flyoutSubHeaderBackButton'); + expect(subHeaderBackLink.textContent).toBe('Endpoint Details'); + expect(subHeaderBackLink.getAttribute('href')).toBe('?selected_host=1'); + }); + it('should update URL when back to details link is clicked', async () => { + const subHeaderBackLink = await renderResult.findByTestId('flyoutSubHeaderBackButton'); + const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl'); + reactTestingLibrary.act(() => { + fireEvent.click(subHeaderBackLink); + }); + const changedUrlAction = await userChangedUrlChecker; + expect(changedUrlAction.payload.search).toEqual('?selected_host=1'); + }); + }); }); }); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.tsx index 94625b8c66191..1d81d6e8a16db 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.tsx @@ -23,12 +23,14 @@ import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; import { FormattedMessage } from '@kbn/i18n/react'; import { createStructuredSelector } from 'reselect'; +import { EuiBasicTableColumn } from '@elastic/eui'; import { HostDetailsFlyout } from './details'; import * as selectors from '../../store/hosts/selectors'; import { HostAction } from '../../store/hosts/action'; import { useHostListSelector } from './hooks'; import { CreateStructuredSelector } from '../../types'; import { urlFromQueryParams } from './url_from_query_params'; +import { HostMetadata, Immutable } from '../../../../../common/types'; const selector = (createStructuredSelector as CreateStructuredSelector)(selectors); export const HostList = () => { @@ -65,7 +67,7 @@ export const HostList = () => { [dispatch] ); - const columns = useMemo(() => { + const columns: Array<EuiBasicTableColumn<Immutable<HostMetadata>>> = useMemo(() => { return [ { field: '', @@ -174,7 +176,7 @@ export const HostList = () => { <EuiHorizontalRule margin="xs" /> <EuiBasicTable data-test-subj="hostListTable" - items={listData} + items={useMemo(() => [...listData], [listData])} columns={columns} loading={isLoading} pagination={paginationSetup} diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_details.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_details.tsx index bc56e5e6f6329..076de7b57b44b 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_details.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_details.tsx @@ -29,12 +29,12 @@ import { isLoading, apiError, } from '../../store/policy_details/selectors'; -import { WindowsEventing } from './policy_forms/eventing/windows'; -import { PageView, PageViewHeaderTitle } from '../../components/page_view'; +import { PageView, PageViewHeaderTitle } from '../components/page_view'; import { AppAction } from '../../types'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { AgentsSummary } from './agents_summary'; import { VerticalDivider } from './vertical_divider'; +import { WindowsEvents, MacEvents, LinuxEvents } from './policy_forms/events'; import { MalwareProtections } from './policy_forms/protections/malware'; export const PolicyDetails = React.memo(() => { @@ -82,7 +82,7 @@ export const PolicyDetails = React.memo(() => { } }, [notifications.toasts, policyItem, policyName, policyUpdateStatus]); - const handleBackToListOnClick = useCallback( + const handleBackToListOnClick: React.MouseEventHandler = useCallback( ev => { ev.preventDefault(); history.push(`/policy`); @@ -161,7 +161,6 @@ export const PolicyDetails = React.memo(() => { fill={true} iconType="save" data-test-subj="policyDetailsSaveButton" - // FIXME: need to disable if User has no write permissions to ingest - see: https://github.com/elastic/endpoint-app-team/issues/296 onClick={handleSaveOnClick} isLoading={isPolicyLoading} > @@ -206,7 +205,11 @@ export const PolicyDetails = React.memo(() => { </h4> </EuiText> <EuiSpacer size="xs" /> - <WindowsEventing /> + <WindowsEvents /> + <EuiSpacer size="l" /> + <MacEvents /> + <EuiSpacer size="l" /> + <LinuxEvents /> </PageView> </> ); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/config_form.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/config_form.tsx index 8b6c32c3277ed..341086c7cf75c 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/config_form.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/config_form.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiCard, EuiFlexGroup, @@ -25,14 +25,27 @@ const PolicyDetailCard = styled.div` } `; export const ConfigForm: React.FC<{ + /** + * A subtitle for this component. + **/ type: string; - supportedOss: string[]; + /** + * Types of supported operating systems. + */ + supportedOss: React.ReactNode; children: React.ReactNode; - id: string; - /** Takes a react component to be put on the right corner of the card */ + /** + * A description for the component. + */ + description: string; + /** + * The `data-test-subj` attribute to append to a certain child element. + */ + dataTestSubj: string; + /** React Node to be put on the right corner of the card */ rightCorner: React.ReactNode; -}> = React.memo(({ type, supportedOss, children, id, rightCorner }) => { - const typeTitle = () => { +}> = React.memo(({ type, supportedOss, children, dataTestSubj, rightCorner, description }) => { + const typeTitle = useMemo(() => { return ( <EuiFlexGroup direction="row" gutterSize="none" alignItems="center"> <EuiFlexGroup direction="column" gutterSize="none"> @@ -59,28 +72,25 @@ export const ConfigForm: React.FC<{ </EuiTitle> </EuiFlexItem> <EuiFlexItem className="policyDetailTitleFlexItem"> - <EuiText>{supportedOss.join(', ')}</EuiText> + <EuiText>{supportedOss}</EuiText> </EuiFlexItem> </EuiFlexGroup> <EuiFlexItem grow={false}>{rightCorner}</EuiFlexItem> </EuiFlexGroup> ); - }; + }, [rightCorner, supportedOss, type]); return ( <PolicyDetailCard> <EuiCard - data-test-subj={id} + description={description} + data-test-subj={dataTestSubj} textAlign="left" - title={typeTitle()} - description="" - children={ - <> - <EuiHorizontalRule margin="m" /> - {children} - </> - } - /> + title={typeTitle} + > + <EuiHorizontalRule margin="m" /> + {children} + </EuiCard> </PolicyDetailCard> ); }); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/eventing/checkbox.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/eventing/checkbox.tsx deleted file mode 100644 index 8b7fb89ed1646..0000000000000 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/eventing/checkbox.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useCallback } from 'react'; -import { EuiCheckbox } from '@elastic/eui'; -import { useDispatch } from 'react-redux'; -import { usePolicyDetailsSelector } from '../../policy_hooks'; -import { policyConfig, windowsEventing } from '../../../../store/policy_details/selectors'; -import { PolicyDetailsAction } from '../../../../store/policy_details'; -import { OS, EventingFields } from '../../../../types'; -import { clone } from '../../../../models/policy_details_config'; - -export const EventingCheckbox: React.FC<{ - id: string; - name: string; - os: OS; - protectionField: EventingFields; -}> = React.memo(({ id, name, os, protectionField }) => { - const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); - const eventing = usePolicyDetailsSelector(windowsEventing); - const dispatch = useDispatch<(action: PolicyDetailsAction) => void>(); - - const handleRadioChange = useCallback( - (event: React.ChangeEvent<HTMLInputElement>) => { - if (policyDetailsConfig) { - const newPayload = clone(policyDetailsConfig); - if (os === OS.linux || os === OS.mac) { - newPayload[os].events.process = event.target.checked; - } else { - newPayload[os].events[protectionField] = event.target.checked; - } - - dispatch({ - type: 'userChangedPolicyConfig', - payload: { policyConfig: newPayload }, - }); - } - }, - [dispatch, os, policyDetailsConfig, protectionField] - ); - - return ( - <EuiCheckbox - id={id} - label={name} - checked={eventing && eventing[protectionField]} - onChange={handleRadioChange} - /> - ); -}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/eventing/windows.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/eventing/windows.tsx deleted file mode 100644 index 7bec2c4c742d2..0000000000000 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/eventing/windows.tsx +++ /dev/null @@ -1,96 +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 React, { useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; -import { EventingCheckbox } from './checkbox'; -import { OS, EventingFields } from '../../../../types'; -import { usePolicyDetailsSelector } from '../../policy_hooks'; -import { - selectedWindowsEventing, - totalWindowsEventing, -} from '../../../../store/policy_details/selectors'; -import { ConfigForm } from '../config_form'; - -export const WindowsEventing = React.memo(() => { - const selected = usePolicyDetailsSelector(selectedWindowsEventing); - const total = usePolicyDetailsSelector(totalWindowsEventing); - - const checkboxes = useMemo( - () => [ - { - name: i18n.translate('xpack.endpoint.policyDetailsConfig.eventingProcess', { - defaultMessage: 'Process', - }), - os: OS.windows, - protectionField: EventingFields.process, - }, - { - name: i18n.translate('xpack.endpoint.policyDetailsConfig.eventingNetwork', { - defaultMessage: 'Network', - }), - os: OS.windows, - protectionField: EventingFields.network, - }, - ], - [] - ); - - const renderCheckboxes = () => { - return ( - <> - <EuiTitle size="xxs"> - <h5> - <FormattedMessage - id="xpack.endpoint.policyDetailsConfig.eventingEvents" - defaultMessage="Events" - /> - </h5> - </EuiTitle> - <EuiSpacer size="s" /> - {checkboxes.map((item, index) => { - return ( - <EventingCheckbox - id={`eventing${item.name}`} - name={item.name} - key={index} - os={item.os} - protectionField={item.protectionField} - /> - ); - })} - </> - ); - }; - - const collectionsEnabled = () => { - return ( - <EuiText size="s" color="subdued"> - <FormattedMessage - id="xpack.endpoint.policy.details.eventCollectionsEnabled" - defaultMessage="{selected} / {total} event collections enabled" - values={{ selected, total }} - /> - </EuiText> - ); - }; - - return ( - <ConfigForm - type={i18n.translate('xpack.endpoint.policy.details.eventCollection', { - defaultMessage: 'Event Collection', - })} - supportedOss={[ - i18n.translate('xpack.endpoint.policy.details.windows', { defaultMessage: 'Windows' }), - ]} - id="windowsEventingForm" - rightCorner={collectionsEnabled()} - children={renderCheckboxes()} - /> - ); -}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/events/checkbox.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/events/checkbox.tsx new file mode 100644 index 0000000000000..74322ac8b993b --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/events/checkbox.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 React, { useCallback, useMemo } from 'react'; +import { EuiCheckbox } from '@elastic/eui'; +import { useDispatch } from 'react-redux'; +import { htmlIdGenerator } from '@elastic/eui'; +import { usePolicyDetailsSelector } from '../../policy_hooks'; +import { policyConfig } from '../../../../store/policy_details/selectors'; +import { PolicyDetailsAction } from '../../../../store/policy_details'; +import { UIPolicyConfig } from '../../../../../../../common/types'; + +export const EventsCheckbox = React.memo(function({ + name, + setter, + getter, +}: { + name: string; + setter: (config: UIPolicyConfig, checked: boolean) => UIPolicyConfig; + getter: (config: UIPolicyConfig) => boolean; +}) { + const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); + const selected = getter(policyDetailsConfig); + const dispatch = useDispatch<(action: PolicyDetailsAction) => void>(); + + const handleCheckboxChange = useCallback( + (event: React.ChangeEvent<HTMLInputElement>) => { + if (policyDetailsConfig) { + dispatch({ + type: 'userChangedPolicyConfig', + payload: { policyConfig: setter(policyDetailsConfig, event.target.checked) }, + }); + } + }, + [dispatch, policyDetailsConfig, setter] + ); + + return ( + <EuiCheckbox + id={useMemo(() => htmlIdGenerator()(), [])} + label={name} + checked={selected} + onChange={handleCheckboxChange} + /> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/events/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/events/index.tsx new file mode 100644 index 0000000000000..927456fb671d8 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/events/index.tsx @@ -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. + */ + +export { WindowsEvents } from './windows'; +export { MacEvents } from './mac'; +export { LinuxEvents } from './linux'; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/events/linux.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/events/linux.tsx new file mode 100644 index 0000000000000..c3d6bdba7c852 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/events/linux.tsx @@ -0,0 +1,107 @@ +/* + * 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, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; +import { EventsCheckbox } from './checkbox'; +import { OS } from '../../../../types'; +import { usePolicyDetailsSelector } from '../../policy_hooks'; +import { selectedLinuxEvents, totalLinuxEvents } from '../../../../store/policy_details/selectors'; +import { ConfigForm } from '../config_form'; +import { getIn, setIn } from '../../../../models/policy_details_config'; +import { UIPolicyConfig } from '../../../../../../../common/types'; + +export const LinuxEvents = React.memo(() => { + const selected = usePolicyDetailsSelector(selectedLinuxEvents); + const total = usePolicyDetailsSelector(totalLinuxEvents); + + const checkboxes = useMemo(() => { + const items: Array<{ + name: string; + os: 'linux'; + protectionField: keyof UIPolicyConfig['linux']['events']; + }> = [ + { + name: i18n.translate('xpack.endpoint.policyDetailsConfig.linux.events.file', { + defaultMessage: 'File', + }), + os: OS.linux, + protectionField: 'file', + }, + { + name: i18n.translate('xpack.endpoint.policyDetailsConfig.linux.events.process', { + defaultMessage: 'Process', + }), + os: OS.linux, + protectionField: 'process', + }, + { + name: i18n.translate('xpack.endpoint.policyDetailsConfig.linux.events.network', { + defaultMessage: 'Network', + }), + os: OS.linux, + protectionField: 'network', + }, + ]; + return ( + <> + <EuiTitle size="xxs"> + <h5> + <FormattedMessage + id="xpack.endpoint.policyDetailsConfig.eventingEvents" + defaultMessage="Events" + /> + </h5> + </EuiTitle> + <EuiSpacer size="s" /> + {items.map((item, index) => { + return ( + <EventsCheckbox + name={item.name} + key={index} + setter={(config, checked) => + setIn(config)(item.os)('events')(item.protectionField)(checked) + } + getter={config => getIn(config)(item.os)('events')(item.protectionField)} + /> + ); + })} + </> + ); + }, []); + + const collectionsEnabled = useMemo(() => { + return ( + <EuiText size="s" color="subdued"> + <FormattedMessage + id="xpack.endpoint.policy.details.eventCollectionsEnabled" + defaultMessage="{selected} / {total} event collections enabled" + values={{ selected, total }} + /> + </EuiText> + ); + }, [selected, total]); + + return ( + <ConfigForm + type={i18n.translate('xpack.endpoint.policy.details.eventCollection', { + defaultMessage: 'Event Collection', + })} + description={i18n.translate('xpack.endpoint.policy.details.eventCollectionLabel', { + defaultMessage: 'Event Collection', + })} + supportedOss={i18n.translate('xpack.endpoint.policy.details.linux', { + defaultMessage: 'Linux', + })} + dataTestSubj="linuxEventingForm" + rightCorner={collectionsEnabled} + > + {checkboxes} + </ConfigForm> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/events/mac.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/events/mac.tsx new file mode 100644 index 0000000000000..40b80b9af0f65 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/events/mac.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; +import { EventsCheckbox } from './checkbox'; +import { OS } from '../../../../types'; +import { usePolicyDetailsSelector } from '../../policy_hooks'; +import { selectedMacEvents, totalMacEvents } from '../../../../store/policy_details/selectors'; +import { ConfigForm } from '../config_form'; +import { getIn, setIn } from '../../../../models/policy_details_config'; +import { UIPolicyConfig } from '../../../../../../../common/types'; + +export const MacEvents = React.memo(() => { + const selected = usePolicyDetailsSelector(selectedMacEvents); + const total = usePolicyDetailsSelector(totalMacEvents); + + const checkboxes = useMemo(() => { + const items: Array<{ + name: string; + os: 'mac'; + protectionField: keyof UIPolicyConfig['mac']['events']; + }> = [ + { + name: i18n.translate('xpack.endpoint.policyDetailsConfig.mac.events.file', { + defaultMessage: 'File', + }), + os: OS.mac, + protectionField: 'file', + }, + { + name: i18n.translate('xpack.endpoint.policyDetailsConfig.mac.events.process', { + defaultMessage: 'Process', + }), + os: OS.mac, + protectionField: 'process', + }, + { + name: i18n.translate('xpack.endpoint.policyDetailsConfig.mac.events.network', { + defaultMessage: 'Network', + }), + os: OS.mac, + protectionField: 'network', + }, + ]; + return ( + <> + <EuiTitle size="xxs"> + <h5> + <FormattedMessage + id="xpack.endpoint.policyDetailsConfig.eventingEvents" + defaultMessage="Events" + /> + </h5> + </EuiTitle> + <EuiSpacer size="s" /> + {items.map((item, index) => { + return ( + <EventsCheckbox + name={item.name} + key={index} + setter={(config, checked) => + setIn(config)(item.os)('events')(item.protectionField)(checked) + } + getter={config => getIn(config)(item.os)('events')(item.protectionField)} + /> + ); + })} + </> + ); + }, []); + + const collectionsEnabled = useMemo(() => { + return ( + <EuiText size="s" color="subdued"> + <FormattedMessage + id="xpack.endpoint.policy.details.eventCollectionsEnabled" + defaultMessage="{selected} / {total} event collections enabled" + values={{ selected, total }} + /> + </EuiText> + ); + }, [selected, total]); + + return ( + <ConfigForm + type={i18n.translate('xpack.endpoint.policy.details.eventCollection', { + defaultMessage: 'Event Collection', + })} + description={i18n.translate('xpack.endpoint.policy.details.eventCollectionLabel', { + defaultMessage: 'Event Collection', + })} + supportedOss={i18n.translate('xpack.endpoint.policy.details.mac', { defaultMessage: 'Mac' })} + dataTestSubj="macEventingForm" + rightCorner={collectionsEnabled} + > + {checkboxes} + </ConfigForm> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/events/windows.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/events/windows.tsx new file mode 100644 index 0000000000000..7f946de9614ca --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/events/windows.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; +import { EventsCheckbox } from './checkbox'; +import { OS } from '../../../../types'; +import { usePolicyDetailsSelector } from '../../policy_hooks'; +import { + selectedWindowsEvents, + totalWindowsEvents, +} from '../../../../store/policy_details/selectors'; +import { ConfigForm } from '../config_form'; +import { setIn, getIn } from '../../../../models/policy_details_config'; +import { UIPolicyConfig, ImmutableArray } from '../../../../../../../common/types'; + +export const WindowsEvents = React.memo(() => { + const selected = usePolicyDetailsSelector(selectedWindowsEvents); + const total = usePolicyDetailsSelector(totalWindowsEvents); + + const checkboxes = useMemo(() => { + const items: ImmutableArray<{ + name: string; + os: 'windows'; + protectionField: keyof UIPolicyConfig['windows']['events']; + }> = [ + { + name: i18n.translate('xpack.endpoint.policyDetailsConfig.windows.events.dllDriverLoad', { + defaultMessage: 'DLL and Driver Load', + }), + os: OS.windows, + protectionField: 'dll_and_driver_load', + }, + { + name: i18n.translate('xpack.endpoint.policyDetailsConfig.windows.events.dns', { + defaultMessage: 'DNS', + }), + os: OS.windows, + protectionField: 'dns', + }, + { + name: i18n.translate('xpack.endpoint.policyDetailsConfig.windows.events.file', { + defaultMessage: 'File', + }), + os: OS.windows, + protectionField: 'file', + }, + { + name: i18n.translate('xpack.endpoint.policyDetailsConfig.windows.events.network', { + defaultMessage: 'Network', + }), + os: OS.windows, + protectionField: 'network', + }, + { + name: i18n.translate('xpack.endpoint.policyDetailsConfig.windows.events.process', { + defaultMessage: 'Process', + }), + os: OS.windows, + protectionField: 'process', + }, + { + name: i18n.translate('xpack.endpoint.policyDetailsConfig.windows.events.registry', { + defaultMessage: 'Registry', + }), + os: OS.windows, + protectionField: 'registry', + }, + { + name: i18n.translate('xpack.endpoint.policyDetailsConfig.windows.events.security', { + defaultMessage: 'Security', + }), + os: OS.windows, + protectionField: 'security', + }, + ]; + return ( + <> + <EuiTitle size="xxs"> + <h5> + <FormattedMessage + id="xpack.endpoint.policyDetailsConfig.eventingEvents" + defaultMessage="Events" + /> + </h5> + </EuiTitle> + <EuiSpacer size="s" /> + {items.map((item, index) => { + return ( + <EventsCheckbox + name={item.name} + key={index} + setter={(config, checked) => + setIn(config)(item.os)('events')(item.protectionField)(checked) + } + getter={config => getIn(config)(item.os)('events')(item.protectionField)} + /> + ); + })} + </> + ); + }, []); + + const collectionsEnabled = useMemo(() => { + return ( + <EuiText size="s" color="subdued"> + <FormattedMessage + id="xpack.endpoint.policy.details.eventCollectionsEnabled" + defaultMessage="{selected} / {total} event collections enabled" + values={{ selected, total }} + /> + </EuiText> + ); + }, [selected, total]); + + return ( + <ConfigForm + type={i18n.translate('xpack.endpoint.policy.details.eventCollection', { + defaultMessage: 'Event Collection', + })} + description={i18n.translate('xpack.endpoint.policy.details.windowsLabel', { + defaultMessage: 'Windows', + })} + supportedOss={i18n.translate('xpack.endpoint.policy.details.windows', { + defaultMessage: 'Windows', + })} + dataTestSubj="windowsEventingForm" + rightCorner={collectionsEnabled} + > + {checkboxes} + </ConfigForm> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/protections/malware.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/protections/malware.tsx index 66b22178607b9..14871c71ec038 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/protections/malware.tsx @@ -11,8 +11,8 @@ import { EuiRadio, EuiSwitch, EuiTitle, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { htmlIdGenerator } from '@elastic/eui'; -import { Immutable } from '../../../../../../../common/types'; -import { OS, ProtectionModes, MalwareProtectionOSes } from '../../../../types'; +import { Immutable, ProtectionModes, ImmutableArray } from '../../../../../../../common/types'; +import { OS, MalwareProtectionOSes } from '../../../../types'; import { ConfigForm } from '../config_form'; import { policyConfig } from '../../../../store/policy_details/selectors'; import { usePolicyDetailsSelector } from '../../policy_hooks'; @@ -73,7 +73,7 @@ export const MalwareProtections = React.memo(() => { // currently just taking windows.malware, but both windows.malware and mac.malware should be the same value const selected = policyDetailsConfig && policyDetailsConfig.windows.malware.mode; - const radios: Array<{ + const radios: ImmutableArray<{ id: ProtectionModes; label: string; protection: 'malware'; @@ -123,7 +123,7 @@ export const MalwareProtections = React.memo(() => { [dispatch, policyDetailsConfig] ); - const RadioButtons = () => { + const radioButtons = useMemo(() => { return ( <> <EuiTitle size="xxxs"> @@ -148,9 +148,9 @@ export const MalwareProtections = React.memo(() => { </ProtectionRadioGroup> </> ); - }; + }, [radios]); - const ProtectionSwitch = () => { + const protectionSwitch = useMemo(() => { return ( <EuiSwitch label={i18n.translate('xpack.endpoint.policy.details.malwareProtectionsEnabled', { @@ -163,18 +163,21 @@ export const MalwareProtections = React.memo(() => { onChange={handleSwitchChange} /> ); - }; + }, [handleSwitchChange, selected]); return ( <ConfigForm type={i18n.translate('xpack.endpoint.policy.details.malware', { defaultMessage: 'Malware' })} - supportedOss={[ - i18n.translate('xpack.endpoint.policy.details.windows', { defaultMessage: 'Windows' }), - i18n.translate('xpack.endpoint.policy.details.mac', { defaultMessage: 'Mac' }), - ]} - id="malwareProtectionsForm" - rightCorner={ProtectionSwitch()} - children={RadioButtons()} - /> + supportedOss={i18n.translate('xpack.endpoint.policy.details.windowsAndMac', { + defaultMessage: 'Windows, Mac', + })} + dataTestSubj="malwareProtectionsForm" + description={i18n.translate('xpack.endpoint.policy.details.malwareLabel', { + defaultMessage: 'Malware', + })} + rightCorner={protectionSwitch} + > + {radioButtons} + </ConfigForm> ); }); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_list.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_list.tsx index 5ee1539ce9788..062c7afb6706d 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_list.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_list.tsx @@ -9,8 +9,7 @@ import { EuiBasicTable, EuiText, EuiTableFieldDataColumnType, EuiLink } from '@e import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { useDispatch } from 'react-redux'; -import { useHistory } from 'react-router-dom'; -import { usePageId } from '../use_page_id'; +import { useHistory, useLocation } from 'react-router-dom'; import { selectApiError, selectIsLoading, @@ -21,10 +20,10 @@ import { } from '../../store/policy_list/selectors'; import { usePolicyListSelector } from './policy_hooks'; import { PolicyListAction } from '../../store/policy_list'; -import { PolicyData } from '../../types'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { PageView } from '../../components/page_view'; -import { LinkToApp } from '../../components/link_to_app'; +import { PageView } from '../components/page_view'; +import { LinkToApp } from '../components/link_to_app'; +import { Immutable, PolicyData } from '../../../../../common/types'; interface TableChangeCallbackArguments { page: { index: number; size: number }; @@ -45,14 +44,14 @@ const PolicyLink: React.FC<{ name: string; route: string }> = ({ name, route }) ); }; -const renderPolicyNameLink = (value: string, _item: PolicyData) => { - return <PolicyLink name={value} route={`/policy/${_item.id}`} />; +const renderPolicyNameLink = (value: string, item: Immutable<PolicyData>) => { + return <PolicyLink name={value} route={`/policy/${item.id}`} />; }; export const PolicyList = React.memo(() => { - usePageId('policyListPage'); - const { services, notifications } = useKibana(); + const history = useHistory(); + const location = useLocation(); const dispatch = useDispatch<(action: PolicyListAction) => void>(); const policyItems = usePolicyListSelector(selectPolicyItems); @@ -84,18 +83,12 @@ export const PolicyList = React.memo(() => { const handleTableChange = useCallback( ({ page: { index, size } }: TableChangeCallbackArguments) => { - dispatch({ - type: 'userPaginatedPolicyListTable', - payload: { - pageIndex: index, - pageSize: size, - }, - }); + history.push(`${location.pathname}?page_index=${index}&page_size=${size}`); }, - [dispatch] + [history, location.pathname] ); - const columns: Array<EuiTableFieldDataColumnType<PolicyData>> = useMemo( + const columns: Array<EuiTableFieldDataColumnType<Immutable<PolicyData>>> = useMemo( () => [ { field: 'name', @@ -167,7 +160,7 @@ export const PolicyList = React.memo(() => { } > <EuiBasicTable - items={policyItems} + items={useMemo(() => [...policyItems], [policyItems])} columns={columns} loading={loading} pagination={paginationSetup} diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/use_page_id.ts b/x-pack/plugins/endpoint/public/applications/endpoint/view/use_page_id.ts deleted file mode 100644 index 49c39064c8d9a..0000000000000 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/use_page_id.ts +++ /dev/null @@ -1,28 +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 { useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { PageId } from '../../../../common/types'; -import { RoutingAction } from '../store/routing'; - -/** - * Dispatches a 'userNavigatedToPage' action with the given 'pageId' as the action payload. - * When the component is un-mounted, a `userNavigatedFromPage` action will be dispatched - * with the given `pageId`. - * - * @param pageId A page id - */ -export function usePageId(pageId: PageId) { - const dispatch: (action: RoutingAction) => unknown = useDispatch(); - useEffect(() => { - dispatch({ type: 'userNavigatedToPage', payload: pageId }); - - return () => { - dispatch({ type: 'userNavigatedFromPage', payload: pageId }); - }; - }, [dispatch, pageId]); -} diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/defs.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/defs.tsx index 8ee9bfafc630e..de9c3c7e8f8f3 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/defs.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/defs.tsx @@ -5,7 +5,7 @@ */ import React, { memo } from 'react'; -import { saturate, lighten } from 'polished'; +import { saturate } from 'polished'; import { htmlIdGenerator, @@ -79,8 +79,6 @@ const idGenerator = htmlIdGenerator(); * Ids of paint servers to be referenced by fill and stroke attributes */ export const PaintServerIds = { - runningProcess: idGenerator('psRunningProcess'), - runningTrigger: idGenerator('psRunningTrigger'), runningProcessCube: idGenerator('psRunningProcessCube'), runningTriggerCube: idGenerator('psRunningTriggerCube'), terminatedProcessCube: idGenerator('psTerminatedProcessCube'), @@ -93,46 +91,6 @@ export const PaintServerIds = { */ const PaintServers = memo(() => ( <> - <linearGradient - id={PaintServerIds.runningProcess} - x1="0" - y1="0" - x2="1" - y2="0" - spreadMethod="reflect" - gradientUnits="objectBoundingBox" - > - <stop - offset="0%" - stopColor={saturate(0.7, lighten(0.05, NamedColors.runningProcessStart))} - stopOpacity="1" - /> - <stop - offset="100%" - stopColor={saturate(0.7, lighten(0.05, NamedColors.runningProcessEnd))} - stopOpacity="1" - /> - </linearGradient> - <linearGradient - id={PaintServerIds.runningTrigger} - x1="0" - y1="0" - x2="1" - y2="0" - spreadMethod="reflect" - gradientUnits="objectBoundingBox" - > - <stop - offset="0%" - stopColor={saturate(0.7, lighten(0.05, NamedColors.runningTriggerStart))} - stopOpacity="1" - /> - <stop - offset="100%" - stopColor={saturate(0.7, lighten(0.05, NamedColors.runningTriggerEnd))} - stopOpacity="1" - /> - </linearGradient> <linearGradient id={PaintServerIds.terminatedProcessCube} x1="-381.23752" diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx index 2e3981de74d34..10e331ffff02d 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx @@ -11,7 +11,7 @@ import { htmlIdGenerator, EuiKeyboardAccessible } from '@elastic/eui'; import { useSelector } from 'react-redux'; import { applyMatrix3 } from '../lib/vector2'; import { Vector2, Matrix3, AdjacentProcessMap, ResolverProcessType } from '../types'; -import { SymbolIds, NamedColors, PaintServerIds } from './defs'; +import { SymbolIds, NamedColors } from './defs'; import { ResolverEvent } from '../../../../common/types'; import { useResolverDispatch } from './use_resolver_dispatch'; import * as eventModel from '../../../../common/models/event'; @@ -21,7 +21,7 @@ import * as selectors from '../store/selectors'; const nodeAssets = { runningProcessCube: { cubeSymbol: `#${SymbolIds.runningProcessCube}`, - labelFill: `url(#${PaintServerIds.runningProcess})`, + labelBackground: NamedColors.fullLabelBackground, descriptionFill: NamedColors.empty, descriptionText: i18n.translate('xpack.endpoint.resolver.runningProcess', { defaultMessage: 'Running Process', @@ -29,7 +29,7 @@ const nodeAssets = { }, runningTriggerCube: { cubeSymbol: `#${SymbolIds.runningTriggerCube}`, - labelFill: `url(#${PaintServerIds.runningTrigger})`, + labelBackground: NamedColors.fullLabelBackground, descriptionFill: NamedColors.empty, descriptionText: i18n.translate('xpack.endpoint.resolver.runningTrigger', { defaultMessage: 'Running Trigger', @@ -37,7 +37,7 @@ const nodeAssets = { }, terminatedProcessCube: { cubeSymbol: `#${SymbolIds.terminatedProcessCube}`, - labelFill: NamedColors.fullLabelBackground, + labelBackground: NamedColors.fullLabelBackground, descriptionFill: NamedColors.empty, descriptionText: i18n.translate('xpack.endpoint.resolver.terminatedProcess', { defaultMessage: 'Terminated Process', @@ -45,7 +45,7 @@ const nodeAssets = { }, terminatedTriggerCube: { cubeSymbol: `#${SymbolIds.terminatedTriggerCube}`, - labelFill: NamedColors.fullLabelBackground, + labelBackground: NamedColors.fullLabelBackground, descriptionFill: NamedColors.empty, descriptionText: i18n.translate('xpack.endpoint.resolver.terminatedTrigger', { defaultMessage: 'Terminated Trigger', @@ -114,14 +114,21 @@ export const ProcessEventDot = styled( [left, magFactorX, top] ); + /** + * Type in non-SVG components scales as follows: + * (These values were adjusted to match the proportions in the comps provided by UX/Design) + * 18.75 : The smallest readable font size at which labels/descriptions can be read. Font size will not scale below this. + * 12.5 : A 'slope' at which the font size will scale w.r.t. to zoom level otherwise + */ + const minimumFontSize = 18.75; + const slopeOfFontScale = 12.5; + const fontSizeAdjustmentForScale = magFactorX > 1 ? slopeOfFontScale * (magFactorX - 1) : 0; + const scaledTypeSize = minimumFontSize + fontSizeAdjustmentForScale; + const markerBaseSize = 15; const markerSize = markerBaseSize; const markerPositionOffset = -markerBaseSize / 2; - const labelYOffset = markerPositionOffset + 0.25 * markerSize - 0.5; - - const labelYHeight = markerSize / 1.7647; - /** * An element that should be animated when the node is clicked. */ @@ -136,9 +143,7 @@ export const ProcessEventDot = styled( }) | null; } = React.createRef(); - const { cubeSymbol, labelFill, descriptionFill, descriptionText } = nodeAssets[ - nodeType(event) - ]; + const { cubeSymbol, labelBackground, descriptionText } = nodeAssets[nodeType(event)]; const resolverNodeIdGenerator = useMemo(() => htmlIdGenerator('resolverNode'), []); const nodeId = useMemo(() => resolverNodeIdGenerator(selfId), [ @@ -154,7 +159,7 @@ export const ProcessEventDot = styled( const dispatch = useResolverDispatch(); const handleFocus = useCallback( - (focusEvent: React.FocusEvent<SVGSVGElement>) => { + (focusEvent: React.FocusEvent<HTMLDivElement>) => { dispatch({ type: 'userFocusedOnResolverNode', payload: { @@ -166,7 +171,7 @@ export const ProcessEventDot = styled( ); const handleClick = useCallback( - (clickEvent: React.MouseEvent<SVGSVGElement, MouseEvent>) => { + (clickEvent: React.MouseEvent<HTMLDivElement, MouseEvent>) => { if (animationTarget.current !== null) { (animationTarget.current as any).beginElement(); } @@ -179,14 +184,15 @@ export const ProcessEventDot = styled( }, [animationTarget, dispatch, nodeId] ); - + /* eslint-disable jsx-a11y/click-events-have-key-events */ + /** + * Key event handling (e.g. 'Enter'/'Space') is provisioned by the `EuiKeyboardAccessible` component + */ return ( <EuiKeyboardAccessible> - <svg + <div data-test-subj={'resolverNode'} className={className + ' kbn-resetFocusState'} - viewBox="-15 -15 90 30" - preserveAspectRatio="xMidYMid meet" role="treeitem" aria-level={adjacentNodeMap.level} aria-flowto={ @@ -203,81 +209,100 @@ export const ProcessEventDot = styled( onFocus={handleFocus} tabIndex={-1} > - <g> - <use - xlinkHref={`#${SymbolIds.processCubeActiveBacking}`} - x={-11.35} - y={-11.35} - width={markerSize * 1.5} - height={markerSize * 1.5} - className="backing" - /> - <rect x="7" y="-12.75" width="15" height="10" fill={NamedColors.resolverBackground} /> - <use - role="presentation" - xlinkHref={cubeSymbol} - x={markerPositionOffset} - y={markerPositionOffset} - width={markerSize} - height={markerSize} - opacity="1" - className="cube" - > - <animateTransform - attributeType="XML" - attributeName="transform" - type="scale" - values="1 1; 1 .83; 1 .8; 1 .83; 1 1" - dur="0.2s" - begin="click" - repeatCount="1" - className="squish" - ref={animationTarget} + <svg + viewBox="-15 -15 90 30" + preserveAspectRatio="xMidYMid meet" + style={{ + display: 'block', + width: '100%', + height: '100%', + position: 'absolute', + top: '0', + left: '0', + }} + > + <g> + <use + xlinkHref={`#${SymbolIds.processCubeActiveBacking}`} + x={-11.35} + y={-11.35} + width={markerSize * 1.5} + height={markerSize * 1.5} + className="backing" /> - </use> - <use - role="presentation" - xlinkHref={`#${SymbolIds.processNodeLabel}`} - x={markerPositionOffset + markerSize - 0.5} - y={labelYOffset} - width={(markerSize / 1.7647) * 5} - height={markerSize / 1.7647} - opacity="1" - fill={labelFill} - /> - <text - x={markerPositionOffset + 0.7 * markerSize + 50 / 2} - y={labelYOffset + labelYHeight / 2} - textAnchor="middle" - dominantBaseline="middle" - fontSize="3.75" - fontWeight="bold" - fill={NamedColors.empty} - paintOrder="stroke" - tabIndex={-1} - style={{ letterSpacing: '-0.02px' }} - id={labelId} - > - {eventModel.eventName(event)} - </text> - <text - x={markerPositionOffset + markerSize} - y={labelYOffset - 1} - textAnchor="start" - dominantBaseline="middle" - fontSize="2.67" - fill={descriptionFill} + <use + role="presentation" + xlinkHref={cubeSymbol} + x={markerPositionOffset} + y={markerPositionOffset} + width={markerSize} + height={markerSize} + opacity="1" + className="cube" + > + <animateTransform + attributeType="XML" + attributeName="transform" + type="scale" + values="1 1; 1 .83; 1 .8; 1 .83; 1 1" + dur="0.2s" + begin="click" + repeatCount="1" + className="squish" + ref={animationTarget} + /> + </use> + </g> + </svg> + <div + style={{ + left: '25%', + top: '30%', + position: 'absolute', + width: '50%', + color: 'white', + fontSize: `${scaledTypeSize}px`, + lineHeight: '140%', + }} + > + <div id={descriptionId} - paintOrder="stroke" - fontWeight="bold" - style={{ textTransform: 'uppercase', letterSpacing: '-0.01px' }} + style={{ + textTransform: 'uppercase', + letterSpacing: '-0.01px', + backgroundColor: NamedColors.resolverBackground, + lineHeight: '1.2', + fontWeight: 'bold', + fontSize: '.5em', + width: '100%', + margin: '0 0 .05em 0', + textAlign: 'left', + padding: '0', + }} > {descriptionText} - </text> - </g> - </svg> + </div> + <div + data-test-subject="nodeLabel" + id={labelId} + style={{ + backgroundColor: labelBackground, + padding: '.15em 0', + textAlign: 'center', + maxWidth: '100%', + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + contain: 'content', + }} + > + {eventModel.eventName(event)} + </div> + </div> + </div> </EuiKeyboardAccessible> ); + /* eslint-enable jsx-a11y/click-events-have-key-events */ } ) )` diff --git a/x-pack/plugins/endpoint/scripts/alert_mapping.json b/x-pack/plugins/endpoint/scripts/alert_mapping.json index a21e48b4bc95f..2e0041d0af986 100644 --- a/x-pack/plugins/endpoint/scripts/alert_mapping.json +++ b/x-pack/plugins/endpoint/scripts/alert_mapping.json @@ -394,7 +394,8 @@ "type": "nested" }, "file_extension": { - "type": "long" + "ignore_above": 1024, + "type": "keyword" }, "project_file": { "properties": { diff --git a/x-pack/plugins/endpoint/scripts/event_mapping.json b/x-pack/plugins/endpoint/scripts/event_mapping.json index 59d1ed17852b1..f410edc7abe5e 100644 --- a/x-pack/plugins/endpoint/scripts/event_mapping.json +++ b/x-pack/plugins/endpoint/scripts/event_mapping.json @@ -386,7 +386,8 @@ "type": "nested" }, "file_extension": { - "type": "long" + "ignore_above": 1024, + "type": "keyword" }, "project_file": { "properties": { diff --git a/x-pack/plugins/endpoint/scripts/resolver_generator.ts b/x-pack/plugins/endpoint/scripts/resolver_generator.ts index 333846bde6ce4..dd9e591f4b034 100644 --- a/x-pack/plugins/endpoint/scripts/resolver_generator.ts +++ b/x-pack/plugins/endpoint/scripts/resolver_generator.ts @@ -41,7 +41,7 @@ async function main() { metadataIndex: { alias: 'mi', describe: 'index to store host metadata in', - default: 'endpoint-agent-1', + default: 'metrics-endpoint-default-1', type: 'string', }, auth: { diff --git a/x-pack/plugins/endpoint/server/index_pattern.ts b/x-pack/plugins/endpoint/server/index_pattern.ts new file mode 100644 index 0000000000000..ea612bfd75441 --- /dev/null +++ b/x-pack/plugins/endpoint/server/index_pattern.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 { Logger, LoggerFactory, RequestHandlerContext } from 'kibana/server'; +import { ESIndexPatternService } from '../../ingest_manager/server'; +import { EndpointAppConstants } from '../common/types'; + +export interface IndexPatternRetriever { + getIndexPattern(ctx: RequestHandlerContext, datasetPath: string): Promise<string>; + getEventIndexPattern(ctx: RequestHandlerContext): Promise<string>; + getMetadataIndexPattern(ctx: RequestHandlerContext): Promise<string>; +} + +/** + * This class is used to retrieve an index pattern. It should be used in the server side code whenever + * an index pattern is needed to query data within ES. The index pattern is constructed by the Ingest Manager + * based on the contents of the Endpoint Package in the Package Registry. + */ +export class IngestIndexPatternRetriever implements IndexPatternRetriever { + private static endpointPackageName = 'endpoint'; + private static metadataDataset = 'metadata'; + private readonly log: Logger; + constructor(private readonly service: ESIndexPatternService, loggerFactory: LoggerFactory) { + this.log = loggerFactory.get('index-pattern-retriever'); + } + + /** + * Retrieves the index pattern for querying events within elasticsearch. + * + * @param ctx a RequestHandlerContext from a route handler + * @returns a string representing the index pattern (e.g. `events-endpoint-*`) + */ + async getEventIndexPattern(ctx: RequestHandlerContext) { + return await this.getIndexPattern(ctx, EndpointAppConstants.EVENT_DATASET); + } + + /** + * Retrieves the index pattern for querying endpoint metadata within elasticsearch. + * + * @param ctx a RequestHandlerContext from a route handler + * @returns a string representing the index pattern (e.g. `metrics-endpoint-*`) + */ + async getMetadataIndexPattern(ctx: RequestHandlerContext) { + return await this.getIndexPattern(ctx, IngestIndexPatternRetriever.metadataDataset); + } + + /** + * Retrieves the index pattern for a specific dataset for querying endpoint data. + * + * @param ctx a RequestHandlerContext from a route handler + * @param datasetPath a string of the path being used for a dataset within the Endpoint Package + * (e.g. `events`, `metadata`) + * @returns a string representing the index pattern (e.g. `metrics-endpoint-*`) + */ + async getIndexPattern(ctx: RequestHandlerContext, datasetPath: string) { + try { + const pattern = await this.service.getESIndexPattern( + ctx.core.savedObjects.client, + IngestIndexPatternRetriever.endpointPackageName, + datasetPath + ); + + if (!pattern) { + const msg = `Unable to retrieve the index pattern for dataset: ${datasetPath}`; + this.log.warn(msg); + throw new Error(msg); + } + return pattern; + } catch (error) { + const errMsg = `Error occurred while retrieving pattern for: ${datasetPath} error: ${error}`; + this.log.warn(errMsg); + throw new Error(errMsg); + } + } +} diff --git a/x-pack/plugins/endpoint/server/mocks.ts b/x-pack/plugins/endpoint/server/mocks.ts new file mode 100644 index 0000000000000..903aa19cd8843 --- /dev/null +++ b/x-pack/plugins/endpoint/server/mocks.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Creates a mock IndexPatternRetriever for use in tests. + * + * @param indexPattern a string index pattern to return when any of the mock's public methods are called. + * @returns the same string passed in via `indexPattern` + */ +export const createMockIndexPatternRetriever = (indexPattern: string) => { + const mockGetFunc = jest.fn().mockResolvedValue(indexPattern); + return { + getIndexPattern: mockGetFunc, + getEventIndexPattern: mockGetFunc, + getMetadataIndexPattern: mockGetFunc, + }; +}; + +export const MetadataIndexPattern = 'metrics-endpoint-*'; + +/** + * Creates a mock IndexPatternRetriever for use in tests that returns `metrics-endpoint-*` + */ +export const createMockMetadataIndexPatternRetriever = () => { + return createMockIndexPatternRetriever(MetadataIndexPattern); +}; + +/** + * Creates a mock IndexPatternService for use in tests that need to interact with the Ingest Manager's + * ESIndexPatternService. + * + * @param indexPattern a string index pattern to return when called by a test + * @returns the same value as `indexPattern` parameter + */ +export const createMockIndexPatternService = (indexPattern: string) => { + return { + esIndexPatternService: { + getESIndexPattern: jest.fn().mockResolvedValue(indexPattern), + }, + }; +}; diff --git a/x-pack/plugins/endpoint/server/plugin.test.ts b/x-pack/plugins/endpoint/server/plugin.test.ts index 7dd878d579043..8d55e64f16dcf 100644 --- a/x-pack/plugins/endpoint/server/plugin.test.ts +++ b/x-pack/plugins/endpoint/server/plugin.test.ts @@ -7,6 +7,7 @@ import { EndpointPlugin, EndpointPluginSetupDependencies } from './plugin'; import { coreMock } from '../../../../src/core/server/mocks'; import { PluginSetupContract } from '../../features/server'; +import { createMockIndexPatternService } from './mocks'; describe('test endpoint plugin', () => { let plugin: EndpointPlugin; @@ -28,7 +29,10 @@ describe('test endpoint plugin', () => { getFeaturesUICapabilities: jest.fn(), registerLegacyAPI: jest.fn(), }; - mockedEndpointPluginSetupDependencies = { features: mockedPluginSetupContract }; + mockedEndpointPluginSetupDependencies = { + features: mockedPluginSetupContract, + ingestManager: createMockIndexPatternService(''), + }; }); it('test properly setup plugin', async () => { diff --git a/x-pack/plugins/endpoint/server/plugin.ts b/x-pack/plugins/endpoint/server/plugin.ts index d3a399124124f..6a42014e91130 100644 --- a/x-pack/plugins/endpoint/server/plugin.ts +++ b/x-pack/plugins/endpoint/server/plugin.ts @@ -6,12 +6,15 @@ import { Plugin, CoreSetup, PluginInitializerContext, Logger } from 'kibana/server'; import { first } from 'rxjs/operators'; import { PluginSetupContract as FeaturesPluginSetupContract } from '../../features/server'; +import { IngestManagerSetupContract } from '../../ingest_manager/server'; import { createConfig$, EndpointConfigType } from './config'; import { EndpointAppContext } from './types'; import { registerAlertRoutes } from './routes/alerts'; import { registerResolverRoutes } from './routes/resolver'; +import { registerIndexPatternRoute } from './routes/index_pattern'; import { registerEndpointRoutes } from './routes/metadata'; +import { IngestIndexPatternRetriever } from './index_pattern'; export type EndpointPluginStart = void; export type EndpointPluginSetup = void; @@ -19,6 +22,7 @@ export interface EndpointPluginStartDependencies {} // eslint-disable-line @type export interface EndpointPluginSetupDependencies { features: FeaturesPluginSetupContract; + ingestManager: IngestManagerSetupContract; } export class EndpointPlugin @@ -62,6 +66,10 @@ export class EndpointPlugin }, }); const endpointContext = { + indexPatternRetriever: new IngestIndexPatternRetriever( + plugins.ingestManager.esIndexPatternService, + this.initializerContext.logger + ), logFactory: this.initializerContext.logger, config: (): Promise<EndpointConfigType> => { return createConfig$(this.initializerContext) @@ -73,6 +81,7 @@ export class EndpointPlugin registerEndpointRoutes(router, endpointContext); registerResolverRoutes(router, endpointContext); registerAlertRoutes(router, endpointContext); + registerIndexPatternRoute(router, endpointContext); } public start() { diff --git a/x-pack/plugins/endpoint/server/routes/alerts/alerts.test.ts b/x-pack/plugins/endpoint/server/routes/alerts/alerts.test.ts index 5f5e4be4ecd0a..6be7b26898206 100644 --- a/x-pack/plugins/endpoint/server/routes/alerts/alerts.test.ts +++ b/x-pack/plugins/endpoint/server/routes/alerts/alerts.test.ts @@ -12,6 +12,7 @@ import { import { registerAlertRoutes } from './index'; import { EndpointConfigSchema } from '../../config'; import { alertingIndexGetQuerySchema } from '../../../common/schema/alert_index'; +import { createMockIndexPatternRetriever } from '../../mocks'; describe('test alerts route', () => { let routerMock: jest.Mocked<IRouter>; @@ -24,6 +25,7 @@ describe('test alerts route', () => { mockClusterClient.asScoped.mockReturnValue(mockScopedClient); routerMock = httpServiceMock.createRouter(); registerAlertRoutes(routerMock, { + indexPatternRetriever: createMockIndexPatternRetriever('events-endpoint-*'), logFactory: loggingServiceMock.create(), config: () => Promise.resolve(EndpointConfigSchema.validate({})), }); diff --git a/x-pack/plugins/endpoint/server/routes/alerts/details/handlers.ts b/x-pack/plugins/endpoint/server/routes/alerts/details/handlers.ts index 0f32deb4fad9b..86e9f55da5697 100644 --- a/x-pack/plugins/endpoint/server/routes/alerts/details/handlers.ts +++ b/x-pack/plugins/endpoint/server/routes/alerts/details/handlers.ts @@ -26,15 +26,18 @@ export const alertDetailsHandlerWrapper = function( id: alertId, })) as GetResponse<AlertEvent>; + const indexPattern = await endpointAppContext.indexPatternRetriever.getEventIndexPattern(ctx); + const config = await endpointAppContext.config(); const pagination: AlertDetailsPagination = new AlertDetailsPagination( config, ctx, req.params, - response + response, + indexPattern ); - const currentHostInfo = await getHostData(ctx, response._source.host.id); + const currentHostInfo = await getHostData(ctx, response._source.host.id, indexPattern); return res.ok({ body: { diff --git a/x-pack/plugins/endpoint/server/routes/alerts/details/lib/pagination.ts b/x-pack/plugins/endpoint/server/routes/alerts/details/lib/pagination.ts index 446d61080e650..d482da03872c6 100644 --- a/x-pack/plugins/endpoint/server/routes/alerts/details/lib/pagination.ts +++ b/x-pack/plugins/endpoint/server/routes/alerts/details/lib/pagination.ts @@ -29,7 +29,8 @@ export class AlertDetailsPagination extends Pagination< config: EndpointConfigType, requestContext: RequestHandlerContext, state: AlertDetailsRequestParams, - data: GetResponse<AlertEvent> + data: GetResponse<AlertEvent>, + private readonly indexPattern: string ) { super(config, requestContext, state, data); } @@ -54,7 +55,8 @@ export class AlertDetailsPagination extends Pagination< const response = await searchESForAlerts( this.requestContext.core.elasticsearch.dataClient, - reqData + reqData, + this.indexPattern ); return response; } diff --git a/x-pack/plugins/endpoint/server/routes/alerts/lib/index.ts b/x-pack/plugins/endpoint/server/routes/alerts/lib/index.ts index dc1ce767a715b..74db24c85eab5 100644 --- a/x-pack/plugins/endpoint/server/routes/alerts/lib/index.ts +++ b/x-pack/plugins/endpoint/server/routes/alerts/lib/index.ts @@ -97,7 +97,8 @@ function buildSort(query: AlertSearchQuery): AlertSort { * Builds a request body for Elasticsearch, given a set of query params. **/ const buildAlertSearchQuery = async ( - query: AlertSearchQuery + query: AlertSearchQuery, + indexPattern: string ): Promise<AlertSearchRequestWrapper> => { let totalHitsMin: number = EndpointAppConstants.DEFAULT_TOTAL_HITS; @@ -125,7 +126,7 @@ const buildAlertSearchQuery = async ( const reqWrapper: AlertSearchRequestWrapper = { size: query.pageSize, - index: EndpointAppConstants.ALERT_INDEX_NAME, + index: indexPattern, body: reqBody, }; @@ -141,9 +142,10 @@ const buildAlertSearchQuery = async ( **/ export const searchESForAlerts = async ( dataClient: IScopedClusterClient, - query: AlertSearchQuery + query: AlertSearchQuery, + indexPattern: string ): Promise<SearchResponse<AlertEvent>> => { - const reqWrapper = await buildAlertSearchQuery(query); + const reqWrapper = await buildAlertSearchQuery(query, indexPattern); const response = (await dataClient.callAsCurrentUser('search', reqWrapper)) as SearchResponse< AlertEvent >; diff --git a/x-pack/plugins/endpoint/server/routes/alerts/list/handlers.ts b/x-pack/plugins/endpoint/server/routes/alerts/list/handlers.ts index 93ec8d7aa3e67..f23dffd13db4f 100644 --- a/x-pack/plugins/endpoint/server/routes/alerts/list/handlers.ts +++ b/x-pack/plugins/endpoint/server/routes/alerts/list/handlers.ts @@ -18,8 +18,13 @@ export const alertListHandlerWrapper = function( res ) => { try { + const indexPattern = await endpointAppContext.indexPatternRetriever.getEventIndexPattern(ctx); const reqData = await getRequestData(req, endpointAppContext); - const response = await searchESForAlerts(ctx.core.elasticsearch.dataClient, reqData); + const response = await searchESForAlerts( + ctx.core.elasticsearch.dataClient, + reqData, + indexPattern + ); const mappedBody = await mapToAlertResultList(ctx, endpointAppContext, reqData, response); return res.ok({ body: mappedBody }); } catch (err) { diff --git a/x-pack/plugins/endpoint/server/routes/index_pattern.ts b/x-pack/plugins/endpoint/server/routes/index_pattern.ts new file mode 100644 index 0000000000000..3b71f6a6957ba --- /dev/null +++ b/x-pack/plugins/endpoint/server/routes/index_pattern.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter, Logger, RequestHandler } from 'kibana/server'; +import { EndpointAppContext } from '../types'; +import { IndexPatternGetParamsResult, EndpointAppConstants } from '../../common/types'; +import { indexPatternGetParamsSchema } from '../../common/schema/index_pattern'; +import { IndexPatternRetriever } from '../index_pattern'; + +function handleIndexPattern( + log: Logger, + indexRetriever: IndexPatternRetriever +): RequestHandler<IndexPatternGetParamsResult> { + return async (context, req, res) => { + try { + return res.ok({ + body: { + indexPattern: await indexRetriever.getIndexPattern(context, req.params.datasetPath), + }, + }); + } catch (error) { + log.warn(error); + return res.notFound({ body: error }); + } + }; +} + +export function registerIndexPatternRoute(router: IRouter, endpointAppContext: EndpointAppContext) { + const log = endpointAppContext.logFactory.get('index_pattern'); + + router.get( + { + path: `${EndpointAppConstants.INDEX_PATTERN_ROUTE}/{datasetPath}`, + validate: { params: indexPatternGetParamsSchema }, + options: { authRequired: true }, + }, + handleIndexPattern(log, endpointAppContext.indexPatternRetriever) + ); +} diff --git a/x-pack/plugins/endpoint/server/routes/metadata/index.ts b/x-pack/plugins/endpoint/server/routes/metadata/index.ts index 450469914bc50..883bb88204fd4 100644 --- a/x-pack/plugins/endpoint/server/routes/metadata/index.ts +++ b/x-pack/plugins/endpoint/server/routes/metadata/index.ts @@ -8,9 +8,9 @@ import { IRouter, RequestHandlerContext } from 'kibana/server'; import { SearchResponse } from 'elasticsearch'; import { schema } from '@kbn/config-schema'; +import { kibanaRequestToMetadataListESQuery, getESQueryHostMetadataByID } from './query_builders'; import { HostInfo, HostMetadata, HostResultList, HostStatus } from '../../../common/types'; import { EndpointAppContext } from '../../types'; -import { getESQueryHostMetadataByID, kibanaRequestToMetadataListESQuery } from './query_builders'; interface HitSource { _source: HostMetadata; @@ -50,7 +50,14 @@ export function registerEndpointRoutes(router: IRouter, endpointAppContext: Endp }, async (context, req, res) => { try { - const queryParams = await kibanaRequestToMetadataListESQuery(req, endpointAppContext); + const index = await endpointAppContext.indexPatternRetriever.getMetadataIndexPattern( + context + ); + const queryParams = await kibanaRequestToMetadataListESQuery( + req, + endpointAppContext, + index + ); const response = (await context.core.elasticsearch.dataClient.callAsCurrentUser( 'search', queryParams @@ -72,7 +79,11 @@ export function registerEndpointRoutes(router: IRouter, endpointAppContext: Endp }, async (context, req, res) => { try { - const doc = await getHostData(context, req.params.id); + const index = await endpointAppContext.indexPatternRetriever.getMetadataIndexPattern( + context + ); + + const doc = await getHostData(context, req.params.id, index); if (doc) { return res.ok({ body: doc }); } @@ -86,9 +97,10 @@ export function registerEndpointRoutes(router: IRouter, endpointAppContext: Endp export async function getHostData( context: RequestHandlerContext, - id: string + id: string, + index: string ): Promise<HostInfo | undefined> { - const query = getESQueryHostMetadataByID(id); + const query = getESQueryHostMetadataByID(id, index); const response = (await context.core.elasticsearch.dataClient.callAsCurrentUser( 'search', query diff --git a/x-pack/plugins/endpoint/server/routes/metadata/metadata.test.ts b/x-pack/plugins/endpoint/server/routes/metadata/metadata.test.ts index 9bd251735cc04..9a7d3fb3188a6 100644 --- a/x-pack/plugins/endpoint/server/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/endpoint/server/routes/metadata/metadata.test.ts @@ -11,24 +11,28 @@ import { RequestHandler, RequestHandlerContext, RouteConfig, + SavedObjectsClientContract, } from 'kibana/server'; import { elasticsearchServiceMock, httpServerMock, httpServiceMock, loggingServiceMock, + savedObjectsClientMock, } from '../../../../../../src/core/server/mocks'; import { HostInfo, HostMetadata, HostResultList, HostStatus } from '../../../common/types'; import { SearchResponse } from 'elasticsearch'; +import { registerEndpointRoutes } from './index'; import { EndpointConfigSchema } from '../../config'; import * as data from '../../test_data/all_metadata_data.json'; -import { registerEndpointRoutes } from './index'; +import { createMockMetadataIndexPatternRetriever } from '../../mocks'; describe('test endpoint route', () => { let routerMock: jest.Mocked<IRouter>; let mockResponse: jest.Mocked<KibanaResponseFactory>; let mockClusterClient: jest.Mocked<IClusterClient>; let mockScopedClient: jest.Mocked<IScopedClusterClient>; + let mockSavedObjectClient: jest.Mocked<SavedObjectsClientContract>; let routeHandler: RequestHandler<any, any, any>; let routeConfig: RouteConfig<any, any, any, any>; @@ -37,15 +41,38 @@ describe('test endpoint route', () => { IClusterClient >; mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); + mockSavedObjectClient = savedObjectsClientMock.create(); mockClusterClient.asScoped.mockReturnValue(mockScopedClient); routerMock = httpServiceMock.createRouter(); mockResponse = httpServerMock.createResponseFactory(); registerEndpointRoutes(routerMock, { + indexPatternRetriever: createMockMetadataIndexPatternRetriever(), logFactory: loggingServiceMock.create(), config: () => Promise.resolve(EndpointConfigSchema.validate({})), }); }); + function createRouteHandlerContext( + dataClient: jest.Mocked<IScopedClusterClient>, + savedObjectsClient: jest.Mocked<SavedObjectsClientContract> + ) { + return ({ + core: { + elasticsearch: { + dataClient, + }, + savedObjects: { + client: savedObjectsClient, + }, + }, + /** + * Using unknown here because the object defined is not a full `RequestHandlerContext`. We don't + * need all of the fields required to run the tests, but the `routeHandler` function requires a + * `RequestHandlerContext`. + */ + } as unknown) as RequestHandlerContext; + } + it('test find the latest of all endpoints', async () => { const mockRequest = httpServerMock.createKibanaRequest({}); @@ -58,13 +85,7 @@ describe('test endpoint route', () => { )!; await routeHandler( - ({ - core: { - elasticsearch: { - dataClient: mockScopedClient, - }, - }, - } as unknown) as RequestHandlerContext, + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), mockRequest, mockResponse ); @@ -100,13 +121,7 @@ describe('test endpoint route', () => { )!; await routeHandler( - ({ - core: { - elasticsearch: { - dataClient: mockScopedClient, - }, - }, - } as unknown) as RequestHandlerContext, + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), mockRequest, mockResponse ); @@ -147,13 +162,7 @@ describe('test endpoint route', () => { )!; await routeHandler( - ({ - core: { - elasticsearch: { - dataClient: mockScopedClient, - }, - }, - } as unknown) as RequestHandlerContext, + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), mockRequest, mockResponse ); @@ -212,13 +221,7 @@ describe('test endpoint route', () => { )!; await routeHandler( - ({ - core: { - elasticsearch: { - dataClient: mockScopedClient, - }, - }, - } as unknown) as RequestHandlerContext, + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), mockRequest, mockResponse ); @@ -243,13 +246,7 @@ describe('test endpoint route', () => { )!; await routeHandler( - ({ - core: { - elasticsearch: { - dataClient: mockScopedClient, - }, - }, - } as unknown) as RequestHandlerContext, + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), mockRequest, mockResponse ); diff --git a/x-pack/plugins/endpoint/server/routes/metadata/query_builders.test.ts b/x-pack/plugins/endpoint/server/routes/metadata/query_builders.test.ts index 2514d5aa85811..c8143fbdda1ea 100644 --- a/x-pack/plugins/endpoint/server/routes/metadata/query_builders.test.ts +++ b/x-pack/plugins/endpoint/server/routes/metadata/query_builders.test.ts @@ -6,7 +6,7 @@ import { httpServerMock, loggingServiceMock } from '../../../../../../src/core/server/mocks'; import { EndpointConfigSchema } from '../../config'; import { kibanaRequestToMetadataListESQuery, getESQueryHostMetadataByID } from './query_builders'; -import { EndpointAppConstants } from '../../../common/types'; +import { createMockMetadataIndexPatternRetriever, MetadataIndexPattern } from '../../mocks'; describe('query builder', () => { describe('MetadataListESQuery', () => { @@ -14,17 +14,22 @@ describe('query builder', () => { const mockRequest = httpServerMock.createKibanaRequest({ body: {}, }); - const query = await kibanaRequestToMetadataListESQuery(mockRequest, { - logFactory: loggingServiceMock.create(), - config: () => Promise.resolve(EndpointConfigSchema.validate({})), - }); + const query = await kibanaRequestToMetadataListESQuery( + mockRequest, + { + indexPatternRetriever: createMockMetadataIndexPatternRetriever(), + logFactory: loggingServiceMock.create(), + config: () => Promise.resolve(EndpointConfigSchema.validate({})), + }, + MetadataIndexPattern + ); expect(query).toEqual({ body: { query: { match_all: {}, }, collapse: { - field: 'host.id.keyword', + field: 'host.id', inner_hits: { name: 'most_recent', size: 1, @@ -34,7 +39,7 @@ describe('query builder', () => { aggs: { total: { cardinality: { - field: 'host.id.keyword', + field: 'host.id', }, }, }, @@ -48,7 +53,7 @@ describe('query builder', () => { }, from: 0, size: 10, - index: EndpointAppConstants.ENDPOINT_INDEX_NAME, + index: MetadataIndexPattern, } as Record<string, any>); }); }); @@ -60,10 +65,15 @@ describe('query builder', () => { filter: 'not host.ip:10.140.73.246', }, }); - const query = await kibanaRequestToMetadataListESQuery(mockRequest, { - logFactory: loggingServiceMock.create(), - config: () => Promise.resolve(EndpointConfigSchema.validate({})), - }); + const query = await kibanaRequestToMetadataListESQuery( + mockRequest, + { + indexPatternRetriever: createMockMetadataIndexPatternRetriever(), + logFactory: loggingServiceMock.create(), + config: () => Promise.resolve(EndpointConfigSchema.validate({})), + }, + MetadataIndexPattern + ); expect(query).toEqual({ body: { query: { @@ -83,7 +93,7 @@ describe('query builder', () => { }, }, collapse: { - field: 'host.id.keyword', + field: 'host.id', inner_hits: { name: 'most_recent', size: 1, @@ -93,7 +103,7 @@ describe('query builder', () => { aggs: { total: { cardinality: { - field: 'host.id.keyword', + field: 'host.id', }, }, }, @@ -107,7 +117,7 @@ describe('query builder', () => { }, from: 0, size: 10, - index: EndpointAppConstants.ENDPOINT_INDEX_NAME, + index: MetadataIndexPattern, } as Record<string, any>); }); }); @@ -115,14 +125,15 @@ describe('query builder', () => { describe('MetadataGetQuery', () => { it('searches for the correct ID', () => { const mockID = 'AABBCCDD-0011-2233-AA44-DEADBEEF8899'; - const query = getESQueryHostMetadataByID(mockID); + const query = getESQueryHostMetadataByID(mockID, MetadataIndexPattern); + expect(query).toEqual({ body: { - query: { match: { 'host.id.keyword': mockID } }, + query: { match: { 'host.id': mockID } }, sort: [{ 'event.created': { order: 'desc' } }], size: 1, }, - index: EndpointAppConstants.ENDPOINT_INDEX_NAME, + index: MetadataIndexPattern, }); }); }); diff --git a/x-pack/plugins/endpoint/server/routes/metadata/query_builders.ts b/x-pack/plugins/endpoint/server/routes/metadata/query_builders.ts index bd07604fe9ad2..abcc293985f9f 100644 --- a/x-pack/plugins/endpoint/server/routes/metadata/query_builders.ts +++ b/x-pack/plugins/endpoint/server/routes/metadata/query_builders.ts @@ -6,18 +6,18 @@ import { KibanaRequest } from 'kibana/server'; import { esKuery } from '../../../../../../src/plugins/data/server'; import { EndpointAppContext } from '../../types'; -import { EndpointAppConstants } from '../../../common/types'; export const kibanaRequestToMetadataListESQuery = async ( request: KibanaRequest<any, any, any>, - endpointAppContext: EndpointAppContext + endpointAppContext: EndpointAppContext, + index: string ): Promise<Record<string, any>> => { const pagingProperties = await getPagingProperties(request, endpointAppContext); return { body: { query: buildQueryBody(request), collapse: { - field: 'host.id.keyword', + field: 'host.id', inner_hits: { name: 'most_recent', size: 1, @@ -27,7 +27,7 @@ export const kibanaRequestToMetadataListESQuery = async ( aggs: { total: { cardinality: { - field: 'host.id.keyword', + field: 'host.id', }, }, }, @@ -41,7 +41,7 @@ export const kibanaRequestToMetadataListESQuery = async ( }, from: pagingProperties.pageIndex * pagingProperties.pageSize, size: pagingProperties.pageSize, - index: EndpointAppConstants.ENDPOINT_INDEX_NAME, + index, }; }; @@ -74,12 +74,12 @@ function buildQueryBody(request: KibanaRequest<any, any, any>): Record<string, a }; } -export function getESQueryHostMetadataByID(hostID: string) { +export function getESQueryHostMetadataByID(hostID: string, index: string) { return { body: { query: { match: { - 'host.id.keyword': hostID, + 'host.id': hostID, }, }, sort: [ @@ -91,6 +91,6 @@ export function getESQueryHostMetadataByID(hostID: string) { ], size: 1, }, - index: EndpointAppConstants.ENDPOINT_INDEX_NAME, + index, }; } diff --git a/x-pack/plugins/endpoint/server/routes/resolver.ts b/x-pack/plugins/endpoint/server/routes/resolver.ts index 946ada51c40e9..77fcbc87baeb1 100644 --- a/x-pack/plugins/endpoint/server/routes/resolver.ts +++ b/x-pack/plugins/endpoint/server/routes/resolver.ts @@ -12,6 +12,7 @@ import { handleLifecycle, validateLifecycle } from './resolver/lifecycle'; export function registerResolverRoutes(router: IRouter, endpointAppContext: EndpointAppContext) { const log = endpointAppContext.logFactory.get('resolver'); + const indexPatternService = endpointAppContext.indexPatternRetriever; router.get( { @@ -19,7 +20,7 @@ export function registerResolverRoutes(router: IRouter, endpointAppContext: Endp validate: validateRelatedEvents, options: { authRequired: true }, }, - handleRelatedEvents(log) + handleRelatedEvents(log, indexPatternService) ); router.get( @@ -28,7 +29,7 @@ export function registerResolverRoutes(router: IRouter, endpointAppContext: Endp validate: validateChildren, options: { authRequired: true }, }, - handleChildren(log) + handleChildren(log, indexPatternService) ); router.get( @@ -37,6 +38,6 @@ export function registerResolverRoutes(router: IRouter, endpointAppContext: Endp validate: validateLifecycle, options: { authRequired: true }, }, - handleLifecycle(log) + handleLifecycle(log, indexPatternService) ); } diff --git a/x-pack/plugins/endpoint/server/routes/resolver/children.ts b/x-pack/plugins/endpoint/server/routes/resolver/children.ts index f97c742b18d67..05b8f0b5f8608 100644 --- a/x-pack/plugins/endpoint/server/routes/resolver/children.ts +++ b/x-pack/plugins/endpoint/server/routes/resolver/children.ts @@ -11,6 +11,7 @@ import { extractEntityID } from './utils/normalize'; import { getPaginationParams } from './utils/pagination'; import { LifecycleQuery } from './queries/lifecycle'; import { ChildrenQuery } from './queries/children'; +import { IndexPatternRetriever } from '../../index_pattern'; interface ChildrenQueryParams { after?: string; @@ -45,7 +46,8 @@ export const validateChildren = { }; export function handleChildren( - log: Logger + log: Logger, + indexRetriever: IndexPatternRetriever ): RequestHandler<ChildrenPathParams, ChildrenQueryParams> { return async (context, req, res) => { const { @@ -54,10 +56,11 @@ export function handleChildren( } = req; try { const pagination = getPaginationParams(limit, after); + const indexPattern = await indexRetriever.getEventIndexPattern(context); const client = context.core.elasticsearch.dataClient; - const childrenQuery = new ChildrenQuery(legacyEndpointID, pagination); - const lifecycleQuery = new LifecycleQuery(legacyEndpointID); + const childrenQuery = new ChildrenQuery(indexPattern, legacyEndpointID, pagination); + const lifecycleQuery = new LifecycleQuery(indexPattern, legacyEndpointID); // Retrieve the related child process events for a given process const { total, results: events, nextCursor } = await childrenQuery.search(client, id); diff --git a/x-pack/plugins/endpoint/server/routes/resolver/lifecycle.ts b/x-pack/plugins/endpoint/server/routes/resolver/lifecycle.ts index 9895344174014..6d155b79651a7 100644 --- a/x-pack/plugins/endpoint/server/routes/resolver/lifecycle.ts +++ b/x-pack/plugins/endpoint/server/routes/resolver/lifecycle.ts @@ -10,6 +10,7 @@ import { RequestHandler, Logger } from 'kibana/server'; import { extractParentEntityID } from './utils/normalize'; import { LifecycleQuery } from './queries/lifecycle'; import { ResolverEvent } from '../../../common/types'; +import { IndexPatternRetriever } from '../../index_pattern'; interface LifecycleQueryParams { ancestors: number; @@ -46,7 +47,8 @@ function getParentEntityID(results: ResolverEvent[]) { } export function handleLifecycle( - log: Logger + log: Logger, + indexRetriever: IndexPatternRetriever ): RequestHandler<LifecyclePathParams, LifecycleQueryParams> { return async (context, req, res) => { const { @@ -56,8 +58,8 @@ export function handleLifecycle( try { const ancestorLifecycles = []; const client = context.core.elasticsearch.dataClient; - - const lifecycleQuery = new LifecycleQuery(legacyEndpointID); + const indexPattern = await indexRetriever.getEventIndexPattern(context); + const lifecycleQuery = new LifecycleQuery(indexPattern, legacyEndpointID); const { results: processLifecycle } = await lifecycleQuery.search(client, id); let nextParentID = getParentEntityID(processLifecycle); diff --git a/x-pack/plugins/endpoint/server/routes/resolver/queries/base.ts b/x-pack/plugins/endpoint/server/routes/resolver/queries/base.ts index be83efc39ca4c..b049439207e50 100644 --- a/x-pack/plugins/endpoint/server/routes/resolver/queries/base.ts +++ b/x-pack/plugins/endpoint/server/routes/resolver/queries/base.ts @@ -11,6 +11,7 @@ import { JsonObject } from '../../../../../../../src/plugins/kibana_utils/public export abstract class ResolverQuery { constructor( + private readonly indexPattern: string, private readonly endpointID?: string, private readonly pagination?: PaginationParams ) {} @@ -26,7 +27,7 @@ export abstract class ResolverQuery { if (this.endpointID) { return this.legacyQuery(this.endpointID, ids, EndpointAppConstants.LEGACY_EVENT_INDEX_NAME); } - return this.query(ids, EndpointAppConstants.EVENT_INDEX_NAME); + return this.query(ids, this.indexPattern); } async search(client: IScopedClusterClient, ...ids: string[]) { diff --git a/x-pack/plugins/endpoint/server/routes/resolver/queries/children.test.ts b/x-pack/plugins/endpoint/server/routes/resolver/queries/children.test.ts index 08a906e2884d6..e73053d53dee0 100644 --- a/x-pack/plugins/endpoint/server/routes/resolver/queries/children.test.ts +++ b/x-pack/plugins/endpoint/server/routes/resolver/queries/children.test.ts @@ -6,11 +6,17 @@ import { ChildrenQuery } from './children'; import { EndpointAppConstants } from '../../../../common/types'; +export const fakeEventIndexPattern = 'events-endpoint-*'; + describe('children events query', () => { it('generates the correct legacy queries', () => { const timestamp = new Date().getTime(); expect( - new ChildrenQuery('awesome-id', { size: 1, timestamp, eventID: 'foo' }).build('5') + new ChildrenQuery(EndpointAppConstants.LEGACY_EVENT_INDEX_NAME, 'awesome-id', { + size: 1, + timestamp, + eventID: 'foo', + }).build('5') ).toStrictEqual({ body: { query: { @@ -50,7 +56,11 @@ describe('children events query', () => { const timestamp = new Date().getTime(); expect( - new ChildrenQuery(undefined, { size: 1, timestamp, eventID: 'bar' }).build('baz') + new ChildrenQuery(fakeEventIndexPattern, undefined, { + size: 1, + timestamp, + eventID: 'bar', + }).build('baz') ).toStrictEqual({ body: { query: { @@ -88,7 +98,7 @@ describe('children events query', () => { size: 1, sort: [{ '@timestamp': 'asc' }, { 'event.id': 'asc' }], }, - index: EndpointAppConstants.EVENT_INDEX_NAME, + index: fakeEventIndexPattern, }); }); }); diff --git a/x-pack/plugins/endpoint/server/routes/resolver/queries/lifecycle.test.ts b/x-pack/plugins/endpoint/server/routes/resolver/queries/lifecycle.test.ts index b1b47bfb9de7f..8a3955706b278 100644 --- a/x-pack/plugins/endpoint/server/routes/resolver/queries/lifecycle.test.ts +++ b/x-pack/plugins/endpoint/server/routes/resolver/queries/lifecycle.test.ts @@ -5,10 +5,13 @@ */ import { EndpointAppConstants } from '../../../../common/types'; import { LifecycleQuery } from './lifecycle'; +import { fakeEventIndexPattern } from './children.test'; describe('lifecycle query', () => { it('generates the correct legacy queries', () => { - expect(new LifecycleQuery('awesome-id').build('5')).toStrictEqual({ + expect( + new LifecycleQuery(EndpointAppConstants.LEGACY_EVENT_INDEX_NAME, 'awesome-id').build('5') + ).toStrictEqual({ body: { query: { bool: { @@ -32,7 +35,7 @@ describe('lifecycle query', () => { }); it('generates the correct non-legacy queries', () => { - expect(new LifecycleQuery().build('baz')).toStrictEqual({ + expect(new LifecycleQuery(fakeEventIndexPattern).build('baz')).toStrictEqual({ body: { query: { bool: { @@ -57,7 +60,7 @@ describe('lifecycle query', () => { }, sort: [{ '@timestamp': 'asc' }], }, - index: EndpointAppConstants.EVENT_INDEX_NAME, + index: fakeEventIndexPattern, }); }); }); diff --git a/x-pack/plugins/endpoint/server/routes/resolver/queries/related_events.test.ts b/x-pack/plugins/endpoint/server/routes/resolver/queries/related_events.test.ts index a91c87274b8dd..5caef935ce621 100644 --- a/x-pack/plugins/endpoint/server/routes/resolver/queries/related_events.test.ts +++ b/x-pack/plugins/endpoint/server/routes/resolver/queries/related_events.test.ts @@ -5,12 +5,17 @@ */ import { RelatedEventsQuery } from './related_events'; import { EndpointAppConstants } from '../../../../common/types'; +import { fakeEventIndexPattern } from './children.test'; describe('related events query', () => { it('generates the correct legacy queries', () => { const timestamp = new Date().getTime(); expect( - new RelatedEventsQuery('awesome-id', { size: 1, timestamp, eventID: 'foo' }).build('5') + new RelatedEventsQuery(EndpointAppConstants.LEGACY_EVENT_INDEX_NAME, 'awesome-id', { + size: 1, + timestamp, + eventID: 'foo', + }).build('5') ).toStrictEqual({ body: { query: { @@ -51,7 +56,11 @@ describe('related events query', () => { const timestamp = new Date().getTime(); expect( - new RelatedEventsQuery(undefined, { size: 1, timestamp, eventID: 'bar' }).build('baz') + new RelatedEventsQuery(fakeEventIndexPattern, undefined, { + size: 1, + timestamp, + eventID: 'bar', + }).build('baz') ).toStrictEqual({ body: { query: { @@ -90,7 +99,7 @@ describe('related events query', () => { size: 1, sort: [{ '@timestamp': 'asc' }, { 'event.id': 'asc' }], }, - index: EndpointAppConstants.EVENT_INDEX_NAME, + index: fakeEventIndexPattern, }); }); }); diff --git a/x-pack/plugins/endpoint/server/routes/resolver/related_events.ts b/x-pack/plugins/endpoint/server/routes/resolver/related_events.ts index 804400522065c..46e205464f53c 100644 --- a/x-pack/plugins/endpoint/server/routes/resolver/related_events.ts +++ b/x-pack/plugins/endpoint/server/routes/resolver/related_events.ts @@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema'; import { RequestHandler, Logger } from 'kibana/server'; import { getPaginationParams } from './utils/pagination'; import { RelatedEventsQuery } from './queries/related_events'; +import { IndexPatternRetriever } from '../../index_pattern'; interface RelatedEventsQueryParams { after?: string; @@ -42,7 +43,8 @@ export const validateRelatedEvents = { }; export function handleRelatedEvents( - log: Logger + log: Logger, + indexRetriever: IndexPatternRetriever ): RequestHandler<RelatedEventsPathParams, RelatedEventsQueryParams> { return async (context, req, res) => { const { @@ -53,8 +55,9 @@ export function handleRelatedEvents( const pagination = getPaginationParams(limit, after); const client = context.core.elasticsearch.dataClient; + const indexPattern = await indexRetriever.getEventIndexPattern(context); // Retrieve the related non-process events for a given process - const relatedEventsQuery = new RelatedEventsQuery(legacyEndpointID, pagination); + const relatedEventsQuery = new RelatedEventsQuery(indexPattern, legacyEndpointID, pagination); const relatedEvents = await relatedEventsQuery.search(client, id); const { total, results: events, nextCursor } = relatedEvents; diff --git a/x-pack/plugins/endpoint/server/test_data/all_metadata_data.json b/x-pack/plugins/endpoint/server/test_data/all_metadata_data.json index 3c8486aa127ea..48952afb33f68 100644 --- a/x-pack/plugins/endpoint/server/test_data/all_metadata_data.json +++ b/x-pack/plugins/endpoint/server/test_data/all_metadata_data.json @@ -1,115 +1,109 @@ { - "took" : 343, - "timed_out" : false, - "_shards" : { - "total" : 1, - "successful" : 1, - "skipped" : 0, - "failed" : 0 + "took": 343, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "skipped": 0, + "failed": 0 }, - "hits" : { - "total" : { - "value" : 4, - "relation" : "eq" + "hits": { + "total": { + "value": 4, + "relation": "eq" }, - "max_score" : null, - "hits" : [ + "max_score": null, + "hits": [ { - "_index" : "endpoint-agent", - "_id" : "WqVo1G8BYQH1gtPUgYkC", - "_score" : null, - "_source" : { - "@timestamp" : 1579816615336, - "event" : { - "created" : "2020-01-23T21:56:55.336Z" + "_index": "metadata-endpoint-default-1", + "_id": "WqVo1G8BYQH1gtPUgYkC", + "_score": null, + "_source": { + "@timestamp": 1579816615336, + "event": { + "created": "2020-01-23T21:56:55.336Z" }, "elastic": { "agent": { "id": "56a75650-3c8a-4e4f-ac17-6dd729c650e2" } }, - "endpoint" : { - "policy" : { - "id" : "C2A9093E-E289-4C0A-AA44-8C32A414FA7A" + "endpoint": { + "policy": { + "id": "C2A9093E-E289-4C0A-AA44-8C32A414FA7A" } }, - "agent" : { - "version" : "6.8.3", - "id" : "56a75650-3c8a-4e4f-ac17-6dd729c650e2", + "agent": { + "version": "6.8.3", + "id": "56a75650-3c8a-4e4f-ac17-6dd729c650e2", "name": "Elastic Endpoint" }, - "host" : { - "id" : "7141a48b-e19f-4ae3-89a0-6e7179a84265", - "hostname" : "larimer-0.example.com", - "ip" : "10.21.48.136", - "mac" : "77-be-30-f0-e8-d6", - "architecture" : "x86_64", - "os" : { - "name" : "windows 6.2", - "full" : "Windows Server 2012", - "version" : "6.2", - "variant" : "Windows Server" + "host": { + "id": "7141a48b-e19f-4ae3-89a0-6e7179a84265", + "hostname": "larimer-0.example.com", + "ip": "10.21.48.136", + "mac": "77-be-30-f0-e8-d6", + "architecture": "x86_64", + "os": { + "name": "windows 6.2", + "full": "Windows Server 2012", + "version": "6.2", + "variant": "Windows Server" } } }, - "fields" : { - "host.id.keyword" : [ - "7141a48b-e19f-4ae3-89a0-6e7179a84265" - ] + "fields": { + "host.id.keyword": ["7141a48b-e19f-4ae3-89a0-6e7179a84265"] }, - "sort" : [ - 1579816615336 - ], - "inner_hits" : { - "most_recent" : { - "hits" : { - "total" : { - "value" : 2, - "relation" : "eq" + "sort": [1579816615336], + "inner_hits": { + "most_recent": { + "hits": { + "total": { + "value": 2, + "relation": "eq" }, - "max_score" : null, - "hits" : [ + "max_score": null, + "hits": [ { - "_index" : "endpoint-agent", - "_id" : "WqVo1G8BYQH1gtPUgYkC", - "_score" : null, - "_source" : { - "@timestamp" : 1579816615336, - "event" : { - "created" : "2020-01-23T21:56:55.336Z" + "_index": "metadata-endpoint-default-1", + "_id": "WqVo1G8BYQH1gtPUgYkC", + "_score": null, + "_source": { + "@timestamp": 1579816615336, + "event": { + "created": "2020-01-23T21:56:55.336Z" }, "elastic": { "agent": { "id": "56a75650-3c8a-4e4f-ac17-6dd729c650e2" } }, - "endpoint" : { - "policy" : { - "id" : "C2A9093E-E289-4C0A-AA44-8C32A414FA7A" + "endpoint": { + "policy": { + "id": "C2A9093E-E289-4C0A-AA44-8C32A414FA7A" } }, - "agent" : { - "version" : "6.8.3", - "id" : "56a75650-3c8a-4e4f-ac17-6dd729c650e2", + "agent": { + "version": "6.8.3", + "id": "56a75650-3c8a-4e4f-ac17-6dd729c650e2", "name": "Elastic Endpoint" }, - "host" : { - "id" : "7141a48b-e19f-4ae3-89a0-6e7179a84265", - "hostname" : "larimer-0.example.com", - "ip" : "10.21.48.136", - "mac" : "77-be-30-f0-e8-d6", - "architecture" : "x86_64", - "os" : { - "name" : "windows 6.2", - "full" : "Windows Server 2012", - "version" : "6.2", - "variant" : "Windows Server" + "host": { + "id": "7141a48b-e19f-4ae3-89a0-6e7179a84265", + "hostname": "larimer-0.example.com", + "ip": "10.21.48.136", + "mac": "77-be-30-f0-e8-d6", + "architecture": "x86_64", + "os": { + "name": "windows 6.2", + "full": "Windows Server 2012", + "version": "6.2", + "variant": "Windows Server" } } }, - "sort" : [ - 1579816615336 - ] + "sort": [1579816615336] } ] } @@ -117,101 +111,95 @@ } }, { - "_index" : "endpoint-agent", - "_id" : "W6Vo1G8BYQH1gtPUgYkC", - "_score" : null, - "_source" : { - "@timestamp" : 1579816615336, - "event" : { - "created" : "2020-01-23T21:56:55.336Z" + "_index": "metadata-endpoint-default-1", + "_id": "W6Vo1G8BYQH1gtPUgYkC", + "_score": null, + "_source": { + "@timestamp": 1579816615336, + "event": { + "created": "2020-01-23T21:56:55.336Z" }, "elastic": { "agent": { "id": "c2d84d8f-d355-40de-8b54-5d318d4d1312" } }, - "endpoint" : { - "policy" : { - "id" : "C2A9093E-E289-4C0A-AA44-8C32A414FA7A" + "endpoint": { + "policy": { + "id": "C2A9093E-E289-4C0A-AA44-8C32A414FA7A" } }, - "agent" : { - "version" : "6.4.3", - "id" : "c2d84d8f-d355-40de-8b54-5d318d4d1312", + "agent": { + "version": "6.4.3", + "id": "c2d84d8f-d355-40de-8b54-5d318d4d1312", "name": "Elastic Endpoint" }, - "host" : { - "id" : "f35ec6c1-6562-45b1-818f-2f14c0854adf", - "hostname" : "hildebrandt-6.example.com", - "ip" : "10.53.92.84", - "mac" : "af-f1-8f-51-25-2a", - "architecture" : "x86_64", - "os" : { - "name" : "windows 10.0", - "full" : "Windows 10", - "version" : "10.0", - "variant" : "Windows Pro" + "host": { + "id": "f35ec6c1-6562-45b1-818f-2f14c0854adf", + "hostname": "hildebrandt-6.example.com", + "ip": "10.53.92.84", + "mac": "af-f1-8f-51-25-2a", + "architecture": "x86_64", + "os": { + "name": "windows 10.0", + "full": "Windows 10", + "version": "10.0", + "variant": "Windows Pro" } } }, - "fields" : { - "host.id.keyword" : [ - "f35ec6c1-6562-45b1-818f-2f14c0854adf" - ] + "fields": { + "host.id.keyword": ["f35ec6c1-6562-45b1-818f-2f14c0854adf"] }, - "sort" : [ - 1579816615336 - ], - "inner_hits" : { - "most_recent" : { - "hits" : { - "total" : { - "value" : 2, - "relation" : "eq" + "sort": [1579816615336], + "inner_hits": { + "most_recent": { + "hits": { + "total": { + "value": 2, + "relation": "eq" }, - "max_score" : null, - "hits" : [ + "max_score": null, + "hits": [ { - "_index" : "endpoint-agent", - "_id" : "W6Vo1G8BYQH1gtPUgYkC", - "_score" : null, - "_source" : { - "@timestamp" : 1579816615336, - "event" : { - "created" : "2020-01-23T21:56:55.336Z" + "_index": "metadata-endpoint-default-1", + "_id": "W6Vo1G8BYQH1gtPUgYkC", + "_score": null, + "_source": { + "@timestamp": 1579816615336, + "event": { + "created": "2020-01-23T21:56:55.336Z" }, "elastic": { "agent": { "id": "c2d84d8f-d355-40de-8b54-5d318d4d1312" } }, - "endpoint" : { - "policy" : { - "id" : "C2A9093E-E289-4C0A-AA44-8C32A414FA7A" + "endpoint": { + "policy": { + "id": "C2A9093E-E289-4C0A-AA44-8C32A414FA7A" } }, - "agent" : { - "version" : "6.4.3", - "id" : "c2d84d8f-d355-40de-8b54-5d318d4d1312", + "agent": { + "version": "6.4.3", + "id": "c2d84d8f-d355-40de-8b54-5d318d4d1312", "name": "Elastic Endpoint" }, - "host" : { - "id" : "f35ec6c1-6562-45b1-818f-2f14c0854adf", - "hostname" : "hildebrandt-6.example.com", - "ip" : "10.53.92.84", - "mac" : "af-f1-8f-51-25-2a", - "architecture" : "x86_64", - "os" : { - "name" : "windows 10.0", - "full" : "Windows 10", - "version" : "10.0", - "variant" : "Windows Pro" + "host": { + "id": "f35ec6c1-6562-45b1-818f-2f14c0854adf", + "hostname": "hildebrandt-6.example.com", + "ip": "10.53.92.84", + "mac": "af-f1-8f-51-25-2a", + "architecture": "x86_64", + "os": { + "name": "windows 10.0", + "full": "Windows 10", + "version": "10.0", + "variant": "Windows Pro" } } }, - "sort" : [ - 1579816615336 - ] + "sort": [1579816615336] } ] } @@ -220,9 +208,9 @@ } ] }, - "aggregations" : { - "total" : { - "value" : 2 + "aggregations": { + "total": { + "value": 2 } } } diff --git a/x-pack/plugins/endpoint/server/types.ts b/x-pack/plugins/endpoint/server/types.ts index 6dc128bd3d61e..46a23060339f4 100644 --- a/x-pack/plugins/endpoint/server/types.ts +++ b/x-pack/plugins/endpoint/server/types.ts @@ -5,11 +5,13 @@ */ import { LoggerFactory } from 'kibana/server'; import { EndpointConfigType } from './config'; +import { IndexPatternRetriever } from './index_pattern'; /** * The context for Endpoint apps. */ export interface EndpointAppContext { + indexPatternRetriever: IndexPatternRetriever; logFactory: LoggerFactory; config(): Promise<EndpointConfigType>; } diff --git a/x-pack/plugins/es_ui_shared/console_lang/ace/modes/index.ts b/x-pack/plugins/es_ui_shared/console_lang/ace/modes/index.ts deleted file mode 100644 index ae3c9962ecadb..0000000000000 --- a/x-pack/plugins/es_ui_shared/console_lang/ace/modes/index.ts +++ /dev/null @@ -1,7 +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 { installXJsonMode, XJsonMode } from './x_json'; diff --git a/x-pack/plugins/es_ui_shared/console_lang/ace/modes/x_json/index.ts b/x-pack/plugins/es_ui_shared/console_lang/ace/modes/x_json/index.ts deleted file mode 100644 index ae3c9962ecadb..0000000000000 --- a/x-pack/plugins/es_ui_shared/console_lang/ace/modes/x_json/index.ts +++ /dev/null @@ -1,7 +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 { installXJsonMode, XJsonMode } from './x_json'; diff --git a/x-pack/plugins/es_ui_shared/console_lang/ace/modes/x_json/worker/index.ts b/x-pack/plugins/es_ui_shared/console_lang/ace/modes/x_json/worker/index.ts deleted file mode 100644 index 0e40fd335dd31..0000000000000 --- a/x-pack/plugins/es_ui_shared/console_lang/ace/modes/x_json/worker/index.ts +++ /dev/null @@ -1,13 +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. - */ - -// @ts-ignore -import src from '!!raw-loader!./worker.js'; - -export const workerModule = { - id: 'ace/mode/json_worker', - src, -}; diff --git a/x-pack/plugins/es_ui_shared/console_lang/ace/modes/x_json/worker/worker.d.ts b/x-pack/plugins/es_ui_shared/console_lang/ace/modes/x_json/worker/worker.d.ts deleted file mode 100644 index 4ebad4f2ef91c..0000000000000 --- a/x-pack/plugins/es_ui_shared/console_lang/ace/modes/x_json/worker/worker.d.ts +++ /dev/null @@ -1,12 +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. - */ - -// Satisfy TS's requirements that the module be declared per './index.ts'. -declare module '!!raw-loader!./worker.js' { - const content: string; - // eslint-disable-next-line import/no-default-export - export default content; -} diff --git a/x-pack/plugins/es_ui_shared/console_lang/ace/modes/x_json/x_json.ts b/x-pack/plugins/es_ui_shared/console_lang/ace/modes/x_json/x_json.ts deleted file mode 100644 index bfeca045bea02..0000000000000 --- a/x-pack/plugins/es_ui_shared/console_lang/ace/modes/x_json/x_json.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import ace from 'brace'; -import { XJsonMode } from '../../../../../../../src/plugins/es_ui_shared/console_lang'; -import { workerModule } from './worker'; -const { WorkerClient } = ace.acequire('ace/worker/worker_client'); - -// Then clobber `createWorker` method to install our worker source. Per ace's wiki: https://github.com/ajaxorg/ace/wiki/Syntax-validation -(XJsonMode.prototype as any).createWorker = function(session: ace.IEditSession) { - const xJsonWorker = new WorkerClient(['ace'], workerModule, 'JsonWorker'); - - xJsonWorker.attachToDocument(session.getDocument()); - - xJsonWorker.on('annotate', function(e: { data: any }) { - session.setAnnotations(e.data); - }); - - xJsonWorker.on('terminate', function() { - session.clearAnnotations(); - }); - - return xJsonWorker; -}; - -export { XJsonMode }; - -export function installXJsonMode(editor: ace.Editor) { - const session = editor.getSession(); - session.setMode(new (XJsonMode as any)()); -} diff --git a/x-pack/plugins/es_ui_shared/console_lang/index.ts b/x-pack/plugins/es_ui_shared/console_lang/index.ts deleted file mode 100644 index b5fe3a554e34d..0000000000000 --- a/x-pack/plugins/es_ui_shared/console_lang/index.ts +++ /dev/null @@ -1,7 +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 { XJsonMode, installXJsonMode } from './ace/modes'; diff --git a/x-pack/plugins/es_ui_shared/console_lang/mocks.ts b/x-pack/plugins/es_ui_shared/console_lang/mocks.ts deleted file mode 100644 index 68480282ddc03..0000000000000 --- a/x-pack/plugins/es_ui_shared/console_lang/mocks.ts +++ /dev/null @@ -1,9 +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. - */ - -jest.mock('./ace/modes/x_json/worker', () => ({ - workerModule: { id: 'ace/mode/json_worker', src: '' }, -})); diff --git a/x-pack/plugins/event_log/README.md b/x-pack/plugins/event_log/README.md index 027bbc694801f..38364033cb70b 100644 --- a/x-pack/plugins/event_log/README.md +++ b/x-pack/plugins/event_log/README.md @@ -125,7 +125,6 @@ Here's the event written to the event log index: "duration": 1000000 }, "kibana": { - "namespace": "default", "saved_objects": [ { "type": "action", diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json index ab1b4096d17f2..9c1dff60f9727 100644 --- a/x-pack/plugins/event_log/generated/mappings.json +++ b/x-pack/plugins/event_log/generated/mappings.json @@ -72,10 +72,6 @@ "type": "keyword", "ignore_above": 1024 }, - "namespace": { - "type": "keyword", - "ignore_above": 1024 - }, "alerting": { "properties": { "instance_id": { @@ -86,7 +82,7 @@ }, "saved_objects": { "properties": { - "store": { + "namespace": { "type": "keyword", "ignore_above": 1024 }, diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts index b731093b33b06..5e93f320c009f 100644 --- a/x-pack/plugins/event_log/generated/schemas.ts +++ b/x-pack/plugins/event_log/generated/schemas.ts @@ -56,7 +56,6 @@ export const EventSchema = schema.maybe( kibana: schema.maybe( schema.object({ server_uuid: ecsString(), - namespace: ecsString(), alerting: schema.maybe( schema.object({ instance_id: ecsString(), @@ -65,7 +64,7 @@ export const EventSchema = schema.maybe( saved_objects: schema.maybe( schema.arrayOf( schema.object({ - store: ecsString(), + namespace: ecsString(), id: ecsString(), type: ecsString(), }) diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js index 9e721b06ec335..de3c9d631fbca 100644 --- a/x-pack/plugins/event_log/scripts/mappings.js +++ b/x-pack/plugins/event_log/scripts/mappings.js @@ -20,17 +20,12 @@ exports.EcsKibanaExtensionsMappings = { }, }, }, - // relevant kibana space - namespace: { - type: 'keyword', - ignore_above: 1024, - }, // array of saved object references, for "linking" via search saved_objects: { type: 'nested', properties: { - // 'kibana' for typical saved object, 'task_manager' for TM, etc - store: { + // relevant kibana space + namespace: { type: 'keyword', ignore_above: 1024, }, @@ -61,9 +56,8 @@ exports.EcsEventLogProperties = [ 'error.message', 'user.name', 'kibana.server_uuid', - 'kibana.namespace', 'kibana.alerting.instance_id', - 'kibana.saved_objects.store', + 'kibana.saved_objects.namespace', 'kibana.saved_objects.id', 'kibana.saved_objects.name', 'kibana.saved_objects.type', diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts index ae26d7a7ece07..986486902c3fa 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts @@ -21,7 +21,7 @@ beforeEach(() => { clusterClient = elasticsearchServiceMock.createClusterClient(); clusterClientAdapter = new ClusterClientAdapter({ logger, - clusterClient, + clusterClientPromise: Promise.resolve(clusterClient), }); }); diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts index 36bc94edfca4e..409bb2d00e161 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts @@ -14,7 +14,7 @@ export type IClusterClientAdapter = PublicMethodsOf<ClusterClientAdapter>; export interface ConstructorOpts { logger: Logger; - clusterClient: EsClusterClient; + clusterClientPromise: Promise<EsClusterClient>; } export interface QueryEventsBySavedObjectResult { @@ -26,11 +26,11 @@ export interface QueryEventsBySavedObjectResult { export class ClusterClientAdapter { private readonly logger: Logger; - private readonly clusterClient: EsClusterClient; + private readonly clusterClientPromise: Promise<EsClusterClient>; constructor(opts: ConstructorOpts) { this.logger = opts.logger; - this.clusterClient = opts.clusterClient; + this.clusterClientPromise = opts.clusterClientPromise; } public async indexDocument(doc: any): Promise<void> { @@ -201,7 +201,8 @@ export class ClusterClientAdapter { private async callEs(operation: string, body?: any): Promise<any> { try { this.debug(`callEs(${operation}) calls:`, body); - const result = await this.clusterClient.callAsInternalUser(operation, body); + const clusterClient = await this.clusterClientPromise; + const result = await clusterClient.callAsInternalUser(operation, body); this.debug(`callEs(${operation}) result:`, result); return result; } catch (err) { diff --git a/x-pack/plugins/event_log/server/es/context.ts b/x-pack/plugins/event_log/server/es/context.ts index 144f44ac8e5ea..b8e351367b695 100644 --- a/x-pack/plugins/event_log/server/es/context.ts +++ b/x-pack/plugins/event_log/server/es/context.ts @@ -32,7 +32,7 @@ export function createEsContext(params: EsContextCtorParams): EsContext { export interface EsContextCtorParams { logger: Logger; - clusterClient: EsClusterClient; + clusterClientPromise: Promise<EsClusterClient>; indexNameRoot: string; } @@ -50,7 +50,7 @@ class EsContextImpl implements EsContext { this.initialized = false; this.esAdapter = new ClusterClientAdapter({ logger: params.logger, - clusterClient: params.clusterClient, + clusterClientPromise: params.clusterClientPromise, }); } diff --git a/x-pack/plugins/event_log/server/plugin.ts b/x-pack/plugins/event_log/server/plugin.ts index 2cc41354b4fbc..e5034f599f118 100644 --- a/x-pack/plugins/event_log/server/plugin.ts +++ b/x-pack/plugins/event_log/server/plugin.ts @@ -66,7 +66,9 @@ export class Plugin implements CorePlugin<IEventLogService, IEventLogClientServi logger: this.systemLogger, // TODO: get index prefix from config.get(kibana.index) indexNameRoot: kibanaIndex, - clusterClient: core.elasticsearch.adminClient, + clusterClientPromise: core + .getStartServices() + .then(([{ elasticsearch }]) => elasticsearch.legacy.client), }); this.eventLogService = new EventLogService({ diff --git a/x-pack/plugins/event_log/server/routes/_mock_handler_arguments.ts b/x-pack/plugins/event_log/server/routes/_mock_handler_arguments.ts index 6640683bf6005..19933649277aa 100644 --- a/x-pack/plugins/event_log/server/routes/_mock_handler_arguments.ts +++ b/x-pack/plugins/event_log/server/routes/_mock_handler_arguments.ts @@ -50,9 +50,9 @@ export function fakeEvent(overrides = {}) { duration: 1000000, }, kibana: { - namespace: 'default', saved_objects: [ { + namespace: 'default', type: 'action', id: '968f1b82-0414-4a10-becc-56b6473e4a29', }, 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 73c399878b17b..35dcc4cf42b37 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 @@ -135,6 +135,42 @@ 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']), + }, + ], + }, + }), + ]) + ).toEqual({ + catalogue: {}, + newFeature: { + capability1: true, + capability2: true, + capability3: true, + capability4: true, + capability5: true, + }, + }); + }); + it(`supports merging features with sub privileges`, () => { expect( uiCapabilitiesForFeatures([ 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 d3d3230822749..e6ff3ad4383d2 100644 --- a/x-pack/plugins/features/server/ui_capabilities_for_features.ts +++ b/x-pack/plugins/features/server/ui_capabilities_for_features.ts @@ -45,6 +45,9 @@ function getCapabilitiesFromFeature(feature: Feature): FeatureCapabilities { ...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 => { UIFeatureCapabilities[feature.id] = { diff --git a/x-pack/plugins/file_upload/kibana.json b/x-pack/plugins/file_upload/kibana.json index 3fda32fb6ebe5..7676a01d0b0f9 100644 --- a/x-pack/plugins/file_upload/kibana.json +++ b/x-pack/plugins/file_upload/kibana.json @@ -1,8 +1,7 @@ { - "id": "file_upload", + "id": "fileUpload", "version": "8.0.0", "kibanaVersion": "kibana", - "configPath": ["xpack", "file_upload"], "server": true, "ui": true, "requiredPlugins": ["data", "usageCollection"] diff --git a/x-pack/plugins/file_upload/server/telemetry/mappings.ts b/x-pack/plugins/file_upload/server/telemetry/mappings.ts index ca935fea3449a..97a5ed9eeef82 100644 --- a/x-pack/plugins/file_upload/server/telemetry/mappings.ts +++ b/x-pack/plugins/file_upload/server/telemetry/mappings.ts @@ -10,7 +10,7 @@ import { TELEMETRY_DOC_ID } from './telemetry'; export const fileUploadTelemetryMappingsType: SavedObjectsType = { name: TELEMETRY_DOC_ID, hidden: false, - namespaceAgnostic: true, + namespaceType: 'agnostic', mappings: { properties: { filesUploadedTotalCount: { diff --git a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.js.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.js.snap rename to x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.js.snap diff --git a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.js.snap b/x-pack/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.js.snap rename to x-pack/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.js.snap diff --git a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js similarity index 96% rename from x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js rename to x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js index 9d143c4d3fc8e..bf4de823f1833 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js @@ -13,13 +13,13 @@ import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import sinon from 'sinon'; import { findTestSubject } from '@elastic/eui/lib/test'; -import { mountWithIntl } from '../../../../../test_utils/enzyme_helpers'; -import { fetchedPolicies, fetchedNodes } from '../../public/np_ready/application/store/actions'; -import { indexLifecycleManagementStore } from '../../public/np_ready/application/store'; -import { EditPolicy } from '../../public/np_ready/application/sections/edit_policy'; -import { init as initHttp } from '../../public/np_ready/application/services/http'; -import { init as initUiMetric } from '../../public/np_ready/application/services/ui_metric'; -import { init as initNotification } from '../../public/np_ready/application/services/notification'; +import { mountWithIntl } from '../../../../test_utils/enzyme_helpers'; +import { fetchedPolicies, fetchedNodes } from '../../public/application/store/actions'; +import { indexLifecycleManagementStore } from '../../public/application/store'; +import { EditPolicy } from '../../public/application/sections/edit_policy'; +import { init as initHttp } from '../../public/application/services/http'; +import { init as initUiMetric } from '../../public/application/services/ui_metric'; +import { init as initNotification } from '../../public/application/services/notification'; import { positiveNumbersAboveZeroErrorMessage, positiveNumberRequiredMessage, @@ -33,16 +33,14 @@ import { policyNameMustBeDifferentErrorMessage, policyNameAlreadyUsedErrorMessage, maximumDocumentsRequiredMessage, -} from '../../public/np_ready/application/store/selectors/lifecycle'; +} from '../../public/application/store/selectors/lifecycle'; initHttp(axios.create({ adapter: axiosXhrAdapter }), path => path); -initUiMetric(() => () => {}); +initUiMetric({ reportUiStats: () => {} }); initNotification({ addDanger: () => {}, }); -jest.mock('ui/new_platform'); - let server; let store; const policy = { diff --git a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/policy_table.test.js b/x-pack/plugins/index_lifecycle_management/__jest__/components/policy_table.test.js similarity index 91% rename from x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/policy_table.test.js rename to x-pack/plugins/index_lifecycle_management/__jest__/components/policy_table.test.js index a3a9c5e59bfa4..78c5c181eea62 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/policy_table.test.js +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/policy_table.test.js @@ -12,17 +12,15 @@ import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import sinon from 'sinon'; import { findTestSubject, takeMountedSnapshot } from '@elastic/eui/lib/test'; -import { mountWithIntl } from '../../../../../test_utils/enzyme_helpers'; -import { fetchedPolicies } from '../../public/np_ready/application/store/actions'; -import { indexLifecycleManagementStore } from '../../public/np_ready/application/store'; -import { PolicyTable } from '../../public/np_ready/application/sections/policy_table'; -import { init as initHttp } from '../../public/np_ready/application/services/http'; -import { init as initUiMetric } from '../../public/np_ready/application/services/ui_metric'; +import { mountWithIntl } from '../../../../test_utils/enzyme_helpers'; +import { fetchedPolicies } from '../../public/application/store/actions'; +import { indexLifecycleManagementStore } from '../../public/application/store'; +import { PolicyTable } from '../../public/application/sections/policy_table'; +import { init as initHttp } from '../../public/application/services/http'; +import { init as initUiMetric } from '../../public/application/services/ui_metric'; initHttp(axios.create({ adapter: axiosXhrAdapter }), path => path); -initUiMetric(() => () => {}); - -jest.mock('ui/new_platform'); +initUiMetric({ reportUiStats: () => {} }); let server = null; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/extend_index_management.test.js b/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.js similarity index 93% rename from x-pack/legacy/plugins/index_lifecycle_management/__jest__/extend_index_management.test.js rename to x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.js index d2619778617c3..900de27ca36ab 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/extend_index_management.test.js +++ b/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.js @@ -8,7 +8,7 @@ import moment from 'moment-timezone'; import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; -import { mountWithIntl } from '../../../../test_utils/enzyme_helpers'; +import { mountWithIntl } from '../../../test_utils/enzyme_helpers'; import { retryLifecycleActionExtension, removeLifecyclePolicyActionExtension, @@ -16,21 +16,18 @@ import { ilmBannerExtension, ilmFilterExtension, ilmSummaryExtension, -} from '../public/np_ready/extend_index_management'; -import { init as initHttp } from '../public/np_ready/application/services/http'; -import { init as initUiMetric } from '../public/np_ready/application/services/ui_metric'; +} from '../public/extend_index_management'; +import { init as initHttp } from '../public/application/services/http'; +import { init as initUiMetric } from '../public/application/services/ui_metric'; // We need to init the http with a mock for any tests that depend upon the http service. // For example, add_lifecycle_confirm_modal makes an API request in its componentDidMount // lifecycle method. If we don't mock this, CI will fail with "Call retries were exceeded". initHttp(axios.create({ adapter: axiosXhrAdapter }), path => path); -initUiMetric(() => () => {}); +initUiMetric({ reportUiStats: () => {} }); -jest.mock('ui/new_platform'); -jest.mock('../../../../plugins/index_management/public', async () => { - const { indexManagementMock } = await import( - '../../../../plugins/index_management/public/mocks.ts' - ); +jest.mock('../../../plugins/index_management/public', async () => { + const { indexManagementMock } = await import('../../../plugins/index_management/public/mocks.ts'); return indexManagementMock.createSetup(); }); diff --git a/x-pack/plugins/index_lifecycle_management/common/constants/index.ts b/x-pack/plugins/index_lifecycle_management/common/constants/index.ts new file mode 100644 index 0000000000000..700039985eaf5 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/common/constants/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { LicenseType } from '../../../licensing/common/types'; + +const basicLicense: LicenseType = 'basic'; + +export const PLUGIN = { + ID: 'index_lifecycle_management', + minimumLicenseType: basicLicense, + TITLE: i18n.translate('xpack.indexLifecycleMgmt.appTitle', { + defaultMessage: 'Index Lifecycle Policies', + }), +}; + +export const BASE_PATH = '/management/elasticsearch/index_lifecycle_management/'; + +export const API_BASE_PATH = '/api/index_lifecycle_management'; diff --git a/x-pack/plugins/index_lifecycle_management/kibana.json b/x-pack/plugins/index_lifecycle_management/kibana.json new file mode 100644 index 0000000000000..6385646b95789 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/kibana.json @@ -0,0 +1,16 @@ +{ + "id": "indexLifecycleManagement", + "version": "kibana", + "server": true, + "ui": true, + "requiredPlugins": [ + "home", + "licensing", + "management" + ], + "optionalPlugins": [ + "usageCollection", + "indexManagement" + ], + "configPath": ["xpack", "ilm"] +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/app.tsx b/x-pack/plugins/index_lifecycle_management/public/application/app.tsx new file mode 100644 index 0000000000000..993dced20bbe6 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/app.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, { useEffect } from 'react'; +import { HashRouter, Switch, Route, Redirect } from 'react-router-dom'; +import { METRIC_TYPE } from '@kbn/analytics'; + +import { BASE_PATH } from '../../common/constants'; +import { UIM_APP_LOAD } from './constants'; +import { EditPolicy } from './sections/edit_policy'; +import { PolicyTable } from './sections/policy_table'; +import { trackUiMetric } from './services/ui_metric'; + +export const App = () => { + useEffect(() => trackUiMetric(METRIC_TYPE.LOADED, UIM_APP_LOAD), []); + + return ( + <HashRouter> + <Switch> + <Redirect exact from={`${BASE_PATH}`} to={`${BASE_PATH}policies`} /> + <Route exact path={`${BASE_PATH}policies`} component={PolicyTable} /> + <Route path={`${BASE_PATH}policies/edit/:policyName?`} component={EditPolicy} /> + </Switch> + </HashRouter> + ); +}; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/constants/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/constants/index.ts similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/constants/index.ts rename to x-pack/plugins/index_lifecycle_management/public/application/constants/index.ts diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/constants/ui_metric.ts b/x-pack/plugins/index_lifecycle_management/public/application/constants/ui_metric.ts similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/constants/ui_metric.ts rename to x-pack/plugins/index_lifecycle_management/public/application/constants/ui_metric.ts diff --git a/x-pack/plugins/index_lifecycle_management/public/application/index.tsx b/x-pack/plugins/index_lifecycle_management/public/application/index.tsx new file mode 100644 index 0000000000000..a7d88d31e58fc --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/index.tsx @@ -0,0 +1,27 @@ +/* + * 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 { render, unmountComponentAtNode } from 'react-dom'; +import { Provider } from 'react-redux'; +import { I18nStart } from 'kibana/public'; +import { UnmountCallback } from 'src/core/public'; + +import { App } from './app'; +import { indexLifecycleManagementStore } from './store'; + +export const renderApp = (element: Element, I18nContext: I18nStart['Context']): UnmountCallback => { + render( + <I18nContext> + <Provider store={indexLifecycleManagementStore()}> + <App /> + </Provider> + </I18nContext>, + element + ); + + return () => unmountComponentAtNode(element); +}; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/components/active_badge.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/components/active_badge.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/components/active_badge.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/components/active_badge.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/components/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/components/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/components/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/components/index.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/components/learn_more_link.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/components/learn_more_link.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/components/learn_more_link.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/components/learn_more_link.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/components/optional_label.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/components/optional_label.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/components/optional_label.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/components/optional_label.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/components/phase_error_message.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/components/phase_error_message.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/components/phase_error_message.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/components/phase_error_message.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/cold_phase/cold_phase.container.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/cold_phase/cold_phase.container.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/cold_phase/cold_phase.container.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/cold_phase/cold_phase.container.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/cold_phase/cold_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/cold_phase/cold_phase.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/cold_phase/cold_phase.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/cold_phase/cold_phase.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/cold_phase/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/cold_phase/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/cold_phase/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/cold_phase/index.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/delete_phase/delete_phase.container.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/delete_phase/delete_phase.container.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/delete_phase/delete_phase.container.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/delete_phase/delete_phase.container.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/delete_phase/delete_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/delete_phase/delete_phase.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/delete_phase/delete_phase.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/delete_phase/delete_phase.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/delete_phase/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/delete_phase/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/delete_phase/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/delete_phase/index.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/hot_phase/hot_phase.container.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/hot_phase/hot_phase.container.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/hot_phase/hot_phase.container.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/hot_phase/hot_phase.container.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/hot_phase/hot_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/hot_phase/hot_phase.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/hot_phase/hot_phase.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/hot_phase/hot_phase.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/hot_phase/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/hot_phase/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/hot_phase/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/hot_phase/index.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/min_age_input.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/min_age_input.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/node_allocation/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/node_allocation/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation/index.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/node_allocation/node_allocation.container.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation/node_allocation.container.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/node_allocation/node_allocation.container.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation/node_allocation.container.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/node_allocation/node_allocation.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation/node_allocation.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/node_allocation/node_allocation.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation/node_allocation.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/node_attrs_details/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_attrs_details/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/node_attrs_details/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_attrs_details/index.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/node_attrs_details/node_attrs_details.container.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_attrs_details/node_attrs_details.container.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/node_attrs_details/node_attrs_details.container.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_attrs_details/node_attrs_details.container.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/node_attrs_details/node_attrs_details.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_attrs_details/node_attrs_details.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/node_attrs_details/node_attrs_details.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_attrs_details/node_attrs_details.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/policy_json_flyout.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/policy_json_flyout.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/set_priority_input.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/set_priority_input.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/set_priority_input.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/set_priority_input.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/warm_phase/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/warm_phase/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/warm_phase/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/warm_phase/index.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/warm_phase/warm_phase.container.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/warm_phase/warm_phase.container.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/warm_phase/warm_phase.container.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/warm_phase/warm_phase.container.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/warm_phase/warm_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/warm_phase/warm_phase.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/components/warm_phase/warm_phase.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/warm_phase/warm_phase.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/edit_policy.container.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/edit_policy.container.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/edit_policy.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/edit_policy.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/form_errors.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_errors.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/form_errors.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_errors.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/index.d.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.d.ts similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/index.d.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.d.ts diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/edit_policy/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/no_match/components/no_match/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/no_match/components/no_match/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/no_match/components/no_match/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/no_match/components/no_match/index.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/no_match/components/no_match/no_match.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/no_match/components/no_match/no_match.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/no_match/components/no_match/no_match.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/no_match/components/no_match/no_match.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/no_match/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/no_match/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/no_match/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/no_match/index.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/policy_table/add_policy_to_template_confirm_modal.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/add_policy_to_template_confirm_modal.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/policy_table/add_policy_to_template_confirm_modal.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/add_policy_to_template_confirm_modal.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/policy_table/confirm_delete.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/confirm_delete.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/policy_table/confirm_delete.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/confirm_delete.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/policy_table/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/policy_table/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/index.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/policy_table/policy_table.container.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.container.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/policy_table/policy_table.container.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.container.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/policy_table/policy_table.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.js similarity index 98% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/policy_table/policy_table.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.js index 903161fe094fc..d406d86bc6ce7 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/components/policy_table/policy_table.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.js @@ -37,8 +37,8 @@ import { import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; -import { getIndexListUri } from '../../../../../../../../../../plugins/index_management/public'; -import { BASE_PATH } from '../../../../../../../common/constants'; +import { getIndexListUri } from '../../../../../../../index_management/public'; +import { BASE_PATH } from '../../../../../../common/constants'; import { UIM_EDIT_CLICK } from '../../../../constants'; import { getPolicyPath } from '../../../../services/navigation'; import { flattenPanelTree } from '../../../../services/flatten_panel_tree'; @@ -52,6 +52,7 @@ const COLUMNS = { label: i18n.translate('xpack.indexLifecycleMgmt.policyTable.headers.nameHeader', { defaultMessage: 'Name', }), + width: 200, }, linkedIndices: { label: i18n.translate('xpack.indexLifecycleMgmt.policyTable.headers.linkedIndicesHeader', { @@ -179,7 +180,6 @@ export class PolicyTable extends Component { return ( /* eslint-disable-next-line @elastic/eui/href-or-on-click */ <EuiLink - className="policyTable__link" data-test-subj="policyTablePolicyNameLink" href={getPolicyPath(value)} onClick={() => trackUiMetric('click', UIM_EDIT_CLICK)} @@ -415,7 +415,7 @@ export class PolicyTable extends Component { tableContent = <EuiLoadingSpinner size="m" />; } else if (totalNumberOfPolicies > 0) { tableContent = ( - <EuiTable className="policyTable__horizontalScroll"> + <EuiTable> <EuiScreenReaderOnly> <caption role="status" aria-relevant="text" aria-live="polite"> <FormattedMessage diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/index.d.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/index.d.ts similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/index.d.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/index.d.ts diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/sections/policy_table/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/index.js diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/api.js b/x-pack/plugins/index_lifecycle_management/public/application/services/api.js new file mode 100644 index 0000000000000..1cb2089ab66db --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/api.js @@ -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 { + UIM_POLICY_DELETE, + UIM_POLICY_ATTACH_INDEX, + UIM_POLICY_ATTACH_INDEX_TEMPLATE, + UIM_POLICY_DETACH_INDEX, + UIM_INDEX_RETRY_STEP, +} from '../constants'; + +import { trackUiMetric } from './ui_metric'; +import { sendGet, sendPost, sendDelete } from './http'; + +export async function loadNodes() { + return await sendGet(`nodes/list`); +} + +export async function loadNodeDetails(selectedNodeAttrs) { + return await sendGet(`nodes/${selectedNodeAttrs}/details`); +} + +export async function loadIndexTemplates() { + return await sendGet(`templates`); +} + +export async function loadPolicies(withIndices) { + const query = withIndices ? '?withIndices=true' : ''; + return await sendGet('policies', query); +} + +export async function savePolicy(policy) { + return await sendPost(`policies`, policy); +} + +export async function deletePolicy(policyName) { + const response = await sendDelete(`policies/${encodeURIComponent(policyName)}`); + // Only track successful actions. + trackUiMetric('count', UIM_POLICY_DELETE); + return response; +} + +export const retryLifecycleForIndex = async indexNames => { + const response = await sendPost(`index/retry`, { indexNames }); + // Only track successful actions. + trackUiMetric('count', UIM_INDEX_RETRY_STEP); + return response; +}; + +export const removeLifecycleForIndex = async indexNames => { + const response = await sendPost(`index/remove`, { indexNames }); + // Only track successful actions. + trackUiMetric('count', UIM_POLICY_DETACH_INDEX); + return response; +}; + +export const addLifecyclePolicyToIndex = async body => { + const response = await sendPost(`index/add`, body); + // Only track successful actions. + trackUiMetric('count', UIM_POLICY_ATTACH_INDEX); + return response; +}; + +export const addLifecyclePolicyToTemplate = async body => { + const response = await sendPost(`template`, body); + // Only track successful actions. + trackUiMetric('count', UIM_POLICY_ATTACH_INDEX_TEMPLATE); + return response; +}; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/api_errors.js b/x-pack/plugins/index_lifecycle_management/public/application/services/api_errors.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/api_errors.js rename to x-pack/plugins/index_lifecycle_management/public/application/services/api_errors.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/documentation.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/documentation.ts similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/documentation.ts rename to x-pack/plugins/index_lifecycle_management/public/application/services/documentation.ts diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/filter_items.js b/x-pack/plugins/index_lifecycle_management/public/application/services/filter_items.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/filter_items.js rename to x-pack/plugins/index_lifecycle_management/public/application/services/filter_items.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/find_errors.js b/x-pack/plugins/index_lifecycle_management/public/application/services/find_errors.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/find_errors.js rename to x-pack/plugins/index_lifecycle_management/public/application/services/find_errors.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/flatten_panel_tree.js b/x-pack/plugins/index_lifecycle_management/public/application/services/flatten_panel_tree.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/flatten_panel_tree.js rename to x-pack/plugins/index_lifecycle_management/public/application/services/flatten_panel_tree.js diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/http.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/http.ts new file mode 100644 index 0000000000000..47e96ea28bb8c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/http.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. + */ + +let _httpClient: any; + +export function init(httpClient: any): void { + _httpClient = httpClient; +} + +function getFullPath(path: string): string { + const apiPrefix = '/api/index_lifecycle_management'; + + if (path) { + return `${apiPrefix}/${path}`; + } + + return apiPrefix; +} + +export function sendPost(path: string, payload: any): any { + return _httpClient.post(getFullPath(path), { body: JSON.stringify(payload) }); +} + +export function sendGet(path: string, query: any): any { + return _httpClient.get(getFullPath(path), { query }); +} + +export function sendDelete(path: string): any { + return _httpClient.delete(getFullPath(path)); +} diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/index.js b/x-pack/plugins/index_lifecycle_management/public/application/services/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/services/index.js diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/navigation.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/navigation.ts new file mode 100644 index 0000000000000..2d518ebb3015e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/navigation.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 { BASE_PATH } from '../../../common/constants'; + +export const goToPolicyList = () => { + window.location.hash = `${BASE_PATH}policies`; +}; + +export const getPolicyPath = (policyName: string): string => { + return encodeURI(`#${BASE_PATH}policies/edit/${encodeURIComponent(policyName)}`); +}; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/notification.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/notification.ts similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/notification.ts rename to x-pack/plugins/index_lifecycle_management/public/application/services/notification.ts diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/sort_table.js b/x-pack/plugins/index_lifecycle_management/public/application/services/sort_table.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/sort_table.js rename to x-pack/plugins/index_lifecycle_management/public/application/services/sort_table.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/ui_metric.test.js b/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/ui_metric.test.js rename to x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/ui_metric.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.ts similarity index 84% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/ui_metric.ts rename to x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.ts index d9f2c26048317..ca987441c7ce9 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/services/ui_metric.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.ts @@ -6,7 +6,8 @@ import { get } from 'lodash'; -import { createUiStatsReporter } from '../../../../../../../../src/legacy/core_plugins/ui_metric/public'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; +import { UiStatsMetricType } from '@kbn/analytics'; import { UIM_APP_NAME, @@ -22,11 +23,11 @@ import { import { defaultColdPhase, defaultWarmPhase, defaultHotPhase } from '../store/defaults'; -export let trackUiMetric: ReturnType<typeof createUiStatsReporter>; +export let trackUiMetric = (metricType: UiStatsMetricType, eventName: string) => {}; -export function init(getReporter: typeof createUiStatsReporter): void { - if (getReporter) { - trackUiMetric = getReporter(UIM_APP_NAME); +export function init(usageCollection?: UsageCollectionSetup): void { + if (usageCollection) { + trackUiMetric = usageCollection.reportUiStats.bind(usageCollection, UIM_APP_NAME); } } diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/actions/general.js b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/general.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/actions/general.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/actions/general.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/actions/index.js b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/actions/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/actions/index.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/actions/lifecycle.js b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/lifecycle.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/actions/lifecycle.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/actions/lifecycle.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/actions/nodes.js b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/nodes.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/actions/nodes.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/actions/nodes.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/actions/policies.js b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/policies.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/actions/policies.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/actions/policies.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/defaults/cold_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/cold_phase.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/defaults/cold_phase.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/defaults/cold_phase.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/defaults/delete_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/delete_phase.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/defaults/delete_phase.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/defaults/delete_phase.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/defaults/hot_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/hot_phase.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/defaults/hot_phase.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/defaults/hot_phase.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/defaults/index.d.ts b/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.d.ts similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/defaults/index.d.ts rename to x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.d.ts diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/defaults/index.js b/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/defaults/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/defaults/warm_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/warm_phase.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/defaults/warm_phase.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/defaults/warm_phase.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/index.d.ts b/x-pack/plugins/index_lifecycle_management/public/application/store/index.d.ts similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/index.d.ts rename to x-pack/plugins/index_lifecycle_management/public/application/store/index.d.ts diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/index.js b/x-pack/plugins/index_lifecycle_management/public/application/store/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/index.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/reducers/general.js b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/general.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/reducers/general.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/reducers/general.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/reducers/index.js b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/reducers/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/reducers/index.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/reducers/nodes.js b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/nodes.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/reducers/nodes.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/reducers/nodes.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/reducers/policies.js b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/policies.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/reducers/policies.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/reducers/policies.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/selectors/general.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/general.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/selectors/general.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/selectors/general.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/selectors/index.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/index.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/selectors/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/selectors/index.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/selectors/lifecycle.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/lifecycle.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/selectors/lifecycle.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/selectors/lifecycle.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/selectors/nodes.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/nodes.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/selectors/nodes.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/selectors/nodes.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/selectors/policies.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/policies.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/selectors/policies.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/selectors/policies.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/store.js b/x-pack/plugins/index_lifecycle_management/public/application/store/store.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/application/store/store.js rename to x-pack/plugins/index_lifecycle_management/public/application/store/store.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/extend_index_management/components/add_lifecycle_confirm_modal.js b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.js similarity index 97% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/extend_index_management/components/add_lifecycle_confirm_modal.js rename to x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.js index 5b8f2d197daf4..143895150172d 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/extend_index_management/components/add_lifecycle_confirm_modal.js +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.js @@ -23,7 +23,7 @@ import { EuiModalHeaderTitle, } from '@elastic/eui'; -import { BASE_PATH } from '../../../../common/constants'; +import { BASE_PATH } from '../../../common/constants'; import { loadPolicies, addLifecyclePolicyToIndex } from '../../application/services/api'; import { showApiError } from '../../application/services/api_errors'; import { toasts } from '../../application/services/notification'; @@ -38,7 +38,7 @@ export class AddLifecyclePolicyConfirmModal extends Component { }; } addPolicy = async () => { - const { indexName, httpClient, closeModal, reloadIndices } = this.props; + const { indexName, closeModal, reloadIndices } = this.props; const { selectedPolicyName, selectedAlias } = this.state; if (!selectedPolicyName) { this.setState({ @@ -55,7 +55,7 @@ export class AddLifecyclePolicyConfirmModal extends Component { policyName: selectedPolicyName, alias: selectedAlias, }; - await addLifecyclePolicyToIndex(body, httpClient); + await addLifecyclePolicyToIndex(body); closeModal(); toasts.addSuccess( i18n.translate( diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/extend_index_management/components/index_lifecycle_summary.js b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.js similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/extend_index_management/components/index_lifecycle_summary.js rename to x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.js diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/extend_index_management/components/remove_lifecycle_confirm_modal.js b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.js similarity index 96% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/extend_index_management/components/remove_lifecycle_confirm_modal.js rename to x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.js index 0ba5ed1720084..4e0d2383c7d79 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/extend_index_management/components/remove_lifecycle_confirm_modal.js +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.js @@ -24,10 +24,10 @@ export class RemoveLifecyclePolicyConfirmModal extends Component { } removePolicy = async () => { - const { indexNames, httpClient, closeModal, reloadIndices } = this.props; + const { indexNames, closeModal, reloadIndices } = this.props; try { - await removeLifecycleForIndex(indexNames, httpClient); + await removeLifecycleForIndex(indexNames); closeModal(); toasts.addSuccess( i18n.translate( diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/extend_index_management/index.d.ts b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.d.ts similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/public/np_ready/extend_index_management/index.d.ts rename to x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.d.ts diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js new file mode 100644 index 0000000000000..40ff04408002f --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js @@ -0,0 +1,204 @@ +/* + * 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 { get, every, any } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { EuiSearchBar } from '@elastic/eui'; + +import { retryLifecycleForIndex } from '../application/services/api'; +import { IndexLifecycleSummary } from './components/index_lifecycle_summary'; +import { AddLifecyclePolicyConfirmModal } from './components/add_lifecycle_confirm_modal'; +import { RemoveLifecyclePolicyConfirmModal } from './components/remove_lifecycle_confirm_modal'; + +const stepPath = 'ilm.step'; + +export const retryLifecycleActionExtension = ({ indices }) => { + const allHaveErrors = every(indices, index => { + return index.ilm && index.ilm.failed_step; + }); + if (!allHaveErrors) { + return null; + } + const indexNames = indices.map(({ name }) => name); + return { + requestMethod: retryLifecycleForIndex, + icon: 'play', + indexNames: [indexNames], + buttonLabel: i18n.translate('xpack.indexLifecycleMgmt.retryIndexLifecycleActionButtonLabel', { + defaultMessage: 'Retry lifecycle step', + }), + successMessage: i18n.translate( + 'xpack.indexLifecycleMgmt.retryIndexLifecycleAction.retriedLifecycleMessage', + { + defaultMessage: 'Called retry lifecycle step for: {indexNames}', + values: { indexNames: indexNames.map(indexName => `"${indexName}"`).join(', ') }, + } + ), + }; +}; + +export const removeLifecyclePolicyActionExtension = ({ indices, reloadIndices }) => { + const allHaveIlm = every(indices, index => { + return index.ilm && index.ilm.managed; + }); + if (!allHaveIlm) { + return null; + } + const indexNames = indices.map(({ name }) => name); + return { + renderConfirmModal: closeModal => { + return ( + <RemoveLifecyclePolicyConfirmModal + indexNames={indexNames} + closeModal={closeModal} + reloadIndices={reloadIndices} + /> + ); + }, + icon: 'stopFilled', + indexNames: [indexNames], + buttonLabel: i18n.translate('xpack.indexLifecycleMgmt.removeIndexLifecycleActionButtonLabel', { + defaultMessage: 'Remove lifecycle policy', + }), + }; +}; + +export const addLifecyclePolicyActionExtension = ({ indices, reloadIndices }) => { + if (indices.length !== 1) { + return null; + } + const index = indices[0]; + const hasIlm = index.ilm && index.ilm.managed; + + if (hasIlm) { + return null; + } + const indexName = index.name; + return { + renderConfirmModal: closeModal => { + return ( + <AddLifecyclePolicyConfirmModal + indexName={indexName} + closeModal={closeModal} + index={index} + reloadIndices={reloadIndices} + /> + ); + }, + icon: 'plusInCircle', + buttonLabel: i18n.translate('xpack.indexLifecycleMgmt.addLifecyclePolicyActionButtonLabel', { + defaultMessage: 'Add lifecycle policy', + }), + }; +}; + +export const ilmBannerExtension = indices => { + const { Query } = EuiSearchBar; + if (!indices.length) { + return null; + } + const indicesWithLifecycleErrors = indices.filter(index => { + return get(index, stepPath) === 'ERROR'; + }); + const numIndicesWithLifecycleErrors = indicesWithLifecycleErrors.length; + if (!numIndicesWithLifecycleErrors) { + return null; + } + return { + type: 'warning', + filter: Query.parse(`${stepPath}:ERROR`), + filterLabel: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtBanner.filterLabel', { + defaultMessage: 'Show errors', + }), + title: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtBanner.errorMessage', { + defaultMessage: `{ numIndicesWithLifecycleErrors, number} + {numIndicesWithLifecycleErrors, plural, one {index has} other {indices have} } + lifecycle errors`, + values: { numIndicesWithLifecycleErrors }, + }), + }; +}; + +export const ilmSummaryExtension = index => { + return <IndexLifecycleSummary index={index} />; +}; + +export const ilmFilterExtension = indices => { + const hasIlm = any(indices, index => index.ilm && index.ilm.managed); + if (!hasIlm) { + return []; + } else { + return [ + { + type: 'field_value_selection', + name: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.lifecycleStatusLabel', { + defaultMessage: 'Lifecycle status', + }), + multiSelect: false, + field: 'ilm.managed', + options: [ + { + value: true, + view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.managedLabel', { + defaultMessage: 'Managed', + }), + }, + { + value: false, + view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.unmanagedLabel', { + defaultMessage: 'Unmanaged', + }), + }, + ], + }, + { + type: 'field_value_selection', + field: 'ilm.phase', + name: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.lifecyclePhaseLabel', { + defaultMessage: 'Lifecycle phase', + }), + multiSelect: 'or', + options: [ + { + value: 'hot', + view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.hotLabel', { + defaultMessage: 'Hot', + }), + }, + { + value: 'warm', + view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.warmLabel', { + defaultMessage: 'Warm', + }), + }, + { + value: 'cold', + view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.coldLabel', { + defaultMessage: 'Cold', + }), + }, + { + value: 'delete', + view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.deleteLabel', { + defaultMessage: 'Delete', + }), + }, + ], + }, + ]; + } +}; + +export const addAllExtensions = extensionsService => { + extensionsService.addAction(retryLifecycleActionExtension); + extensionsService.addAction(removeLifecyclePolicyActionExtension); + extensionsService.addAction(addLifecyclePolicyActionExtension); + + extensionsService.addBanner(ilmBannerExtension); + extensionsService.addSummary(ilmSummaryExtension); + extensionsService.addFilter(ilmFilterExtension); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/index.ts b/x-pack/plugins/index_lifecycle_management/public/index.ts new file mode 100644 index 0000000000000..586763188a54b --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/index.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. + */ + +import { PluginInitializerContext } from 'kibana/public'; + +import { IndexLifecycleManagementPlugin } from './plugin'; + +/** @public */ +export const plugin = (initializerContext: PluginInitializerContext) => { + return new IndexLifecycleManagementPlugin(initializerContext); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx new file mode 100644 index 0000000000000..ca93646e20fcf --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx @@ -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 { CoreSetup, PluginInitializerContext } from 'src/core/public'; + +import { PLUGIN } from '../common/constants'; +import { init as initHttp } from './application/services/http'; +import { init as initDocumentation } from './application/services/documentation'; +import { init as initUiMetric } from './application/services/ui_metric'; +import { init as initNotification } from './application/services/notification'; +import { addAllExtensions } from './extend_index_management'; +import { PluginsDependencies, ClientConfigType } from './types'; + +export class IndexLifecycleManagementPlugin { + constructor(private readonly initializerContext: PluginInitializerContext) {} + + public setup(coreSetup: CoreSetup, plugins: PluginsDependencies) { + const { + ui: { enabled: isIndexLifecycleManagementUiEnabled }, + } = this.initializerContext.config.get<ClientConfigType>(); + + if (isIndexLifecycleManagementUiEnabled) { + const { + http, + notifications: { toasts }, + fatalErrors, + getStartServices, + } = coreSetup; + + const { usageCollection, management, indexManagement } = plugins; + + // Initialize services even if the app isn't mounted, because they're used by index management extensions. + initHttp(http); + initUiMetric(usageCollection); + initNotification(toasts, fatalErrors); + + management.sections.getSection('elasticsearch')!.registerApp({ + id: PLUGIN.ID, + title: PLUGIN.TITLE, + order: 2, + mount: async ({ element }) => { + const [coreStart] = await getStartServices(); + const { + i18n: { Context: I18nContext }, + docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }, + } = coreStart; + + // Initialize additional services. + initDocumentation( + `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/` + ); + + const { renderApp } = await import('./application'); + return renderApp(element, I18nContext); + }, + }); + + if (indexManagement) { + addAllExtensions(indexManagement.extensionsService); + } + } + } + + public start() {} + public stop() {} +} diff --git a/x-pack/plugins/index_lifecycle_management/public/types.ts b/x-pack/plugins/index_lifecycle_management/public/types.ts new file mode 100644 index 0000000000000..178884a7ee679 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/types.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 { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; +import { ManagementSetup } from '../../../../src/plugins/management/public'; +import { IndexManagementPluginSetup } from '../../index_management/public'; + +export interface PluginsDependencies { + usageCollection?: UsageCollectionSetup; + management: ManagementSetup; + indexManagement?: IndexManagementPluginSetup; +} + +export interface ClientConfigType { + ui: { + enabled: boolean; + }; +} diff --git a/x-pack/plugins/index_lifecycle_management/server/config.ts b/x-pack/plugins/index_lifecycle_management/server/config.ts new file mode 100644 index 0000000000000..9728e31a8a148 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/config.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 { schema, TypeOf } from '@kbn/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + // Cloud requires the ability to hide internal node attributes from users. + filteredNodeAttributes: schema.arrayOf(schema.string(), { defaultValue: [] }), +}); + +export type IndexLifecycleManagementConfig = TypeOf<typeof configSchema>; diff --git a/x-pack/plugins/index_lifecycle_management/server/index.ts b/x-pack/plugins/index_lifecycle_management/server/index.ts new file mode 100644 index 0000000000000..8a5f0fe19f9b0 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/index.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 { PluginInitializerContext, PluginConfigDescriptor } from 'kibana/server'; +import { IndexLifecycleManagementServerPlugin } from './plugin'; +import { configSchema, IndexLifecycleManagementConfig } from './config'; + +export const plugin = (ctx: PluginInitializerContext) => + new IndexLifecycleManagementServerPlugin(ctx); + +export const config: PluginConfigDescriptor<IndexLifecycleManagementConfig> = { + schema: configSchema, + exposeToBrowser: { + ui: true, + }, +}; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/is_es_error/is_es_error.ts b/x-pack/plugins/index_lifecycle_management/server/lib/is_es_error.ts similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/server/lib/is_es_error/is_es_error.ts rename to x-pack/plugins/index_lifecycle_management/server/lib/is_es_error.ts diff --git a/x-pack/plugins/index_lifecycle_management/server/plugin.ts b/x-pack/plugins/index_lifecycle_management/server/plugin.ts new file mode 100644 index 0000000000000..faeac67f62a21 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/plugin.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { i18n } from '@kbn/i18n'; +import { CoreSetup, Plugin, Logger, PluginInitializerContext, APICaller } from 'src/core/server'; + +import { PLUGIN } from '../common/constants'; +import { Dependencies } from './types'; +import { registerApiRoutes } from './routes'; +import { License } from './services'; +import { IndexLifecycleManagementConfig } from './config'; +import { isEsError } from './lib/is_es_error'; + +const indexLifecycleDataEnricher = async (indicesList: any, callAsCurrentUser: APICaller) => { + if (!indicesList || !indicesList.length) { + return; + } + + const params = { + path: '/*/_ilm/explain', + method: 'GET', + }; + + const { indices: ilmIndicesData } = await callAsCurrentUser('transport.request', params); + + return indicesList.map((index: any): any => { + return { + ...index, + ilm: { ...(ilmIndicesData[index.name] || {}) }, + }; + }); +}; + +export class IndexLifecycleManagementServerPlugin implements Plugin<void, void, any, any> { + private readonly config$: Observable<IndexLifecycleManagementConfig>; + private readonly license: License; + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + this.config$ = initializerContext.config.create(); + this.license = new License(); + } + + async setup({ http }: CoreSetup, { licensing, indexManagement }: Dependencies): Promise<void> { + const router = http.createRouter(); + const config = await this.config$.pipe(first()).toPromise(); + + this.license.setup( + { + pluginId: PLUGIN.ID, + minimumLicenseType: PLUGIN.minimumLicenseType, + defaultErrorMessage: i18n.translate('xpack.indexLifecycleMgmt.licenseCheckErrorMessage', { + defaultMessage: 'License check failed', + }), + }, + { + licensing, + logger: this.logger, + } + ); + + registerApiRoutes({ + router, + config, + license: this.license, + lib: { + isEsError, + }, + }); + + if (config.ui.enabled) { + if (indexManagement && indexManagement.indexDataEnricher) { + indexManagement.indexDataEnricher.add(indexLifecycleDataEnricher); + } + } + } + + start() {} + stop() {} +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/index.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/index.ts new file mode 100644 index 0000000000000..abe00af74b63a --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { RouteDependencies } from '../../../types'; +import { registerRetryRoute } from './register_retry_route'; +import { registerRemoveRoute } from './register_remove_route'; +import { registerAddPolicyRoute } from './register_add_policy_route'; + +export function registerIndexRoutes(dependencies: RouteDependencies) { + registerRetryRoute(dependencies); + registerRemoveRoute(dependencies); + registerAddPolicyRoute(dependencies); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_add_policy_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_add_policy_route.ts new file mode 100644 index 0000000000000..9627f6399eaaf --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_add_policy_route.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { APICaller } from 'src/core/server'; + +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../../../services'; + +async function addLifecyclePolicy( + callAsCurrentUser: APICaller, + indexName: string, + policyName: string, + alias: string +) { + const params = { + method: 'PUT', + path: `/${encodeURIComponent(indexName)}/_settings`, + body: { + lifecycle: { + name: policyName, + rollover_alias: alias, + }, + }, + }; + + return callAsCurrentUser('transport.request', params); +} + +const bodySchema = schema.object({ + indexName: schema.string(), + policyName: schema.string(), + alias: schema.maybe(schema.string()), +}); + +export function registerAddPolicyRoute({ router, license, lib }: RouteDependencies) { + router.post( + { path: addBasePath('/index/add'), validate: { body: bodySchema } }, + license.guardApiRoute(async (context, request, response) => { + const body = request.body as typeof bodySchema.type; + const { indexName, policyName, alias = '' } = body; + + try { + await addLifecyclePolicy( + context.core.elasticsearch.dataClient.callAsCurrentUser, + indexName, + policyName, + alias + ); + return response.ok(); + } catch (e) { + if (lib.isEsError(e)) { + return response.customError({ + statusCode: e.statusCode, + body: e, + }); + } + // Case: default + return response.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_remove_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_remove_route.ts new file mode 100644 index 0000000000000..8ec94a8591785 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_remove_route.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 { schema } from '@kbn/config-schema'; +import { APICaller } from 'src/core/server'; + +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../../../services'; + +async function removeLifecycle(callAsCurrentUser: APICaller, indexNames: string[]) { + const responses = []; + for (let i = 0; i < indexNames.length; i++) { + const indexName = indexNames[i]; + const params = { + method: 'POST', + path: `/${encodeURIComponent(indexName)}/_ilm/remove`, + ignore: [404], + }; + + responses.push(callAsCurrentUser('transport.request', params)); + } + return Promise.all(responses); +} + +const bodySchema = schema.object({ + indexNames: schema.arrayOf(schema.string()), +}); + +export function registerRemoveRoute({ router, license, lib }: RouteDependencies) { + router.post( + { path: addBasePath('/index/remove'), validate: { body: bodySchema } }, + license.guardApiRoute(async (context, request, response) => { + const body = request.body as typeof bodySchema.type; + const { indexNames } = body; + + try { + await removeLifecycle(context.core.elasticsearch.dataClient.callAsCurrentUser, indexNames); + return response.ok(); + } catch (e) { + if (lib.isEsError(e)) { + return response.customError({ + statusCode: e.statusCode, + body: e, + }); + } + // Case: default + return response.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_retry_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_retry_route.ts new file mode 100644 index 0000000000000..1e2d621cab173 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_retry_route.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 { schema } from '@kbn/config-schema'; +import { APICaller } from 'src/core/server'; + +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../../../services'; + +async function retryLifecycle(callAsCurrentUser: APICaller, indexNames: string[]) { + const responses = []; + for (let i = 0; i < indexNames.length; i++) { + const indexName = indexNames[i]; + const params = { + method: 'POST', + path: `/${encodeURIComponent(indexName)}/_ilm/retry`, + ignore: [404], + }; + + responses.push(callAsCurrentUser('transport.request', params)); + } + return Promise.all(responses); +} + +const bodySchema = schema.object({ + indexNames: schema.arrayOf(schema.string()), +}); + +export function registerRetryRoute({ router, license, lib }: RouteDependencies) { + router.post( + { path: addBasePath('/index/retry'), validate: { body: bodySchema } }, + license.guardApiRoute(async (context, request, response) => { + const body = request.body as typeof bodySchema.type; + const { indexNames } = body; + + try { + await retryLifecycle(context.core.elasticsearch.dataClient.callAsCurrentUser, indexNames); + return response.ok(); + } catch (e) { + if (lib.isEsError(e)) { + return response.customError({ + statusCode: e.statusCode, + body: e, + }); + } + // Case: default + return response.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/index.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/index.ts new file mode 100644 index 0000000000000..bde56f0318bbd --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/index.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. + */ + +import { RouteDependencies } from '../../../types'; +import { registerListRoute } from './register_list_route'; +import { registerDetailsRoute } from './register_details_route'; + +export function registerNodesRoutes(dependencies: RouteDependencies) { + registerListRoute(dependencies); + registerDetailsRoute(dependencies); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_details_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_details_route.ts new file mode 100644 index 0000000000000..6ff1f147e7ea7 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_details_route.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { APICaller } from 'src/core/server'; + +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../../../services'; + +function findMatchingNodes(stats: any, nodeAttrs: string): any { + return Object.entries(stats.nodes).reduce((accum: any[], [nodeId, nodeStats]: [any, any]) => { + const attributes = nodeStats.attributes || {}; + for (const [key, value] of Object.entries(attributes)) { + if (`${key}:${value}` === nodeAttrs) { + accum.push({ + nodeId, + stats: nodeStats, + }); + break; + } + } + return accum; + }, []); +} + +async function fetchNodeStats(callAsCurrentUser: APICaller): Promise<any> { + const params = { + format: 'json', + }; + + return await callAsCurrentUser('nodes.stats', params); +} + +const paramsSchema = schema.object({ + nodeAttrs: schema.string(), +}); + +export function registerDetailsRoute({ router, license, lib }: RouteDependencies) { + router.get( + { path: addBasePath('/nodes/{nodeAttrs}/details'), validate: { params: paramsSchema } }, + license.guardApiRoute(async (context, request, response) => { + const params = request.params as typeof paramsSchema.type; + const { nodeAttrs } = params; + + try { + const stats = await fetchNodeStats(context.core.elasticsearch.dataClient.callAsCurrentUser); + const okResponse = { body: findMatchingNodes(stats, nodeAttrs) }; + return response.ok(okResponse); + } catch (e) { + if (lib.isEsError(e)) { + return response.customError({ + statusCode: e.statusCode, + body: e, + }); + } + // Case: default + return response.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts new file mode 100644 index 0000000000000..73d85c78d3b11 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.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 { APICaller } from 'src/core/server'; + +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../../../services'; + +function convertStatsIntoList(stats: any, disallowedNodeAttributes: string[]): any { + return Object.entries(stats.nodes).reduce((accum: any, [nodeId, nodeStats]: [any, any]) => { + const attributes = nodeStats.attributes || {}; + for (const [key, value] of Object.entries(attributes)) { + const isNodeAttributeAllowed = !disallowedNodeAttributes.includes(key); + if (isNodeAttributeAllowed) { + const attributeString = `${key}:${value}`; + accum[attributeString] = accum[attributeString] || []; + accum[attributeString].push(nodeId); + } + } + return accum; + }, {}); +} + +async function fetchNodeStats(callAsCurrentUser: APICaller): Promise<any> { + const params = { + format: 'json', + }; + + return await callAsCurrentUser('nodes.stats', params); +} + +export function registerListRoute({ router, config, license, lib }: RouteDependencies) { + const { filteredNodeAttributes } = config; + + const NODE_ATTRS_KEYS_TO_IGNORE: string[] = [ + 'ml.enabled', + 'ml.machine_memory', + 'ml.max_open_jobs', + // Used by ML to identify nodes that have transform enabled: + // https://github.com/elastic/elasticsearch/pull/52712/files#diff-225cc2c1291b4c60a8c3412a619094e1R147 + 'transform.node', + 'xpack.installed', + ]; + + const disallowedNodeAttributes = [...NODE_ATTRS_KEYS_TO_IGNORE, ...filteredNodeAttributes]; + + router.get( + { path: addBasePath('/nodes/list'), validate: false }, + license.guardApiRoute(async (context, request, response) => { + try { + const stats = await fetchNodeStats(context.core.elasticsearch.dataClient.callAsCurrentUser); + const okResponse = { body: convertStatsIntoList(stats, disallowedNodeAttributes) }; + return response.ok(okResponse); + } catch (e) { + if (lib.isEsError(e)) { + return response.customError({ + statusCode: e.statusCode, + body: e, + }); + } + // Case: default + return response.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/index.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/index.ts new file mode 100644 index 0000000000000..c30dc04c61169 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { RouteDependencies } from '../../../types'; +import { registerFetchRoute } from './register_fetch_route'; +import { registerCreateRoute } from './register_create_route'; +import { registerDeleteRoute } from './register_delete_route'; + +export function registerPoliciesRoutes(dependencies: RouteDependencies) { + registerFetchRoute(dependencies); + registerCreateRoute(dependencies); + registerDeleteRoute(dependencies); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts new file mode 100644 index 0000000000000..a9c6bab58fdd9 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts @@ -0,0 +1,145 @@ +/* + * 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'; +import { APICaller } from 'src/core/server'; + +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../../../services'; + +async function createPolicy(callAsCurrentUser: APICaller, name: string, phases: any): Promise<any> { + const body = { + policy: { + phases, + }, + }; + const params = { + method: 'PUT', + path: `/_ilm/policy/${encodeURIComponent(name)}`, + ignore: [404], + body, + }; + + return await callAsCurrentUser('transport.request', params); +} + +const minAgeSchema = schema.maybe(schema.string()); + +const setPrioritySchema = schema.maybe( + schema.object({ + priority: schema.number(), + }) +); + +const unfollowSchema = schema.maybe(schema.object({})); // Unfollow has no options + +const allocateNodeSchema = schema.maybe(schema.recordOf(schema.string(), schema.string())); +const allocateSchema = schema.maybe( + schema.object({ + number_of_replicas: schema.maybe(schema.number()), + include: allocateNodeSchema, + exclude: allocateNodeSchema, + require: allocateNodeSchema, + }) +); + +const hotPhaseSchema = schema.object({ + min_age: minAgeSchema, + actions: schema.object({ + set_priority: setPrioritySchema, + unfollow: unfollowSchema, + rollover: schema.maybe( + schema.object({ + max_age: schema.maybe(schema.string()), + max_size: schema.maybe(schema.string()), + max_docs: schema.maybe(schema.number()), + }) + ), + }), +}); + +const warmPhaseSchema = schema.maybe( + schema.object({ + min_age: minAgeSchema, + actions: schema.object({ + set_priority: setPrioritySchema, + unfollow: unfollowSchema, + read_only: schema.maybe(schema.object({})), // Readonly has no options + allocate: allocateSchema, + shrink: schema.maybe( + schema.object({ + number_of_shards: schema.number(), + }) + ), + forcemerge: schema.maybe( + schema.object({ + max_num_segments: schema.number(), + }) + ), + }), + }) +); + +const coldPhaseSchema = schema.maybe( + schema.object({ + min_age: minAgeSchema, + actions: schema.object({ + set_priority: setPrioritySchema, + unfollow: unfollowSchema, + allocate: allocateSchema, + freeze: schema.maybe(schema.object({})), // Freeze has no options + }), + }) +); + +const deletePhaseSchema = schema.maybe( + schema.object({ + min_age: minAgeSchema, + actions: schema.object({ + wait_for_snapshot: schema.maybe( + schema.object({ + policy: schema.string(), + }) + ), + delete: schema.maybe(schema.object({})), // Delete has no options + }), + }) +); + +// Per https://www.elastic.co/guide/en/elasticsearch/reference/current/_actions.html +const bodySchema = schema.object({ + name: schema.string(), + phases: schema.object({ + hot: hotPhaseSchema, + warm: warmPhaseSchema, + cold: coldPhaseSchema, + delete: deletePhaseSchema, + }), +}); + +export function registerCreateRoute({ router, license, lib }: RouteDependencies) { + router.post( + { path: addBasePath('/policies'), validate: { body: bodySchema } }, + license.guardApiRoute(async (context, request, response) => { + const body = request.body as typeof bodySchema.type; + const { name, phases } = body; + + try { + await createPolicy(context.core.elasticsearch.dataClient.callAsCurrentUser, name, phases); + return response.ok(); + } catch (e) { + if (lib.isEsError(e)) { + return response.customError({ + statusCode: e.statusCode, + body: e, + }); + } + // Case: default + return response.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.ts new file mode 100644 index 0000000000000..e08297f4d7bc4 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { APICaller } from 'src/core/server'; + +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../../../services'; + +async function deletePolicies(callAsCurrentUser: APICaller, policyNames: string): Promise<any> { + const params = { + method: 'DELETE', + path: `/_ilm/policy/${encodeURIComponent(policyNames)}`, + // we allow 404 since they may have no policies + ignore: [404], + }; + + return await callAsCurrentUser('transport.request', params); +} + +const paramsSchema = schema.object({ + policyNames: schema.string(), +}); + +export function registerDeleteRoute({ router, license, lib }: RouteDependencies) { + router.delete( + { path: addBasePath('/policies/{policyNames}'), validate: { params: paramsSchema } }, + license.guardApiRoute(async (context, request, response) => { + const params = request.params as typeof paramsSchema.type; + const { policyNames } = params; + + try { + await deletePolicies(context.core.elasticsearch.dataClient.callAsCurrentUser, policyNames); + return response.ok(); + } catch (e) { + if (lib.isEsError(e)) { + return response.customError({ + statusCode: e.statusCode, + body: e, + }); + } + // Case: default + return response.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.ts new file mode 100644 index 0000000000000..294b7c4c65cba --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { APICaller } from 'src/core/server'; + +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../../../services'; + +function formatPolicies(policiesMap: any): any { + if (policiesMap.status === 404) { + return []; + } + + return Object.keys(policiesMap).reduce((accum: any[], lifecycleName: string) => { + const policyEntry = policiesMap[lifecycleName]; + accum.push({ + ...policyEntry, + name: lifecycleName, + }); + return accum; + }, []); +} + +async function fetchPolicies(callAsCurrentUser: APICaller): Promise<any> { + const params = { + method: 'GET', + path: '/_ilm/policy', + // we allow 404 since they may have no policies + ignore: [404], + }; + + return await callAsCurrentUser('transport.request', params); +} + +async function addLinkedIndices(callAsCurrentUser: APICaller, policiesMap: any) { + if (policiesMap.status === 404) { + return policiesMap; + } + const params = { + method: 'GET', + path: '/*/_ilm/explain', + // we allow 404 since they may have no policies + ignore: [404], + }; + + const policyExplanation: any = await callAsCurrentUser('transport.request', params); + Object.entries(policyExplanation.indices).forEach(([indexName, { policy }]: [string, any]) => { + if (policy && policiesMap[policy]) { + policiesMap[policy].linkedIndices = policiesMap[policy].linkedIndices || []; + policiesMap[policy].linkedIndices.push(indexName); + } + }); +} + +const querySchema = schema.object({ + withIndices: schema.boolean({ defaultValue: false }), +}); + +export function registerFetchRoute({ router, license, lib }: RouteDependencies) { + router.get( + { path: addBasePath('/policies'), validate: { query: querySchema } }, + license.guardApiRoute(async (context, request, response) => { + const query = request.query as typeof querySchema.type; + const { withIndices } = query; + const { callAsCurrentUser } = context.core.elasticsearch.dataClient; + + try { + const policiesMap = await fetchPolicies(callAsCurrentUser); + if (withIndices) { + await addLinkedIndices(callAsCurrentUser, policiesMap); + } + const okResponse = { body: formatPolicies(policiesMap) }; + return response.ok(okResponse); + } catch (e) { + if (lib.isEsError(e)) { + return response.customError({ + statusCode: e.statusCode, + body: e, + }); + } + // Case: default + return response.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/index.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/index.ts new file mode 100644 index 0000000000000..a2d885c3170b9 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/index.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. + */ + +import { RouteDependencies } from '../../../types'; +import { registerFetchRoute } from './register_fetch_route'; +import { registerAddPolicyRoute } from './register_add_policy_route'; + +export function registerTemplatesRoutes(dependencies: RouteDependencies) { + registerFetchRoute(dependencies); + registerAddPolicyRoute(dependencies); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.ts new file mode 100644 index 0000000000000..0da8535f8d4ec --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { merge } from 'lodash'; +import { schema } from '@kbn/config-schema'; +import { APICaller } from 'src/core/server'; + +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../../../services'; + +async function getIndexTemplate(callAsCurrentUser: APICaller, templateName: string): Promise<any> { + const response = await callAsCurrentUser('indices.getTemplate', { name: templateName }); + return response[templateName]; +} + +async function updateIndexTemplate( + callAsCurrentUser: APICaller, + templateName: string, + policyName: string, + aliasName?: string +): Promise<any> { + // Fetch existing template + const template = await getIndexTemplate(callAsCurrentUser, templateName); + merge(template, { + settings: { + index: { + lifecycle: { + name: policyName, + rollover_alias: aliasName, + }, + }, + }, + }); + + const params = { + method: 'PUT', + path: `/_template/${encodeURIComponent(templateName)}`, + ignore: [404], + body: template, + }; + + return await callAsCurrentUser('transport.request', params); +} + +const bodySchema = schema.object({ + templateName: schema.string(), + policyName: schema.string(), + aliasName: schema.maybe(schema.string()), +}); + +export function registerAddPolicyRoute({ router, license, lib }: RouteDependencies) { + router.post( + { path: addBasePath('/template'), validate: { body: bodySchema } }, + license.guardApiRoute(async (context, request, response) => { + const body = request.body as typeof bodySchema.type; + const { templateName, policyName, aliasName } = body; + + try { + await updateIndexTemplate( + context.core.elasticsearch.dataClient.callAsCurrentUser, + templateName, + policyName, + aliasName + ); + return response.ok(); + } catch (e) { + if (lib.isEsError(e)) { + return response.customError({ + statusCode: e.statusCode, + body: e, + }); + } + // Case: default + return response.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts new file mode 100644 index 0000000000000..a2dc67cb77afe --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.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 { APICaller } from 'src/core/server'; + +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../../../services'; + +/** + * We don't want to output system template (whose name starts with a ".") which don't + * have a time base index pattern (with a wildcard in it) as those templates are already + * assigned to a single index. + * + * @param {String} templateName The index template + * @param {Array} indexPatterns Index patterns + */ +function isReservedSystemTemplate(templateName: string, indexPatterns: string[]): boolean { + return ( + templateName.startsWith('kibana_index_template') || + (templateName.startsWith('.') && + indexPatterns.every(pattern => { + return !pattern.includes('*'); + })) + ); +} + +function filterAndFormatTemplates(templates: any): any { + const formattedTemplates = []; + const templateNames = Object.keys(templates); + for (const templateName of templateNames) { + const { settings, index_patterns } = templates[templateName]; // eslint-disable-line camelcase + if (isReservedSystemTemplate(templateName, index_patterns)) { + continue; + } + const formattedTemplate = { + index_lifecycle_name: + settings.index && settings.index.lifecycle ? settings.index.lifecycle.name : undefined, + index_patterns, + allocation_rules: + settings.index && settings.index.routing ? settings.index.routing : undefined, + settings, + name: templateName, + }; + formattedTemplates.push(formattedTemplate); + } + return formattedTemplates; +} + +async function fetchTemplates(callAsCurrentUser: APICaller): Promise<any> { + const params = { + method: 'GET', + path: '/_template', + // we allow 404 incase the user shutdown security in-between the check and now + ignore: [404], + }; + + return await callAsCurrentUser('transport.request', params); +} + +export function registerFetchRoute({ router, license, lib }: RouteDependencies) { + router.get( + { path: addBasePath('/templates'), validate: false }, + license.guardApiRoute(async (context, request, response) => { + try { + const templates = await fetchTemplates( + context.core.elasticsearch.dataClient.callAsCurrentUser + ); + const okResponse = { body: filterAndFormatTemplates(templates) }; + return response.ok(okResponse); + } catch (e) { + if (lib.isEsError(e)) { + return response.customError({ + statusCode: e.statusCode, + body: e, + }); + } + // Case: default + return response.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/index.ts b/x-pack/plugins/index_lifecycle_management/server/routes/index.ts new file mode 100644 index 0000000000000..35996721854c6 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/index.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 { RouteDependencies } from '../types'; + +import { registerIndexRoutes } from './api/index'; +import { registerNodesRoutes } from './api/nodes'; +import { registerPoliciesRoutes } from './api/policies'; +import { registerTemplatesRoutes } from './api/templates'; + +export function registerApiRoutes(dependencies: RouteDependencies) { + registerIndexRoutes(dependencies); + registerNodesRoutes(dependencies); + registerPoliciesRoutes(dependencies); + registerTemplatesRoutes(dependencies); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/services/add_base_path.ts b/x-pack/plugins/index_lifecycle_management/server/services/add_base_path.ts new file mode 100644 index 0000000000000..3f3dd131df7c7 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/services/add_base_path.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 { API_BASE_PATH } from '../../common/constants'; + +export const addBasePath = (uri: string): string => `${API_BASE_PATH}${uri}`; diff --git a/x-pack/plugins/index_lifecycle_management/server/services/index.ts b/x-pack/plugins/index_lifecycle_management/server/services/index.ts new file mode 100644 index 0000000000000..d7b544b290c39 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/services/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { License } from './license'; +export { addBasePath } from './add_base_path'; diff --git a/x-pack/plugins/index_lifecycle_management/server/services/license.ts b/x-pack/plugins/index_lifecycle_management/server/services/license.ts new file mode 100644 index 0000000000000..31d3654c51e3e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/services/license.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Logger } from 'src/core/server'; +import { + KibanaRequest, + KibanaResponseFactory, + RequestHandler, + RequestHandlerContext, +} from 'kibana/server'; + +import { LicensingPluginSetup } from '../../../licensing/server'; +import { LicenseType } from '../../../licensing/common/types'; + +export interface LicenseStatus { + isValid: boolean; + message?: string; +} + +interface SetupSettings { + pluginId: string; + minimumLicenseType: LicenseType; + defaultErrorMessage: string; +} + +export class License { + private licenseStatus: LicenseStatus = { + isValid: false, + message: 'Invalid License', + }; + + setup( + { pluginId, minimumLicenseType, defaultErrorMessage }: SetupSettings, + { licensing, logger }: { licensing: LicensingPluginSetup; logger: Logger } + ) { + licensing.license$.subscribe(license => { + const { state, message } = license.check(pluginId, minimumLicenseType); + const hasRequiredLicense = state === 'valid'; + + if (hasRequiredLicense) { + this.licenseStatus = { isValid: true }; + } else { + this.licenseStatus = { + isValid: false, + message: message || defaultErrorMessage, + }; + if (message) { + logger.info(message); + } + } + }); + } + + guardApiRoute(handler: RequestHandler) { + const license = this; + + return function licenseCheck( + ctx: RequestHandlerContext, + request: KibanaRequest, + response: KibanaResponseFactory + ) { + const licenseStatus = license.getStatus(); + + if (!licenseStatus.isValid) { + return response.customError({ + body: { + message: licenseStatus.message || '', + }, + statusCode: 403, + }); + } + + return handler(ctx, request, response); + }; + } + + getStatus() { + return this.licenseStatus; + } +} diff --git a/x-pack/plugins/index_lifecycle_management/server/types.ts b/x-pack/plugins/index_lifecycle_management/server/types.ts new file mode 100644 index 0000000000000..734d05a82000e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/types.ts @@ -0,0 +1,27 @@ +/* + * 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 { IRouter } from 'src/core/server'; + +import { LicensingPluginSetup } from '../../licensing/server'; +import { IndexManagementPluginSetup } from '../../index_management/server'; +import { License } from './services'; +import { IndexLifecycleManagementConfig } from './config'; +import { isEsError } from './lib/is_es_error'; + +export interface Dependencies { + licensing: LicensingPluginSetup; + indexManagement?: IndexManagementPluginSetup; +} + +export interface RouteDependencies { + router: IRouter; + config: IndexLifecycleManagementConfig; + license: License; + lib: { + isEsError: typeof isEsError; + }; +} diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_actions_context_menu/index_actions_context_menu.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_actions_context_menu/index_actions_context_menu.js index 8f794ce1ed612..a351d39b123a8 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_actions_context_menu/index_actions_context_menu.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_actions_context_menu/index_actions_context_menu.js @@ -46,11 +46,7 @@ export class IndexActionsContextMenu extends Component { confirmAction = isActionConfirmed => { this.setState({ isActionConfirmed }); }; - panels({ - core: { fatalErrors }, - services: { extensionsService, httpService, notificationService }, - plugins: { usageCollection }, - }) { + panels({ services: { extensionsService } }) { const { closeIndices, openIndices, @@ -218,15 +214,6 @@ export class IndexActionsContextMenu extends Component { const actionExtensionDefinition = actionExtension({ indices, reloadIndices, - // These config options can be removed once the NP migration out of legacy is complete. - // They're needed for now because ILM's extensions make API calls which require these - // dependencies, but they're not available unless the app's "setup" lifecycle stage occurs. - // Within the old platform, "setup" only occurs once the user actually visits the app. - // Once ILM and IM have been moved out of legacy this hack won't be necessary. - usageCollection, - toasts: notificationService.toasts, - fatalErrors, - httpClient: httpService.httpClient, }); if (actionExtensionDefinition) { const { diff --git a/x-pack/plugins/index_management/public/index.ts b/x-pack/plugins/index_management/public/index.ts index 6bb921ef648f3..7a76fff7f3ec6 100644 --- a/x-pack/plugins/index_management/public/index.ts +++ b/x-pack/plugins/index_management/public/index.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ import './index.scss'; -import { IndexMgmtUIPlugin, IndexMgmtSetup } from './plugin'; +import { IndexMgmtUIPlugin, IndexManagementPluginSetup } from './plugin'; /** @public */ export const plugin = () => { return new IndexMgmtUIPlugin(); }; -export { IndexMgmtSetup }; +export { IndexManagementPluginSetup }; export { getIndexListUri } from './application/services/navigation'; diff --git a/x-pack/plugins/index_management/public/plugin.ts b/x-pack/plugins/index_management/public/plugin.ts index 4aa06d286e3c4..f9e2a47170b3d 100644 --- a/x-pack/plugins/index_management/public/plugin.ts +++ b/x-pack/plugins/index_management/public/plugin.ts @@ -20,7 +20,7 @@ import { setUiMetricService } from './application/services/api'; import { IndexMgmtMetricsType } from './types'; import { ExtensionsService, ExtensionsSetup } from './services'; -export interface IndexMgmtSetup { +export interface IndexManagementPluginSetup { extensionsService: ExtensionsSetup; } @@ -40,7 +40,7 @@ export class IndexMgmtUIPlugin { setUiMetricService(this.uiMetricService); } - public setup(coreSetup: CoreSetup, plugins: PluginsDependencies): IndexMgmtSetup { + public setup(coreSetup: CoreSetup, plugins: PluginsDependencies): IndexManagementPluginSetup { const { http, notifications } = coreSetup; const { usageCollection, management } = plugins; diff --git a/x-pack/plugins/index_management/server/index.ts b/x-pack/plugins/index_management/server/index.ts index e4102711708cb..4d9409e4a516c 100644 --- a/x-pack/plugins/index_management/server/index.ts +++ b/x-pack/plugins/index_management/server/index.ts @@ -17,6 +17,6 @@ export const config = { /** @public */ export { Dependencies } from './types'; -export { IndexMgmtSetup } from './plugin'; +export { IndexManagementPluginSetup } from './plugin'; export { Index } from './types'; export { IndexManagementConfig } from './config'; diff --git a/x-pack/plugins/index_management/server/plugin.ts b/x-pack/plugins/index_management/server/plugin.ts index a0a9151cdb71f..e5bd7451b028f 100644 --- a/x-pack/plugins/index_management/server/plugin.ts +++ b/x-pack/plugins/index_management/server/plugin.ts @@ -12,13 +12,13 @@ import { ApiRoutes } from './routes'; import { License, IndexDataEnricher } from './services'; import { isEsError } from './lib/is_es_error'; -export interface IndexMgmtSetup { +export interface IndexManagementPluginSetup { indexDataEnricher: { add: IndexDataEnricher['add']; }; } -export class IndexMgmtServerPlugin implements Plugin<IndexMgmtSetup, void, any, any> { +export class IndexMgmtServerPlugin implements Plugin<IndexManagementPluginSetup, void, any, any> { private readonly apiRoutes: ApiRoutes; private readonly license: License; private readonly logger: Logger; @@ -31,7 +31,7 @@ export class IndexMgmtServerPlugin implements Plugin<IndexMgmtSetup, void, any, this.indexDataEnricher = new IndexDataEnricher(); } - setup({ http }: CoreSetup, { licensing }: Dependencies): IndexMgmtSetup { + setup({ http }: CoreSetup, { licensing }: Dependencies): IndexManagementPluginSetup { const router = http.createRouter(); this.license.setup( diff --git a/x-pack/plugins/infra/common/http_api/metadata_api.ts b/x-pack/plugins/infra/common/http_api/metadata_api.ts index 7fc3c3e876f08..5ee96b479be8e 100644 --- a/x-pack/plugins/infra/common/http_api/metadata_api.ts +++ b/x-pack/plugins/infra/common/http_api/metadata_api.ts @@ -11,6 +11,10 @@ export const InfraMetadataRequestRT = rt.type({ nodeId: rt.string, nodeType: ItemTypeRT, sourceId: rt.string, + timeRange: rt.type({ + from: rt.number, + to: rt.number, + }), }); export const InfraMetadataFeatureRT = rt.type({ diff --git a/x-pack/plugins/infra/common/inventory_models/aws_ec2/layout.tsx b/x-pack/plugins/infra/common/inventory_models/aws_ec2/layout.tsx index c8e0680287526..68bfe41fd538e 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_ec2/layout.tsx +++ b/x-pack/plugins/infra/common/inventory_models/aws_ec2/layout.tsx @@ -20,7 +20,7 @@ import { withTheme } from '../../../../observability/public'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { MetadataDetails } from '../../../public/pages/metrics/components/metadata_details'; -export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( +export const Layout = withTheme(({ metrics, theme, onChangeRangeTime }: LayoutPropsWithTheme) => ( <React.Fragment> <MetadataDetails fields={[ @@ -42,6 +42,7 @@ export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( } )} metrics={metrics} + onChangeRangeTime={onChangeRangeTime} > <SubSection id="awsEC2CpuUtilization" diff --git a/x-pack/plugins/infra/common/inventory_models/aws_rds/layout.tsx b/x-pack/plugins/infra/common/inventory_models/aws_rds/layout.tsx index 4bc4562ab2760..220c6c67f4aea 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_rds/layout.tsx +++ b/x-pack/plugins/infra/common/inventory_models/aws_rds/layout.tsx @@ -18,7 +18,7 @@ import { withTheme } from '../../../../observability/public'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { LayoutContent } from '../../../public/pages/metrics/components/layout_content'; -export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( +export const Layout = withTheme(({ metrics, onChangeRangeTime, theme }: LayoutPropsWithTheme) => ( <React.Fragment> <LayoutContent> <Section @@ -30,6 +30,7 @@ export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( } )} metrics={metrics} + onChangeRangeTime={onChangeRangeTime} > <SubSection id="awsRDSCpuTotal" diff --git a/x-pack/plugins/infra/common/inventory_models/aws_s3/layout.tsx b/x-pack/plugins/infra/common/inventory_models/aws_s3/layout.tsx index c40c8e7b8c6d3..805236cf47082 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_s3/layout.tsx +++ b/x-pack/plugins/infra/common/inventory_models/aws_s3/layout.tsx @@ -18,7 +18,7 @@ import { withTheme } from '../../../../observability/public'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { LayoutContent } from '../../../public/pages/metrics/components/layout_content'; -export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( +export const Layout = withTheme(({ metrics, onChangeRangeTime, theme }: LayoutPropsWithTheme) => ( <React.Fragment> <LayoutContent> <Section @@ -30,6 +30,7 @@ export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( } )} metrics={metrics} + onChangeRangeTime={onChangeRangeTime} > <SubSection id="awsS3BucketSize" diff --git a/x-pack/plugins/infra/common/inventory_models/aws_sqs/layout.tsx b/x-pack/plugins/infra/common/inventory_models/aws_sqs/layout.tsx index 7f2dc92f42205..d581ac751682d 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_sqs/layout.tsx +++ b/x-pack/plugins/infra/common/inventory_models/aws_sqs/layout.tsx @@ -18,7 +18,7 @@ import { withTheme } from '../../../../observability/public'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { LayoutContent } from '../../../public/pages/metrics/components/layout_content'; -export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( +export const Layout = withTheme(({ metrics, onChangeRangeTime, theme }: LayoutPropsWithTheme) => ( <React.Fragment> <LayoutContent> <Section @@ -30,6 +30,7 @@ export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( } )} metrics={metrics} + onChangeRangeTime={onChangeRangeTime} > <SubSection id="awsSQSMessagesVisible" diff --git a/x-pack/plugins/infra/common/inventory_models/container/layout.tsx b/x-pack/plugins/infra/common/inventory_models/container/layout.tsx index b61e8eed09cce..9956b2c9a2ce4 100644 --- a/x-pack/plugins/infra/common/inventory_models/container/layout.tsx +++ b/x-pack/plugins/infra/common/inventory_models/container/layout.tsx @@ -22,7 +22,7 @@ import { LayoutContent } from '../../../public/pages/metrics/components/layout_c // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { MetadataDetails } from '../../../public/pages/metrics/components/metadata_details'; -export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( +export const Layout = withTheme(({ metrics, onChangeRangeTime, theme }: LayoutPropsWithTheme) => ( <React.Fragment> <MetadataDetails /> <LayoutContent> @@ -40,6 +40,7 @@ export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( } )} metrics={metrics} + onChangeRangeTime={onChangeRangeTime} > <SubSection id="containerOverview"> <GaugesSectionVis diff --git a/x-pack/plugins/infra/common/inventory_models/host/layout.tsx b/x-pack/plugins/infra/common/inventory_models/host/layout.tsx index 15e5530e8db53..6d7d361254220 100644 --- a/x-pack/plugins/infra/common/inventory_models/host/layout.tsx +++ b/x-pack/plugins/infra/common/inventory_models/host/layout.tsx @@ -24,7 +24,7 @@ import { MetadataDetails } from '../../../public/pages/metrics/components/metada // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { LayoutContent } from '../../../public/pages/metrics/components/layout_content'; -export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( +export const Layout = withTheme(({ metrics, onChangeRangeTime, theme }: LayoutPropsWithTheme) => ( <React.Fragment> <MetadataDetails fields={[ @@ -52,6 +52,7 @@ export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( } )} metrics={metrics} + onChangeRangeTime={onChangeRangeTime} > <SubSection id="hostSystemOverview"> <GaugesSectionVis @@ -242,6 +243,7 @@ export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( } )} metrics={metrics} + onChangeRangeTime={onChangeRangeTime} > <SubSection id="hostK8sOverview"> <GaugesSectionVis @@ -371,8 +373,8 @@ export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( /> </SubSection> </Section> - <Aws.Layout metrics={metrics} /> - <Ngnix.Layout metrics={metrics} /> + <Aws.Layout metrics={metrics} onChangeRangeTime={onChangeRangeTime} /> + <Ngnix.Layout metrics={metrics} onChangeRangeTime={onChangeRangeTime} /> </LayoutContent> </React.Fragment> )); diff --git a/x-pack/plugins/infra/common/inventory_models/pod/layout.tsx b/x-pack/plugins/infra/common/inventory_models/pod/layout.tsx index 43b95d73f6d95..8bc2f3ee8b4b3 100644 --- a/x-pack/plugins/infra/common/inventory_models/pod/layout.tsx +++ b/x-pack/plugins/infra/common/inventory_models/pod/layout.tsx @@ -23,7 +23,7 @@ import { MetadataDetails } from '../../../public/pages/metrics/components/metada // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { LayoutContent } from '../../../public/pages/metrics/components/layout_content'; -export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( +export const Layout = withTheme(({ metrics, onChangeRangeTime, theme }: LayoutPropsWithTheme) => ( <React.Fragment> <MetadataDetails /> <LayoutContent> @@ -38,6 +38,7 @@ export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( } )} metrics={metrics} + onChangeRangeTime={onChangeRangeTime} > <SubSection id="podOverview"> <GaugesSectionVis @@ -161,7 +162,7 @@ export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( /> </SubSection> </Section> - <Nginx.Layout metrics={metrics} /> + <Nginx.Layout metrics={metrics} onChangeRangeTime={onChangeRangeTime} /> </LayoutContent> </React.Fragment> )); diff --git a/x-pack/plugins/infra/common/inventory_models/shared/layouts/aws.tsx b/x-pack/plugins/infra/common/inventory_models/shared/layouts/aws.tsx index fba48c4224e6b..7a0b898d406ce 100644 --- a/x-pack/plugins/infra/common/inventory_models/shared/layouts/aws.tsx +++ b/x-pack/plugins/infra/common/inventory_models/shared/layouts/aws.tsx @@ -18,7 +18,7 @@ import { ChartSectionVis } from '../../../../public/pages/metrics/components/cha // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { withTheme } from '../../../../../observability/public'; -export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( +export const Layout = withTheme(({ metrics, onChangeRangeTime, theme }: LayoutPropsWithTheme) => ( <React.Fragment> <Section navLabel="AWS" @@ -29,6 +29,7 @@ export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( } )} metrics={metrics} + onChangeRangeTime={onChangeRangeTime} > <SubSection id="awsOverview"> <GaugesSectionVis diff --git a/x-pack/plugins/infra/common/inventory_models/shared/layouts/nginx.tsx b/x-pack/plugins/infra/common/inventory_models/shared/layouts/nginx.tsx index eff0a9bbca85a..79cea5150d498 100644 --- a/x-pack/plugins/infra/common/inventory_models/shared/layouts/nginx.tsx +++ b/x-pack/plugins/infra/common/inventory_models/shared/layouts/nginx.tsx @@ -16,9 +16,14 @@ import { ChartSectionVis } from '../../../../public/pages/metrics/components/cha // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { withTheme } from '../../../../../observability/public'; -export const Layout = withTheme(({ metrics, theme }: LayoutPropsWithTheme) => ( +export const Layout = withTheme(({ metrics, onChangeRangeTime, theme }: LayoutPropsWithTheme) => ( <React.Fragment> - <Section navLabel="Nginx" sectionLabel="Nginx" metrics={metrics}> + <Section + navLabel="Nginx" + sectionLabel="Nginx" + metrics={metrics} + onChangeRangeTime={onChangeRangeTime} + > <SubSection id="nginxHits" label={i18n.translate( diff --git a/x-pack/plugins/infra/common/saved_objects/inventory_view.ts b/x-pack/plugins/infra/common/saved_objects/inventory_view.ts index bccffadc0a1ba..8ae765f379add 100644 --- a/x-pack/plugins/infra/common/saved_objects/inventory_view.ts +++ b/x-pack/plugins/infra/common/saved_objects/inventory_view.ts @@ -7,7 +7,7 @@ // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ElasticsearchMappingOf } from '../../server/utils/typed_elasticsearch_mappings'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { WaffleViewState } from '../../public/containers/waffle/with_waffle_view_state'; +import { WaffleViewState } from '../../public/pages/inventory_view/hooks/use_waffle_view_state'; export const inventoryViewSavedObjectType = 'inventory-view'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths @@ -102,7 +102,7 @@ export const inventoryViewSavedObjectMappings: { type: 'boolean', }, time: { - type: 'integer', + type: 'long', }, autoReload: { type: 'boolean', @@ -117,6 +117,12 @@ export const inventoryViewSavedObjectMappings: { }, }, }, + accountId: { + type: 'keyword', + }, + region: { + type: 'keyword', + }, }, }, }; diff --git a/x-pack/plugins/infra/kibana.json b/x-pack/plugins/infra/kibana.json index b8796ad7a358e..a15465a0cde66 100644 --- a/x-pack/plugins/infra/kibana.json +++ b/x-pack/plugins/infra/kibana.json @@ -10,7 +10,7 @@ "home", "data", "dataEnhanced", - "metrics", + "visTypeTimeseries", "alerting", "triggers_actions_ui" ], diff --git a/x-pack/plugins/infra/public/apps/start_app.tsx b/x-pack/plugins/infra/public/apps/start_app.tsx index a986ee6ece352..ebf9562c38d7a 100644 --- a/x-pack/plugins/infra/public/apps/start_app.tsx +++ b/x-pack/plugins/infra/public/apps/start_app.tsx @@ -8,9 +8,6 @@ import { createBrowserHistory } from 'history'; import React from 'react'; import ReactDOM from 'react-dom'; import { ApolloProvider } from 'react-apollo'; -import { Provider as ReduxStoreProvider } from 'react-redux'; -import { BehaviorSubject } from 'rxjs'; -import { pluck } from 'rxjs/operators'; import { CoreStart, AppMountParameters } from 'kibana/public'; // TODO use theme provided from parentApp when kibana supports it @@ -18,9 +15,7 @@ import { EuiErrorBoundary } from '@elastic/eui'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { EuiThemeProvider } from '../../../observability/public/typings/eui_styled_components'; import { InfraFrontendLibs } from '../lib/lib'; -import { createStore } from '../store'; import { ApolloClientContext } from '../utils/apollo_context'; -import { ReduxStateContextProvider } from '../utils/redux_context'; import { HistoryContext } from '../utils/history_context'; import { useUiSetting$, @@ -43,12 +38,6 @@ export async function startApp( ) { const { element, appBasePath } = params; const history = createBrowserHistory({ basename: appBasePath }); - const libs$ = new BehaviorSubject(libs); - const store = createStore({ - apolloClient: libs$.pipe(pluck('apolloClient')), - observableApi: libs$.pipe(pluck('observableApi')), - }); - const InfraPluginRoot: React.FunctionComponent = () => { const [darkMode] = useUiSetting$<boolean>('theme:darkMode'); @@ -56,19 +45,15 @@ export async function startApp( <core.i18n.Context> <EuiErrorBoundary> <TriggersActionsProvider triggersActionsUI={triggersActionsUI}> - <ReduxStoreProvider store={store}> - <ReduxStateContextProvider> - <ApolloProvider client={libs.apolloClient}> - <ApolloClientContext.Provider value={libs.apolloClient}> - <EuiThemeProvider darkMode={darkMode}> - <HistoryContext.Provider value={history}> - <Router history={history} /> - </HistoryContext.Provider> - </EuiThemeProvider> - </ApolloClientContext.Provider> - </ApolloProvider> - </ReduxStateContextProvider> - </ReduxStoreProvider> + <ApolloProvider client={libs.apolloClient}> + <ApolloClientContext.Provider value={libs.apolloClient}> + <EuiThemeProvider darkMode={darkMode}> + <HistoryContext.Provider value={history}> + <Router history={history} /> + </HistoryContext.Provider> + </EuiThemeProvider> + </ApolloClientContext.Provider> + </ApolloProvider> </TriggersActionsProvider> </EuiErrorBoundary> </core.i18n.Context> diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx index 2e43ede2480ce..7de52840777f9 100644 --- a/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx +++ b/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx @@ -17,6 +17,9 @@ import { import { IFieldType } from 'src/plugins/data/public'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { EuiExpression } from '@elastic/eui'; +import { EuiCallOut } from '@elastic/eui'; +import { EuiLink } from '@elastic/eui'; import { MetricExpressionParams, Comparator, @@ -208,6 +211,37 @@ export const Expressions: React.FC<Props> = props => { } }, [alertsContext.metadata, defaultExpression, source]); // eslint-disable-line react-hooks/exhaustive-deps + // INFO: If there is metadata, you're in the metrics explorer context + const canAddConditions = !!alertsContext.metadata; + + if (!canAddConditions && !alertParams.criteria) { + return ( + <> + <EuiSpacer size={'m'} /> + <EuiCallOut + title={ + <> + <FormattedMessage + id="xpack.infra.metrics.alertFlyout.createAlertWarningBody" + defaultMessage="Create new metric threshold alerts from" + />{' '} + <EuiLink href={'../app/metrics/explorer'}> + <FormattedMessage + id="xpack.infra.homePage.metricsExplorerTabTitle" + defaultMessage="Metrics Explorer" + /> + </EuiLink> + . + </> + } + color="warning" + iconType="help" + /> + <EuiSpacer size={'m'} /> + </> + ); + } + return ( <> <EuiSpacer size={'m'} /> @@ -224,6 +258,7 @@ export const Expressions: React.FC<Props> = props => { alertParams.criteria.map((e, idx) => { return ( <ExpressionRow + canEditAggField={canAddConditions} canDelete={alertParams.criteria.length > 1} fields={derivedIndexPattern.fields} remove={removeExpression} @@ -246,61 +281,65 @@ export const Expressions: React.FC<Props> = props => { /> <div> - <EuiButtonEmpty - color={'primary'} - iconSide={'left'} - flush={'left'} - iconType={'plusInCircleFilled'} - onClick={addExpression} - > - <FormattedMessage - id="xpack.infra.metrics.alertFlyout.addCondition" - defaultMessage="Add condition" - /> - </EuiButtonEmpty> + {canAddConditions && ( + <EuiButtonEmpty + color={'primary'} + iconSide={'left'} + flush={'left'} + iconType={'plusInCircleFilled'} + onClick={addExpression} + > + <FormattedMessage + id="xpack.infra.metrics.alertFlyout.addCondition" + defaultMessage="Add condition" + /> + </EuiButtonEmpty> + )} </div> <EuiSpacer size={'m'} /> - <EuiFormRow - label={i18n.translate('xpack.infra.metrics.alertFlyout.filterLabel', { - defaultMessage: 'Filter', - })} - helpText={i18n.translate('xpack.infra.metrics.alertFlyout.filterHelpText', { - defaultMessage: 'Filter help text', - })} - fullWidth - compressed - > - <MetricsExplorerKueryBar - derivedIndexPattern={derivedIndexPattern} - onSubmit={onFilterQuerySubmit} - value={alertParams.filterQuery} - /> - </EuiFormRow> - - <EuiSpacer size={'m'} /> - {alertsContext.metadata && ( - <EuiFormRow - label={i18n.translate('xpack.infra.metrics.alertFlyout.createAlertPerText', { - defaultMessage: 'Create alert per', - })} - helpText={i18n.translate('xpack.infra.metrics.alertFlyout.createAlertPerHelpText', { - defaultMessage: 'Create alert help text', - })} - fullWidth - compressed - > - <MetricsExplorerGroupBy - onChange={onGroupByChange} - fields={derivedIndexPattern.fields} - options={{ - ...options, - groupBy: alertParams.groupBy || undefined, - }} - /> - </EuiFormRow> + <> + <EuiFormRow + label={i18n.translate('xpack.infra.metrics.alertFlyout.filterLabel', { + defaultMessage: 'Filter (optional)', + })} + helpText={i18n.translate('xpack.infra.metrics.alertFlyout.filterHelpText', { + defaultMessage: 'Use a KQL expression to limit the scope of your alert trigger.', + })} + fullWidth + compressed + > + <MetricsExplorerKueryBar + derivedIndexPattern={derivedIndexPattern} + onSubmit={onFilterQuerySubmit} + value={alertParams.filterQuery} + /> + </EuiFormRow> + + <EuiSpacer size={'m'} /> + <EuiFormRow + label={i18n.translate('xpack.infra.metrics.alertFlyout.createAlertPerText', { + defaultMessage: 'Create alert per (optional)', + })} + helpText={i18n.translate('xpack.infra.metrics.alertFlyout.createAlertPerHelpText', { + defaultMessage: + 'Create an alert for every unique value. For example: "host.id" or "cloud.region".', + })} + fullWidth + compressed + > + <MetricsExplorerGroupBy + onChange={onGroupByChange} + fields={derivedIndexPattern.fields} + options={{ + ...options, + groupBy: alertParams.groupBy || undefined, + }} + /> + </EuiFormRow> + </> )} </> ); @@ -308,6 +347,7 @@ export const Expressions: React.FC<Props> = props => { interface ExpressionRowProps { fields: IFieldType[]; + canEditAggField: boolean; expressionId: number; expression: MetricExpression; errors: IErrorObject; @@ -359,7 +399,9 @@ export const ExpressionRow: React.FC<ExpressionRowProps> = props => { const updateThreshold = useCallback( t => { - setAlertParams(expressionId, { ...expression, threshold: t }); + if (t.join() !== expression.threshold.join()) { + setAlertParams(expressionId, { ...expression, threshold: t }); + } }, [expressionId, expression, setAlertParams] ); @@ -378,17 +420,20 @@ export const ExpressionRow: React.FC<ExpressionRowProps> = props => { </StyledExpression> {aggType !== 'count' && ( <StyledExpression> - <OfExpression - customAggTypesOptions={aggregationType} - aggField={metric} - fields={fields.map(f => ({ - normalizedType: f.type, - name: f.name, - }))} - aggType={aggType} - errors={errors} - onChangeSelectedAggField={updateMetric} - /> + {!props.canEditAggField && <DisabledAggField text={metric || ''} />} + {props.canEditAggField && ( + <OfExpression + customAggTypesOptions={aggregationType} + aggField={metric} + fields={fields.map(f => ({ + normalizedType: f.type, + name: f.name, + }))} + aggType={aggType} + errors={errors} + onChangeSelectedAggField={updateMetric} + /> + )} </StyledExpression> )} <StyledExpression> @@ -420,6 +465,19 @@ export const ExpressionRow: React.FC<ExpressionRowProps> = props => { ); }; +export const DisabledAggField = ({ text }: { text: string }) => { + return ( + <EuiExpression + description={i18n.translate('xpack.infra.metrics.alertFlyout.of.buttonLabel', { + defaultMessage: 'of', + })} + value={text} + isActive={false} + color={'secondary'} + /> + ); +}; + export const aggregationType: { [key: string]: any } = { avg: { text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.avg', { diff --git a/x-pack/plugins/infra/public/components/inventory/layout.tsx b/x-pack/plugins/infra/public/components/inventory/layout.tsx index 4dd9803c7bfce..3c91f9fa5946f 100644 --- a/x-pack/plugins/infra/public/components/inventory/layout.tsx +++ b/x-pack/plugins/infra/public/components/inventory/layout.tsx @@ -5,64 +5,89 @@ */ import React from 'react'; -import { InfraWaffleMapOptions, InfraWaffleMapBounds } from '../../lib/lib'; -import { KueryFilterQuery } from '../../store/local/waffle_filter'; +import { useInterval } from 'react-use'; +import { euiPaletteColorBlind } from '@elastic/eui'; import { NodesOverview } from '../nodes_overview'; import { Toolbar } from './toolbars/toolbar'; import { PageContent } from '../page'; import { useSnapshot } from '../../containers/waffle/use_snaphot'; import { useInventoryMeta } from '../../containers/inventory_metadata/use_inventory_meta'; -import { SnapshotMetricInput, SnapshotGroupBy } from '../../../common/http_api/snapshot_api'; -import { InventoryItemType } from '../../../common/inventory_models/types'; +import { useWaffleTimeContext } from '../../pages/inventory_view/hooks/use_waffle_time'; +import { useWaffleFiltersContext } from '../../pages/inventory_view/hooks/use_waffle_filters'; +import { useWaffleOptionsContext } from '../../pages/inventory_view/hooks/use_waffle_options'; +import { useSourceContext } from '../../containers/source'; +import { InfraFormatterType, InfraWaffleMapGradientLegend } from '../../lib/lib'; -export interface LayoutProps { - options: InfraWaffleMapOptions; - nodeType: InventoryItemType; - onDrilldown: (filter: KueryFilterQuery) => void; - currentTime: number; - onViewChange: (view: string) => void; - view: string; - boundsOverride: InfraWaffleMapBounds; - autoBounds: boolean; +const euiVisColorPalette = euiPaletteColorBlind(); - filterQuery: string | null | undefined; - metric: SnapshotMetricInput; - groupBy: SnapshotGroupBy; - sourceId: string; - accountId: string; - region: string; -} - -export const Layout = (props: LayoutProps) => { - const { accounts, regions } = useInventoryMeta(props.sourceId, props.nodeType); +export const Layout = () => { + const { sourceId, source } = useSourceContext(); + const { + metric, + groupBy, + nodeType, + accountId, + region, + changeView, + view, + autoBounds, + boundsOverride, + } = useWaffleOptionsContext(); + const { accounts, regions } = useInventoryMeta(sourceId, nodeType); + const { currentTime, jumpToTime, isAutoReloading } = useWaffleTimeContext(); + const { filterQueryAsJson, applyFilterQuery } = useWaffleFiltersContext(); const { loading, nodes, reload, interval } = useSnapshot( - props.filterQuery, - props.metric, - props.groupBy, - props.nodeType, - props.sourceId, - props.currentTime, - props.accountId, - props.region + filterQueryAsJson, + metric, + groupBy, + nodeType, + sourceId, + currentTime, + accountId, + region + ); + + const options = { + formatter: InfraFormatterType.percent, + formatTemplate: '{{value}}', + legend: { + type: 'gradient', + rules: [ + { value: 0, color: '#D3DAE6' }, + { value: 1, color: euiVisColorPalette[1] }, + ], + } as InfraWaffleMapGradientLegend, + metric, + fields: source?.configuration?.fields, + groupBy, + }; + + useInterval( + () => { + if (!loading) { + jumpToTime(Date.now()); + } + }, + isAutoReloading ? 5000 : null ); return ( <> - <Toolbar accounts={accounts} regions={regions} nodeType={props.nodeType} /> + <Toolbar accounts={accounts} regions={regions} nodeType={nodeType} /> <PageContent> <NodesOverview nodes={nodes} - options={props.options} - nodeType={props.nodeType} + options={options} + nodeType={nodeType} loading={loading} reload={reload} - onDrilldown={props.onDrilldown} - currentTime={props.currentTime} - onViewChange={props.onViewChange} - view={props.view} - autoBounds={props.autoBounds} - boundsOverride={props.boundsOverride} + onDrilldown={applyFilterQuery} + currentTime={currentTime} + onViewChange={changeView} + view={view} + autoBounds={autoBounds} + boundsOverride={boundsOverride} interval={interval} /> </PageContent> diff --git a/x-pack/plugins/infra/public/components/inventory/toolbars/save_views.tsx b/x-pack/plugins/infra/public/components/inventory/toolbars/save_views.tsx new file mode 100644 index 0000000000000..cb315d3e17b03 --- /dev/null +++ b/x-pack/plugins/infra/public/components/inventory/toolbars/save_views.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 { SavedViewsToolbarControls } from '../../saved_views/toolbar_control'; +import { inventoryViewSavedObjectType } from '../../../../common/saved_objects/inventory_view'; +import { useWaffleViewState } from '../../../pages/inventory_view/hooks/use_waffle_view_state'; + +export const SavedViews = () => { + const { viewState, defaultViewState, onViewChange } = useWaffleViewState(); + return ( + <SavedViewsToolbarControls + defaultViewState={defaultViewState} + viewState={viewState} + onViewChange={onViewChange} + viewType={inventoryViewSavedObjectType} + /> + ); +}; diff --git a/x-pack/plugins/infra/public/components/inventory/toolbars/toolbar.tsx b/x-pack/plugins/infra/public/components/inventory/toolbars/toolbar.tsx index c59ab994a018c..63ab6d2f4465a 100644 --- a/x-pack/plugins/infra/public/components/inventory/toolbars/toolbar.tsx +++ b/x-pack/plugins/infra/public/components/inventory/toolbars/toolbar.tsx @@ -5,7 +5,6 @@ */ import React, { FunctionComponent } from 'react'; -import { Action } from 'typescript-fsa'; import { EuiFlexItem } from '@elastic/eui'; import { SnapshotMetricInput, @@ -16,33 +15,23 @@ import { InventoryCloudAccount } from '../../../../common/http_api/inventory_met import { findToolbar } from '../../../../common/inventory_models/toolbars'; import { ToolbarWrapper } from './toolbar_wrapper'; -import { waffleOptionsSelectors } from '../../../store'; import { InfraGroupByOptions } from '../../../lib/lib'; -import { WithWaffleViewState } from '../../../containers/waffle/with_waffle_view_state'; -import { SavedViewsToolbarControls } from '../../saved_views/toolbar_control'; -import { inventoryViewSavedObjectType } from '../../../../common/saved_objects/inventory_view'; import { IIndexPattern } from '../../../../../../../src/plugins/data/public'; import { InventoryItemType } from '../../../../common/inventory_models/types'; +import { WaffleOptionsState } from '../../../pages/inventory_view/hooks/use_waffle_options'; +import { SavedViews } from './save_views'; -export interface ToolbarProps { +export interface ToolbarProps + extends Omit<WaffleOptionsState, 'view' | 'boundsOverride' | 'autoBounds'> { createDerivedIndexPattern: (type: 'logs' | 'metrics' | 'both') => IIndexPattern; - changeMetric: (payload: SnapshotMetricInput) => Action<SnapshotMetricInput>; - changeGroupBy: (payload: SnapshotGroupBy) => Action<SnapshotGroupBy>; - changeCustomOptions: (payload: InfraGroupByOptions[]) => Action<InfraGroupByOptions[]>; - changeAccount: (id: string) => Action<string>; - changeRegion: (name: string) => Action<string>; - customOptions: ReturnType<typeof waffleOptionsSelectors.selectCustomOptions>; - groupBy: ReturnType<typeof waffleOptionsSelectors.selectGroupBy>; - metric: ReturnType<typeof waffleOptionsSelectors.selectMetric>; - nodeType: ReturnType<typeof waffleOptionsSelectors.selectNodeType>; - accountId: ReturnType<typeof waffleOptionsSelectors.selectAccountId>; - region: ReturnType<typeof waffleOptionsSelectors.selectRegion>; + changeMetric: (payload: SnapshotMetricInput) => void; + changeGroupBy: (payload: SnapshotGroupBy) => void; + changeCustomOptions: (payload: InfraGroupByOptions[]) => void; + changeAccount: (id: string) => void; + changeRegion: (name: string) => void; accounts: InventoryCloudAccount[]; regions: string[]; - customMetrics: ReturnType<typeof waffleOptionsSelectors.selectCustomMetrics>; - changeCustomMetrics: ( - payload: SnapshotCustomMetricInput[] - ) => Action<SnapshotCustomMetricInput[]>; + changeCustomMetrics: (payload: SnapshotCustomMetricInput[]) => void; } const wrapToolbarItems = ( @@ -57,16 +46,7 @@ const wrapToolbarItems = ( <ToolbarItems {...props} accounts={accounts} regions={regions} /> <EuiFlexItem grow={true} /> <EuiFlexItem grow={false}> - <WithWaffleViewState indexPattern={props.createDerivedIndexPattern('metrics')}> - {({ defaultViewState, viewState, onViewChange }) => ( - <SavedViewsToolbarControls - defaultViewState={defaultViewState} - viewState={viewState} - onViewChange={onViewChange} - viewType={inventoryViewSavedObjectType} - /> - )} - </WithWaffleViewState> + <SavedViews /> </EuiFlexItem> </> )} diff --git a/x-pack/plugins/infra/public/components/inventory/toolbars/toolbar_wrapper.tsx b/x-pack/plugins/infra/public/components/inventory/toolbars/toolbar_wrapper.tsx index 735539d063b01..fefda94372cfb 100644 --- a/x-pack/plugins/infra/public/components/inventory/toolbars/toolbar_wrapper.tsx +++ b/x-pack/plugins/infra/public/components/inventory/toolbars/toolbar_wrapper.tsx @@ -8,58 +8,52 @@ import React from 'react'; import { EuiFlexGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { SnapshotMetricType } from '../../../../common/inventory_models/types'; -import { WithSource } from '../../../containers/with_source'; -import { WithWaffleOptions } from '../../../containers/waffle/with_waffle_options'; import { Toolbar } from '../../eui/toolbar'; import { ToolbarProps } from './toolbar'; import { fieldToName } from '../../waffle/lib/field_to_display_name'; +import { useSourceContext } from '../../../containers/source'; +import { useWaffleOptionsContext } from '../../../pages/inventory_view/hooks/use_waffle_options'; interface Props { children: (props: Omit<ToolbarProps, 'accounts' | 'regions'>) => React.ReactElement; } export const ToolbarWrapper = (props: Props) => { + const { + changeMetric, + changeGroupBy, + changeCustomOptions, + changeAccount, + changeRegion, + customOptions, + groupBy, + metric, + nodeType, + accountId, + region, + customMetrics, + changeCustomMetrics, + } = useWaffleOptionsContext(); + const { createDerivedIndexPattern } = useSourceContext(); return ( <Toolbar> <EuiFlexGroup alignItems="center" gutterSize="m"> - <WithSource> - {({ createDerivedIndexPattern }) => ( - <WithWaffleOptions> - {({ - changeMetric, - changeGroupBy, - changeCustomOptions, - changeAccount, - changeRegion, - customOptions, - groupBy, - metric, - nodeType, - accountId, - region, - customMetrics, - changeCustomMetrics, - }) => - props.children({ - createDerivedIndexPattern, - changeMetric, - changeGroupBy, - changeAccount, - changeRegion, - changeCustomOptions, - customOptions, - groupBy, - metric, - nodeType, - region, - accountId, - customMetrics, - changeCustomMetrics, - }) - } - </WithWaffleOptions> - )} - </WithSource> + {props.children({ + createDerivedIndexPattern, + changeMetric, + changeGroupBy, + changeAccount, + changeRegion, + changeCustomOptions, + customOptions, + groupBy, + metric, + nodeType, + region, + accountId, + customMetrics, + changeCustomMetrics, + })} </EuiFlexGroup> </Toolbar> ); diff --git a/x-pack/plugins/infra/public/components/nodes_overview/index.tsx b/x-pack/plugins/infra/public/components/nodes_overview/index.tsx index 4d61568a63b9f..ef22e0486f892 100644 --- a/x-pack/plugins/infra/public/components/nodes_overview/index.tsx +++ b/x-pack/plugins/infra/public/components/nodes_overview/index.tsx @@ -12,7 +12,6 @@ import React from 'react'; import { euiStyled } from '../../../../observability/public'; import { InfraFormatterType, InfraWaffleMapBounds, InfraWaffleMapOptions } from '../../lib/lib'; -import { KueryFilterQuery } from '../../store/local/waffle_filter'; import { createFormatter } from '../../utils/formatters'; import { NoData } from '../empty_states'; import { InfraLoadingPanel } from '../loading'; @@ -24,6 +23,11 @@ import { convertIntervalToString } from '../../utils/convert_interval_to_string' import { InventoryItemType } from '../../../common/inventory_models/types'; import { createFormatterForMetric } from '../metrics_explorer/helpers/create_formatter_for_metric'; +export interface KueryFilterQuery { + kind: 'kuery'; + expression: string; +} + interface Props { options: InfraWaffleMapOptions; nodeType: InventoryItemType; diff --git a/x-pack/plugins/infra/public/components/waffle/legend.tsx b/x-pack/plugins/infra/public/components/waffle/legend.tsx index de070efb35a1f..13e533b225d4d 100644 --- a/x-pack/plugins/infra/public/components/waffle/legend.tsx +++ b/x-pack/plugins/infra/public/components/waffle/legend.tsx @@ -6,12 +6,12 @@ import React from 'react'; import { euiStyled } from '../../../../observability/public'; -import { WithWaffleOptions } from '../../containers/waffle/with_waffle_options'; import { InfraFormatter, InfraWaffleMapBounds, InfraWaffleMapLegend } from '../../lib/lib'; import { GradientLegend } from './gradient_legend'; import { LegendControls } from './legend_controls'; import { isInfraWaffleMapGradientLegend, isInfraWaffleMapStepLegend } from './lib/type_guards'; import { StepLegend } from './steps_legend'; +import { useWaffleOptionsContext } from '../../pages/inventory_view/hooks/use_waffle_options'; interface Props { legend: InfraWaffleMapLegend; bounds: InfraWaffleMapBounds; @@ -25,22 +25,24 @@ interface LegendControlOptions { } export const Legend: React.FC<Props> = ({ dataBounds, legend, bounds, formatter }) => { + const { + changeBoundsOverride, + changeAutoBounds, + autoBounds, + boundsOverride, + } = useWaffleOptionsContext(); return ( <LegendContainer> - <WithWaffleOptions> - {({ changeBoundsOverride, changeAutoBounds, autoBounds, boundsOverride }) => ( - <LegendControls - dataBounds={dataBounds} - bounds={bounds} - autoBounds={autoBounds} - boundsOverride={boundsOverride} - onChange={(options: LegendControlOptions) => { - changeBoundsOverride(options.bounds); - changeAutoBounds(options.auto); - }} - /> - )} - </WithWaffleOptions> + <LegendControls + dataBounds={dataBounds} + bounds={bounds} + autoBounds={autoBounds} + boundsOverride={boundsOverride} + onChange={(options: LegendControlOptions) => { + changeBoundsOverride(options.bounds); + changeAutoBounds(options.auto); + }} + /> {isInfraWaffleMapGradientLegend(legend) && ( <GradientLegend formatter={formatter} legend={legend} bounds={bounds} /> )} diff --git a/x-pack/plugins/infra/public/components/waffle/lib/create_uptime_link.test.ts b/x-pack/plugins/infra/public/components/waffle/lib/create_uptime_link.test.ts index 18e5838a15b56..902969c83ba39 100644 --- a/x-pack/plugins/infra/public/components/waffle/lib/create_uptime_link.test.ts +++ b/x-pack/plugins/infra/public/components/waffle/lib/create_uptime_link.test.ts @@ -5,11 +5,7 @@ */ import { createUptimeLink } from './create_uptime_link'; -import { - InfraWaffleMapOptions, - InfraWaffleMapLegendMode, - InfraFormatterType, -} from '../../../lib/lib'; +import { InfraWaffleMapOptions, InfraFormatterType } from '../../../lib/lib'; import { SnapshotMetricType } from '../../../../common/inventory_models/types'; const options: InfraWaffleMapOptions = { @@ -26,7 +22,7 @@ const options: InfraWaffleMapOptions = { metric: { type: 'cpu' }, groupBy: [], legend: { - type: InfraWaffleMapLegendMode.gradient, + type: 'gradient', rules: [], }, }; diff --git a/x-pack/plugins/infra/public/components/waffle/lib/type_guards.ts b/x-pack/plugins/infra/public/components/waffle/lib/type_guards.ts index aff16374ae262..f793afee1b948 100644 --- a/x-pack/plugins/infra/public/components/waffle/lib/type_guards.ts +++ b/x-pack/plugins/infra/public/components/waffle/lib/type_guards.ts @@ -4,16 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - InfraWaffleMapGradientLegend, - InfraWaffleMapLegendMode, - InfraWaffleMapStepLegend, -} from '../../../lib/lib'; +import { InfraWaffleMapGradientLegend, InfraWaffleMapStepLegend } from '../../../lib/lib'; + export function isInfraWaffleMapStepLegend(subject: any): subject is InfraWaffleMapStepLegend { - return subject.type && subject.type === InfraWaffleMapLegendMode.step; + return subject.type && subject.type === 'step'; } + export function isInfraWaffleMapGradientLegend( subject: any ): subject is InfraWaffleMapGradientLegend { - return subject.type && subject.type === InfraWaffleMapLegendMode.gradient; + return subject.type && subject.type === 'gradient'; } diff --git a/x-pack/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx b/x-pack/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx index d265b418f010d..21da10a0a7650 100644 --- a/x-pack/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx +++ b/x-pack/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx @@ -16,36 +16,23 @@ import React, { useCallback, useState, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { findInventoryModel } from '../../../common/inventory_models'; import { InventoryItemType } from '../../../common/inventory_models/types'; -import { - SnapshotMetricInput, - SnapshotGroupBy, - SnapshotCustomMetricInput, -} from '../../../common/http_api/snapshot_api'; - -interface WaffleInventorySwitcherProps { - nodeType: InventoryItemType; - changeNodeType: (nodeType: InventoryItemType) => void; - changeGroupBy: (groupBy: SnapshotGroupBy) => void; - changeMetric: (metric: SnapshotMetricInput) => void; - changeCustomMetrics: (metrics: SnapshotCustomMetricInput[]) => void; - changeAccount: (id: string) => void; - changeRegion: (name: string) => void; -} +import { useWaffleOptionsContext } from '../../pages/inventory_view/hooks/use_waffle_options'; const getDisplayNameForType = (type: InventoryItemType) => { const inventoryModel = findInventoryModel(type); return inventoryModel.displayName; }; -export const WaffleInventorySwitcher: React.FC<WaffleInventorySwitcherProps> = ({ - changeNodeType, - changeGroupBy, - changeMetric, - changeAccount, - changeRegion, - changeCustomMetrics, - nodeType, -}) => { +export const WaffleInventorySwitcher: React.FC = () => { + const { + changeNodeType, + changeGroupBy, + changeMetric, + changeAccount, + changeRegion, + changeCustomMetrics, + nodeType, + } = useWaffleOptionsContext(); const [isOpen, setIsOpen] = useState(false); const closePopover = useCallback(() => setIsOpen(false), []); const openPopover = useCallback(() => setIsOpen(true), []); diff --git a/x-pack/plugins/infra/public/components/waffle/waffle_time_controls.tsx b/x-pack/plugins/infra/public/components/waffle/waffle_time_controls.tsx index 4f840336de8c3..458bb674afade 100644 --- a/x-pack/plugins/infra/public/components/waffle/waffle_time_controls.tsx +++ b/x-pack/plugins/infra/public/components/waffle/waffle_time_controls.tsx @@ -7,84 +7,60 @@ import { EuiButtonEmpty, EuiDatePicker, EuiFormControlLayout } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import moment, { Moment } from 'moment'; -import React from 'react'; -import { Action } from 'typescript-fsa'; +import React, { useCallback } from 'react'; +import { useWaffleTimeContext } from '../../pages/inventory_view/hooks/use_waffle_time'; -interface WaffleTimeControlsProps { - currentTime: number; - isLiveStreaming?: boolean; - onChangeTime?: (time: number) => void; - startLiveStreaming?: (payload: void) => Action<void>; - stopLiveStreaming?: (payload: void) => Action<void>; -} +export const WaffleTimeControls = () => { + const { + currentTime, + isAutoReloading, + startAutoReload, + stopAutoReload, + jumpToTime, + } = useWaffleTimeContext(); -export class WaffleTimeControls extends React.Component<WaffleTimeControlsProps> { - public render() { - const { currentTime, isLiveStreaming } = this.props; + const currentMoment = moment(currentTime); - const currentMoment = moment(currentTime); + const liveStreamingButton = isAutoReloading ? ( + <EuiButtonEmpty color="primary" iconSide="left" iconType="pause" onClick={stopAutoReload}> + <FormattedMessage + id="xpack.infra.waffleTime.stopRefreshingButtonLabel" + defaultMessage="Stop refreshing" + /> + </EuiButtonEmpty> + ) : ( + <EuiButtonEmpty iconSide="left" iconType="play" onClick={startAutoReload}> + <FormattedMessage + id="xpack.infra.waffleTime.autoRefreshButtonLabel" + defaultMessage="Auto-refresh" + /> + </EuiButtonEmpty> + ); - const liveStreamingButton = isLiveStreaming ? ( - <EuiButtonEmpty - color="primary" - iconSide="left" - iconType="pause" - onClick={this.stopLiveStreaming} - > - <FormattedMessage - id="xpack.infra.waffleTime.stopRefreshingButtonLabel" - defaultMessage="Stop refreshing" - /> - </EuiButtonEmpty> - ) : ( - <EuiButtonEmpty iconSide="left" iconType="play" onClick={this.startLiveStreaming}> - <FormattedMessage - id="xpack.infra.waffleTime.autoRefreshButtonLabel" - defaultMessage="Auto-refresh" - /> - </EuiButtonEmpty> - ); + const handleChangeDate = useCallback( + (time: Moment | null) => { + if (time) { + jumpToTime(time.valueOf()); + } + }, + [jumpToTime] + ); - return ( - <EuiFormControlLayout append={liveStreamingButton} data-test-subj="waffleDatePicker"> - <EuiDatePicker - className="euiFieldText--inGroup" - dateFormat="L LTS" - disabled={isLiveStreaming} - injectTimes={currentMoment ? [currentMoment] : []} - isLoading={isLiveStreaming} - onChange={this.handleChangeDate} - popperPlacement="top-end" - selected={currentMoment} - shouldCloseOnSelect - showTimeSelect - timeFormat="LT" - /> - </EuiFormControlLayout> - ); - } - - private handleChangeDate = (time: Moment | null) => { - const { onChangeTime } = this.props; - - if (onChangeTime && time) { - onChangeTime(time.valueOf()); - } - }; - - private startLiveStreaming = () => { - const { startLiveStreaming } = this.props; - - if (startLiveStreaming) { - startLiveStreaming(); - } - }; - - private stopLiveStreaming = () => { - const { stopLiveStreaming } = this.props; - - if (stopLiveStreaming) { - stopLiveStreaming(); - } - }; -} + return ( + <EuiFormControlLayout append={liveStreamingButton} data-test-subj="waffleDatePicker"> + <EuiDatePicker + className="euiFieldText--inGroup" + dateFormat="L LTS" + disabled={isAutoReloading} + injectTimes={currentMoment ? [currentMoment] : []} + isLoading={isAutoReloading} + onChange={handleChangeDate} + popperPlacement="top-end" + selected={currentMoment} + shouldCloseOnSelect + showTimeSelect + timeFormat="LT" + /> + </EuiFormControlLayout> + ); +}; diff --git a/x-pack/plugins/infra/public/containers/metadata/use_metadata.ts b/x-pack/plugins/infra/public/containers/metadata/use_metadata.ts index 52c522ce8efd4..1ba016195bef4 100644 --- a/x-pack/plugins/infra/public/containers/metadata/use_metadata.ts +++ b/x-pack/plugins/infra/public/containers/metadata/use_metadata.ts @@ -13,17 +13,18 @@ import { useHTTPRequest } from '../../hooks/use_http_request'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; import { InventoryMetric, InventoryItemType } from '../../../common/inventory_models/types'; import { getFilteredMetrics } from './lib/get_filtered_metrics'; +import { MetricsTimeInput } from '../../pages/metrics/hooks/use_metrics_time'; export function useMetadata( nodeId: string, nodeType: InventoryItemType, requiredMetrics: InventoryMetric[], - sourceId: string + sourceId: string, + timeRange: MetricsTimeInput ) { const decodeResponse = (response: any) => { return pipe(InfraMetadataRT.decode(response), fold(throwErrors(createPlainError), identity)); }; - const { error, loading, response, makeRequest } = useHTTPRequest<InfraMetadata>( '/api/infra/metadata', 'POST', @@ -31,6 +32,7 @@ export function useMetadata( nodeId, nodeType, sourceId, + timeRange: { from: timeRange.from, to: timeRange.to }, }), decodeResponse ); diff --git a/x-pack/plugins/infra/public/containers/waffle/index.ts b/x-pack/plugins/infra/public/containers/waffle/index.ts deleted file mode 100644 index 40c4bfc8cf678..0000000000000 --- a/x-pack/plugins/infra/public/containers/waffle/index.ts +++ /dev/null @@ -1,7 +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 * from './with_waffle_filters'; diff --git a/x-pack/plugins/infra/public/containers/waffle/waffle_nodes.gql_query.ts b/x-pack/plugins/infra/public/containers/waffle/waffle_nodes.gql_query.ts deleted file mode 100644 index 1ca6bc8c397e5..0000000000000 --- a/x-pack/plugins/infra/public/containers/waffle/waffle_nodes.gql_query.ts +++ /dev/null @@ -1,37 +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 gql from 'graphql-tag'; - -export const waffleNodesQuery = gql` - query WaffleNodesQuery( - $sourceId: ID! - $timerange: InfraTimerangeInput! - $filterQuery: String - $metric: InfraSnapshotMetricInput! - $groupBy: [InfraSnapshotGroupbyInput!]! - $type: InfraNodeType! - ) { - source(id: $sourceId) { - id - snapshot(timerange: $timerange, filterQuery: $filterQuery) { - nodes(groupBy: $groupBy, metric: $metric, type: $type) { - path { - value - label - ip - } - metric { - name - value - avg - max - } - } - } - } - } -`; diff --git a/x-pack/plugins/infra/public/containers/waffle/with_waffle_filters.tsx b/x-pack/plugins/infra/public/containers/waffle/with_waffle_filters.tsx deleted file mode 100644 index 0214237ef52d8..0000000000000 --- a/x-pack/plugins/infra/public/containers/waffle/with_waffle_filters.tsx +++ /dev/null @@ -1,96 +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 React from 'react'; -import { connect } from 'react-redux'; - -import { IIndexPattern } from 'src/plugins/data/public'; -import { State, waffleFilterActions, waffleFilterSelectors } from '../../store'; -import { FilterQuery } from '../../store/local/waffle_filter'; -import { convertKueryToElasticSearchQuery } from '../../utils/kuery'; -import { asChildFunctionRenderer } from '../../utils/typed_react'; -import { bindPlainActionCreators } from '../../utils/typed_redux'; -import { UrlStateContainer } from '../../utils/url_state'; - -interface WithWaffleFilterProps { - indexPattern: IIndexPattern; -} - -export const withWaffleFilter = connect( - (state: State) => ({ - filterQuery: waffleFilterSelectors.selectWaffleFilterQuery(state), - filterQueryDraft: waffleFilterSelectors.selectWaffleFilterQueryDraft(state), - filterQueryAsJson: waffleFilterSelectors.selectWaffleFilterQueryAsJson(state), - isFilterQueryDraftValid: waffleFilterSelectors.selectIsWaffleFilterQueryDraftValid(state), - }), - (dispatch, ownProps: WithWaffleFilterProps) => - bindPlainActionCreators({ - applyFilterQuery: (query: FilterQuery) => - waffleFilterActions.applyWaffleFilterQuery({ - query, - serializedQuery: convertKueryToElasticSearchQuery( - query.expression, - ownProps.indexPattern - ), - }), - applyFilterQueryFromKueryExpression: (expression: string) => - waffleFilterActions.applyWaffleFilterQuery({ - query: { - kind: 'kuery', - expression, - }, - serializedQuery: convertKueryToElasticSearchQuery(expression, ownProps.indexPattern), - }), - setFilterQueryDraft: waffleFilterActions.setWaffleFilterQueryDraft, - setFilterQueryDraftFromKueryExpression: (expression: string) => - waffleFilterActions.setWaffleFilterQueryDraft({ - kind: 'kuery', - expression, - }), - }) -); - -export const WithWaffleFilter = asChildFunctionRenderer(withWaffleFilter); - -/** - * Url State - */ - -type WaffleFilterUrlState = ReturnType<typeof waffleFilterSelectors.selectWaffleFilterQuery>; - -type WithWaffleFilterUrlStateProps = WithWaffleFilterProps; - -export const WithWaffleFilterUrlState: React.FC<WithWaffleFilterUrlStateProps> = ({ - indexPattern, -}) => ( - <WithWaffleFilter indexPattern={indexPattern}> - {({ applyFilterQuery, filterQuery }) => ( - <UrlStateContainer - urlState={filterQuery} - urlStateKey="waffleFilter" - mapToUrlState={mapToUrlState} - onChange={urlState => { - if (urlState) { - applyFilterQuery(urlState); - } - }} - onInitialize={urlState => { - if (urlState) { - applyFilterQuery(urlState); - } - }} - /> - )} - </WithWaffleFilter> -); - -const mapToUrlState = (value: any): WaffleFilterUrlState | undefined => - value && value.kind === 'kuery' && typeof value.expression === 'string' - ? { - kind: value.kind, - expression: value.expression, - } - : undefined; diff --git a/x-pack/plugins/infra/public/containers/waffle/with_waffle_options.tsx b/x-pack/plugins/infra/public/containers/waffle/with_waffle_options.tsx deleted file mode 100644 index 47dd6a5a73a73..0000000000000 --- a/x-pack/plugins/infra/public/containers/waffle/with_waffle_options.tsx +++ /dev/null @@ -1,265 +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 React from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; - -import { isBoolean, isNumber } from 'lodash'; -import { InfraGroupByOptions } from '../../lib/lib'; -import { State, waffleOptionsActions, waffleOptionsSelectors } from '../../store'; -import { asChildFunctionRenderer } from '../../utils/typed_react'; -import { bindPlainActionCreators } from '../../utils/typed_redux'; -import { UrlStateContainer } from '../../utils/url_state'; -import { - SnapshotMetricInput, - SnapshotGroupBy, - SnapshotCustomMetricInputRT, -} from '../../../common/http_api/snapshot_api'; -import { - SnapshotMetricTypeRT, - InventoryItemType, - ItemTypeRT, -} from '../../../common/inventory_models/types'; - -const selectOptionsUrlState = createSelector( - waffleOptionsSelectors.selectMetric, - waffleOptionsSelectors.selectView, - waffleOptionsSelectors.selectGroupBy, - waffleOptionsSelectors.selectNodeType, - waffleOptionsSelectors.selectCustomOptions, - waffleOptionsSelectors.selectBoundsOverride, - waffleOptionsSelectors.selectAutoBounds, - waffleOptionsSelectors.selectAccountId, - waffleOptionsSelectors.selectRegion, - waffleOptionsSelectors.selectCustomMetrics, - ( - metric, - view, - groupBy, - nodeType, - customOptions, - boundsOverride, - autoBounds, - accountId, - region, - customMetrics - ) => ({ - metric, - groupBy, - nodeType, - view, - customOptions, - boundsOverride, - autoBounds, - accountId, - region, - customMetrics, - }) -); - -export const withWaffleOptions = connect( - (state: State) => ({ - metric: waffleOptionsSelectors.selectMetric(state), - groupBy: waffleOptionsSelectors.selectGroupBy(state), - nodeType: waffleOptionsSelectors.selectNodeType(state), - view: waffleOptionsSelectors.selectView(state), - customOptions: waffleOptionsSelectors.selectCustomOptions(state), - boundsOverride: waffleOptionsSelectors.selectBoundsOverride(state), - autoBounds: waffleOptionsSelectors.selectAutoBounds(state), - accountId: waffleOptionsSelectors.selectAccountId(state), - region: waffleOptionsSelectors.selectRegion(state), - urlState: selectOptionsUrlState(state), - customMetrics: waffleOptionsSelectors.selectCustomMetrics(state), - }), - bindPlainActionCreators({ - changeMetric: waffleOptionsActions.changeMetric, - changeGroupBy: waffleOptionsActions.changeGroupBy, - changeNodeType: waffleOptionsActions.changeNodeType, - changeView: waffleOptionsActions.changeView, - changeCustomOptions: waffleOptionsActions.changeCustomOptions, - changeBoundsOverride: waffleOptionsActions.changeBoundsOverride, - changeAutoBounds: waffleOptionsActions.changeAutoBounds, - changeAccount: waffleOptionsActions.changeAccount, - changeRegion: waffleOptionsActions.changeRegion, - changeCustomMetrics: waffleOptionsActions.changeCustomMetrics, - }) -); - -export const WithWaffleOptions = asChildFunctionRenderer(withWaffleOptions); - -/** - * Url State - */ - -interface WaffleOptionsUrlState { - metric?: ReturnType<typeof waffleOptionsSelectors.selectMetric>; - groupBy?: ReturnType<typeof waffleOptionsSelectors.selectGroupBy>; - nodeType?: ReturnType<typeof waffleOptionsSelectors.selectNodeType>; - view?: ReturnType<typeof waffleOptionsSelectors.selectView>; - customOptions?: ReturnType<typeof waffleOptionsSelectors.selectCustomOptions>; - bounds?: ReturnType<typeof waffleOptionsSelectors.selectBoundsOverride>; - auto?: ReturnType<typeof waffleOptionsSelectors.selectAutoBounds>; - accountId?: ReturnType<typeof waffleOptionsSelectors.selectAccountId>; - region?: ReturnType<typeof waffleOptionsSelectors.selectRegion>; - customMetrics?: ReturnType<typeof waffleOptionsSelectors.selectCustomMetrics>; -} - -export const WithWaffleOptionsUrlState = () => ( - <WithWaffleOptions> - {({ - changeMetric, - urlState, - changeGroupBy, - changeNodeType, - changeView, - changeCustomOptions, - changeAutoBounds, - changeBoundsOverride, - changeAccount, - changeRegion, - changeCustomMetrics, - }) => ( - <UrlStateContainer<WaffleOptionsUrlState> - urlState={urlState} - urlStateKey="waffleOptions" - mapToUrlState={mapToUrlState} - onChange={newUrlState => { - if (newUrlState && newUrlState.metric) { - changeMetric(newUrlState.metric); - } - if (newUrlState && newUrlState.groupBy) { - changeGroupBy(newUrlState.groupBy); - } - if (newUrlState && newUrlState.nodeType) { - changeNodeType(newUrlState.nodeType); - } - if (newUrlState && newUrlState.view) { - changeView(newUrlState.view); - } - if (newUrlState && newUrlState.customOptions) { - changeCustomOptions(newUrlState.customOptions); - } - if (newUrlState && newUrlState.bounds) { - changeBoundsOverride(newUrlState.bounds); - } - if (newUrlState && newUrlState.auto) { - changeAutoBounds(newUrlState.auto); - } - if (newUrlState && newUrlState.accountId) { - changeAccount(newUrlState.accountId); - } - if (newUrlState && newUrlState.region) { - changeRegion(newUrlState.region); - } - if (newUrlState && newUrlState.customMetrics) { - changeCustomMetrics(newUrlState.customMetrics); - } - }} - onInitialize={initialUrlState => { - if (initialUrlState && initialUrlState.metric) { - changeMetric(initialUrlState.metric); - } - if (initialUrlState && initialUrlState.groupBy) { - changeGroupBy(initialUrlState.groupBy); - } - if (initialUrlState && initialUrlState.nodeType) { - changeNodeType(initialUrlState.nodeType); - } - if (initialUrlState && initialUrlState.view) { - changeView(initialUrlState.view); - } - if (initialUrlState && initialUrlState.customOptions) { - changeCustomOptions(initialUrlState.customOptions); - } - if (initialUrlState && initialUrlState.bounds) { - changeBoundsOverride(initialUrlState.bounds); - } - if (initialUrlState && initialUrlState.auto) { - changeAutoBounds(initialUrlState.auto); - } - if (initialUrlState && initialUrlState.accountId) { - changeAccount(initialUrlState.accountId); - } - if (initialUrlState && initialUrlState.region) { - changeRegion(initialUrlState.region); - } - if (initialUrlState && initialUrlState.customMetrics) { - changeCustomMetrics(initialUrlState.customMetrics); - } - }} - /> - )} - </WithWaffleOptions> -); - -const mapToUrlState = (value: any): WaffleOptionsUrlState | undefined => - value - ? { - metric: mapToMetricUrlState(value.metric), - groupBy: mapToGroupByUrlState(value.groupBy), - nodeType: mapToNodeTypeUrlState(value.nodeType), - view: mapToViewUrlState(value.view), - customOptions: mapToCustomOptionsUrlState(value.customOptions), - bounds: mapToBoundsOverideUrlState(value.boundsOverride), - auto: mapToAutoBoundsUrlState(value.autoBounds), - accountId: value.accountId, - region: value.region, - customMetrics: mapToCustomMetricsUrlState(value.customMetrics), - } - : undefined; - -const isInfraNodeType = (value: any): value is InventoryItemType => value in ItemTypeRT; - -const isInfraSnapshotMetricInput = (subject: any): subject is SnapshotMetricInput => { - return subject != null && subject.type in SnapshotMetricTypeRT; -}; - -const isInfraSnapshotGroupbyInput = (subject: any): subject is SnapshotGroupBy => { - return subject != null && subject.type != null; -}; - -const isInfraGroupByOption = (subject: any): subject is InfraGroupByOptions => { - return subject != null && subject.text != null && subject.field != null; -}; - -const mapToMetricUrlState = (subject: any) => { - return subject && isInfraSnapshotMetricInput(subject) ? subject : undefined; -}; - -const mapToGroupByUrlState = (subject: any) => { - return subject && Array.isArray(subject) && subject.every(isInfraSnapshotGroupbyInput) - ? subject - : undefined; -}; - -const mapToNodeTypeUrlState = (subject: any) => { - return isInfraNodeType(subject) ? subject : undefined; -}; - -const mapToViewUrlState = (subject: any) => { - return subject && ['map', 'table'].includes(subject) ? subject : undefined; -}; - -const mapToCustomOptionsUrlState = (subject: any) => { - return subject && Array.isArray(subject) && subject.every(isInfraGroupByOption) - ? subject - : undefined; -}; - -const mapToCustomMetricsUrlState = (subject: any) => { - return subject && Array.isArray(subject) && subject.every(s => SnapshotCustomMetricInputRT.is(s)) - ? subject - : []; -}; - -const mapToBoundsOverideUrlState = (subject: any) => { - return subject != null && isNumber(subject.max) && isNumber(subject.min) ? subject : undefined; -}; - -const mapToAutoBoundsUrlState = (subject: any) => { - return subject != null && isBoolean(subject) ? subject : undefined; -}; diff --git a/x-pack/plugins/infra/public/containers/waffle/with_waffle_time.tsx b/x-pack/plugins/infra/public/containers/waffle/with_waffle_time.tsx deleted file mode 100644 index 293f6184af21b..0000000000000 --- a/x-pack/plugins/infra/public/containers/waffle/with_waffle_time.tsx +++ /dev/null @@ -1,96 +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 React from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; - -import { State, waffleTimeActions, waffleTimeSelectors } from '../../store'; -import { asChildFunctionRenderer } from '../../utils/typed_react'; -import { bindPlainActionCreators } from '../../utils/typed_redux'; -import { UrlStateContainer } from '../../utils/url_state'; - -export const withWaffleTime = connect( - (state: State) => ({ - currentTime: waffleTimeSelectors.selectCurrentTime(state), - currentTimeRange: waffleTimeSelectors.selectCurrentTimeRange(state), - isAutoReloading: waffleTimeSelectors.selectIsAutoReloading(state), - urlState: selectTimeUrlState(state), - }), - bindPlainActionCreators({ - jumpToTime: waffleTimeActions.jumpToTime, - startAutoReload: waffleTimeActions.startAutoReload, - stopAutoReload: waffleTimeActions.stopAutoReload, - }) -); - -export const WithWaffleTime = asChildFunctionRenderer(withWaffleTime, { - onCleanup: ({ stopAutoReload }) => stopAutoReload(), -}); - -/** - * Url State - */ - -interface WaffleTimeUrlState { - time?: ReturnType<typeof waffleTimeSelectors.selectCurrentTime>; - autoReload?: ReturnType<typeof waffleTimeSelectors.selectIsAutoReloading>; -} - -export const WithWaffleTimeUrlState = () => ( - <WithWaffleTime> - {({ jumpToTime, startAutoReload, stopAutoReload, urlState }) => ( - <UrlStateContainer - urlState={urlState} - urlStateKey="waffleTime" - mapToUrlState={mapToUrlState} - onChange={newUrlState => { - if (newUrlState && newUrlState.time) { - jumpToTime(newUrlState.time); - } - if (newUrlState && newUrlState.autoReload) { - startAutoReload(); - } else if ( - newUrlState && - typeof newUrlState.autoReload !== 'undefined' && - !newUrlState.autoReload - ) { - stopAutoReload(); - } - }} - onInitialize={initialUrlState => { - if (initialUrlState) { - jumpToTime(initialUrlState.time ? initialUrlState.time : Date.now()); - } - if (initialUrlState && initialUrlState.autoReload) { - startAutoReload(); - } - }} - /> - )} - </WithWaffleTime> -); - -const selectTimeUrlState = createSelector( - waffleTimeSelectors.selectCurrentTime, - waffleTimeSelectors.selectIsAutoReloading, - (time, autoReload) => ({ - time, - autoReload, - }) -); - -const mapToUrlState = (value: any): WaffleTimeUrlState | undefined => - value - ? { - time: mapToTimeUrlState(value.time), - autoReload: mapToAutoReloadUrlState(value.autoReload), - } - : undefined; - -const mapToTimeUrlState = (value: any) => (value && typeof value === 'number' ? value : undefined); - -const mapToAutoReloadUrlState = (value: any) => (typeof value === 'boolean' ? value : undefined); diff --git a/x-pack/plugins/infra/public/containers/waffle/with_waffle_view_state.tsx b/x-pack/plugins/infra/public/containers/waffle/with_waffle_view_state.tsx deleted file mode 100644 index 421c506166d04..0000000000000 --- a/x-pack/plugins/infra/public/containers/waffle/with_waffle_view_state.tsx +++ /dev/null @@ -1,145 +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 { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { IIndexPattern } from 'src/plugins/data/public'; -import { - State, - waffleOptionsActions, - waffleOptionsSelectors, - waffleTimeSelectors, - waffleTimeActions, - waffleFilterActions, - waffleFilterSelectors, - initialState, -} from '../../store'; -import { asChildFunctionRenderer } from '../../utils/typed_react'; -import { convertKueryToElasticSearchQuery } from '../../utils/kuery'; - -const selectViewState = createSelector( - waffleOptionsSelectors.selectMetric, - waffleOptionsSelectors.selectView, - waffleOptionsSelectors.selectGroupBy, - waffleOptionsSelectors.selectNodeType, - waffleOptionsSelectors.selectCustomOptions, - waffleOptionsSelectors.selectBoundsOverride, - waffleOptionsSelectors.selectAutoBounds, - waffleTimeSelectors.selectCurrentTime, - waffleTimeSelectors.selectIsAutoReloading, - waffleFilterSelectors.selectWaffleFilterQuery, - waffleOptionsSelectors.selectCustomMetrics, - ( - metric, - view, - groupBy, - nodeType, - customOptions, - boundsOverride, - autoBounds, - time, - autoReload, - filterQuery, - customMetrics - ) => ({ - time, - autoReload, - metric, - groupBy, - nodeType, - view, - customOptions, - boundsOverride, - autoBounds, - filterQuery, - customMetrics, - }) -); - -interface Props { - indexPattern: IIndexPattern; -} - -export const withWaffleViewState = connect( - (state: State) => ({ - viewState: selectViewState(state), - defaultViewState: selectViewState(initialState), - }), - (dispatch, ownProps: Props) => { - return { - onViewChange: (viewState: WaffleViewState) => { - if (viewState.time) { - dispatch(waffleTimeActions.jumpToTime(viewState.time)); - } - if (viewState.autoReload) { - dispatch(waffleTimeActions.startAutoReload()); - } else if (typeof viewState.autoReload !== 'undefined' && !viewState.autoReload) { - dispatch(waffleTimeActions.stopAutoReload()); - } - if (viewState.metric) { - dispatch(waffleOptionsActions.changeMetric(viewState.metric)); - } - if (viewState.groupBy) { - dispatch(waffleOptionsActions.changeGroupBy(viewState.groupBy)); - } - if (viewState.nodeType) { - dispatch(waffleOptionsActions.changeNodeType(viewState.nodeType)); - } - if (viewState.view) { - dispatch(waffleOptionsActions.changeView(viewState.view)); - } - if (viewState.customOptions) { - dispatch(waffleOptionsActions.changeCustomOptions(viewState.customOptions)); - } - if (viewState.customMetrics) { - dispatch(waffleOptionsActions.changeCustomMetrics(viewState.customMetrics)); - } - if (viewState.boundsOverride) { - dispatch(waffleOptionsActions.changeBoundsOverride(viewState.boundsOverride)); - } - if (viewState.autoBounds) { - dispatch(waffleOptionsActions.changeAutoBounds(viewState.autoBounds)); - } - if (viewState.filterQuery) { - dispatch( - waffleFilterActions.applyWaffleFilterQuery({ - query: viewState.filterQuery, - serializedQuery: convertKueryToElasticSearchQuery( - viewState.filterQuery.expression, - ownProps.indexPattern - ), - }) - ); - } else { - dispatch( - waffleFilterActions.applyWaffleFilterQuery({ - query: null, - serializedQuery: null, - }) - ); - } - }, - }; - } -); - -export const WithWaffleViewState = asChildFunctionRenderer(withWaffleViewState); - -/** - * View State - */ -export interface WaffleViewState { - metric?: ReturnType<typeof waffleOptionsSelectors.selectMetric>; - groupBy?: ReturnType<typeof waffleOptionsSelectors.selectGroupBy>; - nodeType?: ReturnType<typeof waffleOptionsSelectors.selectNodeType>; - view?: ReturnType<typeof waffleOptionsSelectors.selectView>; - customOptions?: ReturnType<typeof waffleOptionsSelectors.selectCustomOptions>; - customMetrics?: ReturnType<typeof waffleOptionsSelectors.selectCustomMetrics>; - boundsOverride?: ReturnType<typeof waffleOptionsSelectors.selectBoundsOverride>; - autoBounds?: ReturnType<typeof waffleOptionsSelectors.selectAutoBounds>; - time?: ReturnType<typeof waffleTimeSelectors.selectCurrentTime>; - autoReload?: ReturnType<typeof waffleTimeSelectors.selectIsAutoReloading>; - filterQuery?: ReturnType<typeof waffleFilterSelectors.selectWaffleFilterQuery>; -} diff --git a/x-pack/plugins/infra/public/containers/with_options.tsx b/x-pack/plugins/infra/public/containers/with_options.tsx index 972722890ffef..e18fc85a68d60 100644 --- a/x-pack/plugins/infra/public/containers/with_options.tsx +++ b/x-pack/plugins/infra/public/containers/with_options.tsx @@ -8,7 +8,7 @@ import moment from 'moment'; import React from 'react'; import { euiPaletteColorBlind } from '@elastic/eui'; -import { InfraFormatterType, InfraOptions, InfraWaffleMapLegendMode } from '../lib/lib'; +import { InfraFormatterType, InfraOptions } from '../lib/lib'; import { RendererFunction } from '../utils/typed_react'; const euiVisColorPalette = euiPaletteColorBlind(); @@ -29,7 +29,7 @@ const initialState = { metric: { type: 'cpu' }, groupBy: [], legend: { - type: InfraWaffleMapLegendMode.gradient, + type: 'gradient', rules: [ { value: 0, diff --git a/x-pack/plugins/infra/public/lib/lib.ts b/x-pack/plugins/infra/public/lib/lib.ts index 9f851e185018b..e4de0caf9bb8b 100644 --- a/x-pack/plugins/infra/public/lib/lib.ts +++ b/x-pack/plugins/infra/public/lib/lib.ts @@ -136,18 +136,13 @@ export interface InfraWaffleMapGradientRule { color: string; } -export enum InfraWaffleMapLegendMode { - step = 'step', - gradient = 'gradient', -} - export interface InfraWaffleMapStepLegend { - type: InfraWaffleMapLegendMode.step; + type: 'step'; rules: InfraWaffleMapStepRule[]; } export interface InfraWaffleMapGradientLegend { - type: InfraWaffleMapLegendMode.gradient; + type: 'gradient'; rules: InfraWaffleMapGradientRule[]; } diff --git a/x-pack/plugins/infra/public/pages/infrastructure/index.tsx b/x-pack/plugins/infra/public/pages/infrastructure/index.tsx index 422eb53148fe6..d592ae3480fc9 100644 --- a/x-pack/plugins/infra/public/pages/infrastructure/index.tsx +++ b/x-pack/plugins/infra/public/pages/infrastructure/index.tsx @@ -25,6 +25,9 @@ import { MetricsSettingsPage } from './settings'; import { AppNavigation } from '../../components/navigation/app_navigation'; import { SourceLoadingPage } from '../../components/source_loading_page'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { WaffleOptionsProvider } from '../inventory_view/hooks/use_waffle_options'; +import { WaffleTimeProvider } from '../inventory_view/hooks/use_waffle_time'; +import { WaffleFiltersProvider } from '../inventory_view/hooks/use_waffle_filters'; import { AlertDropdown } from '../../components/alerting/metrics/alert_dropdown'; export const InfrastructurePage = ({ match }: RouteComponentProps) => { @@ -32,96 +35,101 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { return ( <Source.Provider sourceId="default"> - <ColumnarPage> - <DocumentTitle - title={i18n.translate('xpack.infra.homePage.documentTitle', { - defaultMessage: 'Metrics', - })} - /> - - <HelpCenterContent - feedbackLink="https://discuss.elastic.co/c/metrics" - appName={i18n.translate('xpack.infra.header.infrastructureHelpAppName', { - defaultMessage: 'Metrics', - })} - /> + <WaffleOptionsProvider> + <WaffleTimeProvider> + <WaffleFiltersProvider> + <ColumnarPage> + <DocumentTitle + title={i18n.translate('xpack.infra.homePage.documentTitle', { + defaultMessage: 'Metrics', + })} + /> - <Header - breadcrumbs={[ - { - text: i18n.translate('xpack.infra.header.infrastructureTitle', { - defaultMessage: 'Metrics', - }), - }, - ]} - readOnlyBadge={!uiCapabilities?.infrastructure?.save} - /> + <HelpCenterContent + feedbackLink="https://discuss.elastic.co/c/metrics" + appName={i18n.translate('xpack.infra.header.infrastructureHelpAppName', { + defaultMessage: 'Metrics', + })} + /> - <AppNavigation - aria-label={i18n.translate('xpack.infra.header.infrastructureNavigationTitle', { - defaultMessage: 'Metrics', - })} - > - <EuiFlexGroup gutterSize={'none'} alignItems={'center'}> - <EuiFlexItem> - <RoutedTabs - tabs={[ - { - app: 'metrics', - title: i18n.translate('xpack.infra.homePage.inventoryTabTitle', { - defaultMessage: 'Inventory', - }), - pathname: '/inventory', - }, - { - app: 'metrics', - title: i18n.translate('xpack.infra.homePage.metricsExplorerTabTitle', { - defaultMessage: 'Metrics Explorer', - }), - pathname: '/explorer', - }, + <Header + breadcrumbs={[ { - app: 'metrics', - title: i18n.translate('xpack.infra.homePage.settingsTabTitle', { - defaultMessage: 'Settings', + text: i18n.translate('xpack.infra.header.infrastructureTitle', { + defaultMessage: 'Metrics', }), - pathname: '/settings', }, ]} + readOnlyBadge={!uiCapabilities?.infrastructure?.save} /> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <Route path={'/explorer'} component={AlertDropdown} /> - </EuiFlexItem> - </EuiFlexGroup> - </AppNavigation> + <AppNavigation + aria-label={i18n.translate('xpack.infra.header.infrastructureNavigationTitle', { + defaultMessage: 'Metrics', + })} + > + <EuiFlexGroup gutterSize={'none'} alignItems={'center'}> + <EuiFlexItem> + <RoutedTabs + tabs={[ + { + app: 'metrics', + title: i18n.translate('xpack.infra.homePage.inventoryTabTitle', { + defaultMessage: 'Inventory', + }), + pathname: '/inventory', + }, + { + app: 'metrics', + title: i18n.translate('xpack.infra.homePage.metricsExplorerTabTitle', { + defaultMessage: 'Metrics Explorer', + }), + pathname: '/explorer', + }, + { + app: 'metrics', + title: i18n.translate('xpack.infra.homePage.settingsTabTitle', { + defaultMessage: 'Settings', + }), + pathname: '/settings', + }, + ]} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <Route path={'/explorer'} component={AlertDropdown} /> + </EuiFlexItem> + </EuiFlexGroup> + </AppNavigation> - <Switch> - <Route path={'/inventory'} component={SnapshotPage} /> - <Route - path={'/explorer'} - render={props => ( - <WithSource> - {({ configuration, createDerivedIndexPattern }) => ( - <MetricsExplorerOptionsContainer.Provider> - <WithMetricsExplorerOptionsUrlState /> - {configuration ? ( - <MetricsExplorerPage - derivedIndexPattern={createDerivedIndexPattern('metrics')} - source={configuration} - {...props} - /> - ) : ( - <SourceLoadingPage /> - )} - </MetricsExplorerOptionsContainer.Provider> - )} - </WithSource> - )} - /> - <Route path={'/settings'} component={MetricsSettingsPage} /> - </Switch> - </ColumnarPage> + <Switch> + <Route path={'/inventory'} component={SnapshotPage} /> + <Route + path={'/explorer'} + render={props => ( + <WithSource> + {({ configuration, createDerivedIndexPattern }) => ( + <MetricsExplorerOptionsContainer.Provider> + <WithMetricsExplorerOptionsUrlState /> + {configuration ? ( + <MetricsExplorerPage + derivedIndexPattern={createDerivedIndexPattern('metrics')} + source={configuration} + {...props} + /> + ) : ( + <SourceLoadingPage /> + )} + </MetricsExplorerOptionsContainer.Provider> + )} + </WithSource> + )} + /> + <Route path={'/settings'} component={MetricsSettingsPage} /> + </Switch> + </ColumnarPage> + </WaffleFiltersProvider> + </WaffleTimeProvider> + </WaffleOptionsProvider> </Source.Provider> ); }; diff --git a/x-pack/plugins/infra/public/pages/infrastructure/snapshot/index.tsx b/x-pack/plugins/infra/public/pages/infrastructure/snapshot/index.tsx index dbb8b2d8e2952..48cc56388c0f2 100644 --- a/x-pack/plugins/infra/public/pages/infrastructure/snapshot/index.tsx +++ b/x-pack/plugins/infra/public/pages/infrastructure/snapshot/index.tsx @@ -8,7 +8,6 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useContext } from 'react'; -import { SnapshotPageContent } from './page_content'; import { SnapshotToolbar } from './toolbar'; import { DocumentTitle } from '../../../components/document_title'; @@ -19,17 +18,14 @@ import { SourceErrorPage } from '../../../components/source_error_page'; import { SourceLoadingPage } from '../../../components/source_loading_page'; import { ViewSourceConfigurationButton } from '../../../components/source_configuration'; import { Source } from '../../../containers/source'; -import { WithWaffleFilterUrlState } from '../../../containers/waffle/with_waffle_filters'; -import { WithWaffleOptionsUrlState } from '../../../containers/waffle/with_waffle_options'; -import { WithWaffleTimeUrlState } from '../../../containers/waffle/with_waffle_time'; import { useTrackPageview } from '../../../../../observability/public'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { Layout } from '../../../components/inventory/layout'; import { useLinkProps } from '../../../hooks/use_link_props'; export const SnapshotPage = () => { const uiCapabilities = useKibana().services.application?.capabilities; const { - createDerivedIndexPattern, hasFailedLoadingSource, isLoading, loadSourceFailureMessage, @@ -60,11 +56,8 @@ export const SnapshotPage = () => { <SourceLoadingPage /> ) : metricIndicesExist ? ( <> - <WithWaffleTimeUrlState /> - <WithWaffleFilterUrlState indexPattern={createDerivedIndexPattern('metrics')} /> - <WithWaffleOptionsUrlState /> <SnapshotToolbar /> - <SnapshotPageContent /> + <Layout /> </> ) : hasFailedLoadingSource ? ( <SourceErrorPage errorMessage={loadSourceFailureMessage || ''} retry={loadSource} /> diff --git a/x-pack/plugins/infra/public/pages/infrastructure/snapshot/page_content.tsx b/x-pack/plugins/infra/public/pages/infrastructure/snapshot/page_content.tsx deleted file mode 100644 index 83a4c8d3a497f..0000000000000 --- a/x-pack/plugins/infra/public/pages/infrastructure/snapshot/page_content.tsx +++ /dev/null @@ -1,68 +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 React from 'react'; - -import { WithWaffleFilter } from '../../../containers/waffle/with_waffle_filters'; -import { WithWaffleOptions } from '../../../containers/waffle/with_waffle_options'; -import { WithWaffleTime } from '../../../containers/waffle/with_waffle_time'; -import { WithOptions } from '../../../containers/with_options'; -import { WithSource } from '../../../containers/with_source'; -import { Layout } from '../../../components/inventory/layout'; - -export const SnapshotPageContent: React.FC = () => ( - <WithSource> - {({ configuration, createDerivedIndexPattern, sourceId }) => ( - <WithOptions> - {({ wafflemap }) => ( - <WithWaffleFilter indexPattern={createDerivedIndexPattern('metrics')}> - {({ filterQueryAsJson, applyFilterQuery }) => ( - <WithWaffleTime> - {({ currentTime }) => ( - <WithWaffleOptions> - {({ - metric, - groupBy, - nodeType, - view, - changeView, - autoBounds, - boundsOverride, - accountId, - region, - }) => ( - <Layout - currentTime={currentTime} - filterQuery={filterQueryAsJson} - metric={metric} - groupBy={groupBy} - nodeType={nodeType} - sourceId={sourceId} - options={{ - ...wafflemap, - metric, - fields: configuration && configuration.fields, - groupBy, - }} - onDrilldown={applyFilterQuery} - view={view} - onViewChange={changeView} - autoBounds={autoBounds} - boundsOverride={boundsOverride} - accountId={accountId} - region={region} - /> - )} - </WithWaffleOptions> - )} - </WithWaffleTime> - )} - </WithWaffleFilter> - )} - </WithOptions> - )} - </WithSource> -); diff --git a/x-pack/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx b/x-pack/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx index 3606580e86504..ccdaa5e8dc785 100644 --- a/x-pack/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx @@ -5,92 +5,24 @@ */ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import React from 'react'; -import { AutocompleteField } from '../../../components/autocomplete_field'; import { Toolbar } from '../../../components/eui/toolbar'; import { WaffleTimeControls } from '../../../components/waffle/waffle_time_controls'; -import { WithWaffleFilter } from '../../../containers/waffle/with_waffle_filters'; -import { WithWaffleTime } from '../../../containers/waffle/with_waffle_time'; -import { WithKueryAutocompletion } from '../../../containers/with_kuery_autocompletion'; -import { WithSource } from '../../../containers/with_source'; -import { WithWaffleOptions } from '../../../containers/waffle/with_waffle_options'; import { WaffleInventorySwitcher } from '../../../components/waffle/waffle_inventory_switcher'; +import { SearchBar } from '../../inventory_view/compontents/search_bar'; export const SnapshotToolbar = () => ( <Toolbar> <EuiFlexGroup alignItems="center" justifyContent="spaceBetween" gutterSize="m"> <EuiFlexItem grow={false}> - <WithWaffleOptions> - {({ - changeMetric, - changeNodeType, - changeGroupBy, - changeAccount, - changeRegion, - changeCustomMetrics, - nodeType, - }) => ( - <WaffleInventorySwitcher - nodeType={nodeType} - changeNodeType={changeNodeType} - changeMetric={changeMetric} - changeGroupBy={changeGroupBy} - changeAccount={changeAccount} - changeRegion={changeRegion} - changeCustomMetrics={changeCustomMetrics} - /> - )} - </WithWaffleOptions> + <WaffleInventorySwitcher /> </EuiFlexItem> <EuiFlexItem> - <WithSource> - {({ createDerivedIndexPattern }) => ( - <WithKueryAutocompletion indexPattern={createDerivedIndexPattern('metrics')}> - {({ isLoadingSuggestions, loadSuggestions, suggestions }) => ( - <WithWaffleFilter indexPattern={createDerivedIndexPattern('metrics')}> - {({ - applyFilterQueryFromKueryExpression, - filterQueryDraft, - isFilterQueryDraftValid, - setFilterQueryDraftFromKueryExpression, - }) => ( - <AutocompleteField - isLoadingSuggestions={isLoadingSuggestions} - isValid={isFilterQueryDraftValid} - loadSuggestions={loadSuggestions} - onChange={setFilterQueryDraftFromKueryExpression} - onSubmit={applyFilterQueryFromKueryExpression} - placeholder={i18n.translate( - 'xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder', - { - defaultMessage: 'Search for infrastructure data… (e.g. host.name:host-1)', - } - )} - suggestions={suggestions} - value={filterQueryDraft ? filterQueryDraft.expression : ''} - autoFocus={true} - /> - )} - </WithWaffleFilter> - )} - </WithKueryAutocompletion> - )} - </WithSource> + <SearchBar /> </EuiFlexItem> <EuiFlexItem grow={false}> - <WithWaffleTime resetOnUnmount> - {({ currentTime, isAutoReloading, jumpToTime, startAutoReload, stopAutoReload }) => ( - <WaffleTimeControls - currentTime={currentTime} - isLiveStreaming={isAutoReloading} - onChangeTime={jumpToTime} - startLiveStreaming={startAutoReload} - stopLiveStreaming={stopAutoReload} - /> - )} - </WithWaffleTime> + <WaffleTimeControls /> </EuiFlexItem> </EuiFlexGroup> </Toolbar> diff --git a/x-pack/plugins/infra/public/pages/inventory_view/compontents/search_bar.tsx b/x-pack/plugins/infra/public/pages/inventory_view/compontents/search_bar.tsx new file mode 100644 index 0000000000000..f4fde46d434f8 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/inventory_view/compontents/search_bar.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useContext } from 'react'; +import { i18n } from '@kbn/i18n'; +import { Source } from '../../../containers/source'; +import { AutocompleteField } from '../../../components/autocomplete_field'; +import { WithKueryAutocompletion } from '../../../containers/with_kuery_autocompletion'; +import { useWaffleFiltersContext } from '../hooks/use_waffle_filters'; + +export const SearchBar = () => { + const { createDerivedIndexPattern } = useContext(Source.Context); + const { + applyFilterQueryFromKueryExpression, + filterQueryDraft, + isFilterQueryDraftValid, + setFilterQueryDraftFromKueryExpression, + } = useWaffleFiltersContext(); + return ( + <WithKueryAutocompletion indexPattern={createDerivedIndexPattern('metrics')}> + {({ isLoadingSuggestions, loadSuggestions, suggestions }) => ( + <AutocompleteField + isLoadingSuggestions={isLoadingSuggestions} + isValid={isFilterQueryDraftValid} + loadSuggestions={loadSuggestions} + onChange={setFilterQueryDraftFromKueryExpression} + onSubmit={applyFilterQueryFromKueryExpression} + placeholder={i18n.translate('xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder', { + defaultMessage: 'Search for infrastructure data… (e.g. host.name:host-1)', + })} + suggestions={suggestions} + value={filterQueryDraft ? filterQueryDraft : ''} + autoFocus={true} + /> + )} + </WithKueryAutocompletion> + ); +}; diff --git a/x-pack/plugins/infra/public/pages/inventory_view/hooks/use_waffle_filters.ts b/x-pack/plugins/infra/public/pages/inventory_view/hooks/use_waffle_filters.ts new file mode 100644 index 0000000000000..02c079dcaddc4 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/inventory_view/hooks/use_waffle_filters.ts @@ -0,0 +1,93 @@ +/* + * 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, useMemo, useCallback, useEffect } from 'react'; +import * as rt from 'io-ts'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { constant, identity } from 'fp-ts/lib/function'; +import createContainter from 'constate'; +import { useUrlState } from '../../../utils/use_url_state'; +import { useSourceContext } from '../../../containers/source'; +import { convertKueryToElasticSearchQuery } from '../../../utils/kuery'; +import { esKuery } from '../../../../../../../src/plugins/data/public'; + +const validateKuery = (expression: string) => { + try { + esKuery.fromKueryExpression(expression); + } catch (err) { + return false; + } + return true; +}; + +export const DEFAULT_WAFFLE_FILTERS_STATE: WaffleFiltersState = { kind: 'kuery', expression: '' }; + +export const useWaffleFilters = () => { + const { createDerivedIndexPattern } = useSourceContext(); + const indexPattern = createDerivedIndexPattern('metrics'); + + const [urlState, setUrlState] = useUrlState<WaffleFiltersState>({ + defaultState: DEFAULT_WAFFLE_FILTERS_STATE, + decodeUrlState, + encodeUrlState, + urlStateKey: 'waffleFilter', + }); + + const [state, setState] = useState<WaffleFiltersState>(urlState); + + useEffect(() => setUrlState(state), [setUrlState, state]); + + const [filterQueryDraft, setFilterQueryDraft] = useState<string>(urlState.expression); + + const filterQueryAsJson = useMemo( + () => convertKueryToElasticSearchQuery(urlState.expression, indexPattern), + [indexPattern, urlState.expression] + ); + + const applyFilterQueryFromKueryExpression = useCallback( + (expression: string) => { + setState(previous => ({ + ...previous, + kind: 'kuery', + expression, + })); + }, + [setState] + ); + + const applyFilterQuery = useCallback((filterQuery: WaffleFiltersState) => { + setState(filterQuery); + setFilterQueryDraft(filterQuery.expression); + }, []); + + const isFilterQueryDraftValid = useMemo(() => validateKuery(filterQueryDraft), [ + filterQueryDraft, + ]); + + return { + filterQuery: urlState, + filterQueryDraft, + filterQueryAsJson, + applyFilterQuery, + setFilterQueryDraftFromKueryExpression: setFilterQueryDraft, + applyFilterQueryFromKueryExpression, + isFilterQueryDraftValid, + setWaffleFiltersState: applyFilterQuery, + }; +}; + +export const WaffleFiltersStateRT = rt.type({ + kind: rt.literal('kuery'), + expression: rt.string, +}); + +export type WaffleFiltersState = rt.TypeOf<typeof WaffleFiltersStateRT>; +const encodeUrlState = WaffleFiltersStateRT.encode; +const decodeUrlState = (value: unknown) => + pipe(WaffleFiltersStateRT.decode(value), fold(constant(undefined), identity)); +export const WaffleFilters = createContainter(useWaffleFilters); +export const [WaffleFiltersProvider, useWaffleFiltersContext] = WaffleFilters; diff --git a/x-pack/plugins/infra/public/pages/inventory_view/hooks/use_waffle_options.ts b/x-pack/plugins/infra/public/pages/inventory_view/hooks/use_waffle_options.ts new file mode 100644 index 0000000000000..2853917d5f683 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/inventory_view/hooks/use_waffle_options.ts @@ -0,0 +1,147 @@ +/* + * 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 { useCallback, useState, useEffect } from 'react'; +import * as rt from 'io-ts'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { constant, identity } from 'fp-ts/lib/function'; +import createContainer from 'constate'; +import { + SnapshotMetricInput, + SnapshotGroupBy, + SnapshotCustomMetricInput, + SnapshotMetricInputRT, + SnapshotGroupByRT, + SnapshotCustomMetricInputRT, +} from '../../../../common/http_api/snapshot_api'; +import { useUrlState } from '../../../utils/use_url_state'; +import { InventoryItemType, ItemTypeRT } from '../../../../common/inventory_models/types'; + +export const DEFAULT_WAFFLE_OPTIONS_STATE: WaffleOptionsState = { + metric: { type: 'cpu' }, + groupBy: [], + nodeType: 'host', + view: 'map', + customOptions: [], + boundsOverride: { max: 1, min: 0 }, + autoBounds: true, + accountId: '', + region: '', + customMetrics: [], +}; + +export const useWaffleOptions = () => { + const [urlState, setUrlState] = useUrlState<WaffleOptionsState>({ + defaultState: DEFAULT_WAFFLE_OPTIONS_STATE, + decodeUrlState, + encodeUrlState, + urlStateKey: 'waffleOptions', + }); + + const [state, setState] = useState<WaffleOptionsState>(urlState); + + useEffect(() => setUrlState(state), [setUrlState, state]); + + const changeMetric = useCallback( + (metric: SnapshotMetricInput) => setState(previous => ({ ...previous, metric })), + [setState] + ); + + const changeGroupBy = useCallback( + (groupBy: SnapshotGroupBy) => setState(previous => ({ ...previous, groupBy })), + [setState] + ); + + const changeNodeType = useCallback( + (nodeType: InventoryItemType) => setState(previous => ({ ...previous, nodeType })), + [setState] + ); + + const changeView = useCallback((view: string) => setState(previous => ({ ...previous, view })), [ + setState, + ]); + + const changeCustomOptions = useCallback( + (customOptions: Array<{ text: string; field: string }>) => + setState(previous => ({ ...previous, customOptions })), + [setState] + ); + + const changeAutoBounds = useCallback( + (autoBounds: boolean) => setState(previous => ({ ...previous, autoBounds })), + [setState] + ); + + const changeBoundsOverride = useCallback( + (boundsOverride: { min: number; max: number }) => + setState(previous => ({ ...previous, boundsOverride })), + [setState] + ); + + const changeAccount = useCallback( + (accountId: string) => setState(previous => ({ ...previous, accountId })), + [setState] + ); + + const changeRegion = useCallback( + (region: string) => setState(previous => ({ ...previous, region })), + [setState] + ); + + const changeCustomMetrics = useCallback( + (customMetrics: SnapshotCustomMetricInput[]) => { + setState(previous => ({ ...previous, customMetrics })); + }, + [setState] + ); + + return { + ...state, + changeMetric, + changeGroupBy, + changeNodeType, + changeView, + changeCustomOptions, + changeAutoBounds, + changeBoundsOverride, + changeAccount, + changeRegion, + changeCustomMetrics, + setWaffleOptionsState: setState, + }; +}; + +export const WaffleOptionsStateRT = rt.type({ + metric: SnapshotMetricInputRT, + groupBy: SnapshotGroupByRT, + nodeType: ItemTypeRT, + view: rt.string, + customOptions: rt.array( + rt.type({ + text: rt.string, + field: rt.string, + }) + ), + boundsOverride: rt.type({ + min: rt.number, + max: rt.number, + }), + autoBounds: rt.boolean, + accountId: rt.string, + region: rt.string, + customMetrics: rt.array(SnapshotCustomMetricInputRT), +}); + +export type WaffleOptionsState = rt.TypeOf<typeof WaffleOptionsStateRT>; +const encodeUrlState = (state: WaffleOptionsState) => { + return WaffleOptionsStateRT.encode(state); +}; +const decodeUrlState = (value: unknown) => + pipe(WaffleOptionsStateRT.decode(value), fold(constant(undefined), identity)); + +export const WaffleOptions = createContainer(useWaffleOptions); +export const [WaffleOptionsProvider, useWaffleOptionsContext] = WaffleOptions; diff --git a/x-pack/plugins/infra/public/pages/inventory_view/hooks/use_waffle_time.ts b/x-pack/plugins/infra/public/pages/inventory_view/hooks/use_waffle_time.ts new file mode 100644 index 0000000000000..051b5e598cb75 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/inventory_view/hooks/use_waffle_time.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 { useCallback, useState, useEffect } from 'react'; +import * as rt from 'io-ts'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { constant, identity } from 'fp-ts/lib/function'; +import createContainer from 'constate'; +import { useUrlState } from '../../../utils/use_url_state'; + +export const DEFAULT_WAFFLE_TIME_STATE: WaffleTimeState = { + currentTime: Date.now(), + isAutoReloading: false, +}; + +export const useWaffleTime = () => { + const [urlState, setUrlState] = useUrlState<WaffleTimeState>({ + defaultState: DEFAULT_WAFFLE_TIME_STATE, + decodeUrlState, + encodeUrlState, + urlStateKey: 'waffleTime', + }); + + const [state, setState] = useState<WaffleTimeState>(urlState); + + useEffect(() => setUrlState(state), [setUrlState, state]); + + const { currentTime, isAutoReloading } = urlState; + + const startAutoReload = useCallback(() => { + setState(previous => ({ ...previous, isAutoReloading: true })); + }, [setState]); + + const stopAutoReload = useCallback(() => { + setState(previous => ({ ...previous, isAutoReloading: false })); + }, [setState]); + + const jumpToTime = useCallback( + (time: number) => { + setState(previous => ({ ...previous, currentTime: time })); + }, + [setState] + ); + + const currentTimeRange = { + from: currentTime - 1000 * 60 * 5, + interval: '1m', + to: currentTime, + }; + + return { + currentTime, + currentTimeRange, + isAutoReloading, + startAutoReload, + stopAutoReload, + jumpToTime, + setWaffleTimeState: setState, + }; +}; + +export const WaffleTimeStateRT = rt.type({ + currentTime: rt.number, + isAutoReloading: rt.boolean, +}); + +export type WaffleTimeState = rt.TypeOf<typeof WaffleTimeStateRT>; +const encodeUrlState = WaffleTimeStateRT.encode; +const decodeUrlState = (value: unknown) => + pipe(WaffleTimeStateRT.decode(value), fold(constant(undefined), identity)); + +export const WaffleTime = createContainer(useWaffleTime); +export const [WaffleTimeProvider, useWaffleTimeContext] = WaffleTime; diff --git a/x-pack/plugins/infra/public/pages/inventory_view/hooks/use_waffle_view_state.ts b/x-pack/plugins/infra/public/pages/inventory_view/hooks/use_waffle_view_state.ts new file mode 100644 index 0000000000000..869560b2b8709 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/inventory_view/hooks/use_waffle_view_state.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { useCallback } from 'react'; +import { + useWaffleOptionsContext, + DEFAULT_WAFFLE_OPTIONS_STATE, + WaffleOptionsState, +} from './use_waffle_options'; +import { useWaffleTimeContext, DEFAULT_WAFFLE_TIME_STATE } from './use_waffle_time'; +import { + useWaffleFiltersContext, + DEFAULT_WAFFLE_FILTERS_STATE, + WaffleFiltersState, +} from './use_waffle_filters'; + +export const useWaffleViewState = () => { + const { + metric, + groupBy, + nodeType, + view, + customOptions, + customMetrics, + boundsOverride, + autoBounds, + accountId, + region, + setWaffleOptionsState, + } = useWaffleOptionsContext(); + const { currentTime, isAutoReloading, setWaffleTimeState } = useWaffleTimeContext(); + const { filterQuery, setWaffleFiltersState } = useWaffleFiltersContext(); + + const viewState: WaffleViewState = { + metric, + groupBy, + nodeType, + view, + customOptions, + customMetrics, + boundsOverride, + autoBounds, + accountId, + region, + time: currentTime, + autoReload: isAutoReloading, + filterQuery, + }; + + const defaultViewState: WaffleViewState = { + ...DEFAULT_WAFFLE_OPTIONS_STATE, + filterQuery: DEFAULT_WAFFLE_FILTERS_STATE, + time: DEFAULT_WAFFLE_TIME_STATE.currentTime, + autoReload: DEFAULT_WAFFLE_TIME_STATE.isAutoReloading, + }; + + const onViewChange = useCallback( + (newState: WaffleViewState) => { + setWaffleOptionsState({ + metric: newState.metric, + groupBy: newState.groupBy, + nodeType: newState.nodeType, + view: newState.view, + customOptions: newState.customOptions, + customMetrics: newState.customMetrics, + boundsOverride: newState.boundsOverride, + autoBounds: newState.autoBounds, + accountId: newState.accountId, + region: newState.region, + }); + if (newState.time) { + setWaffleTimeState({ + currentTime: newState.time, + isAutoReloading: newState.autoReload, + }); + } + setWaffleFiltersState(newState.filterQuery); + }, + [setWaffleOptionsState, setWaffleTimeState, setWaffleFiltersState] + ); + + return { + viewState, + defaultViewState, + onViewChange, + }; +}; + +export type WaffleViewState = WaffleOptionsState & { + time: number; + autoReload: boolean; + filterQuery: WaffleFiltersState; +}; diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_host_detail_via_ip.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_host_detail_via_ip.tsx index 01b02f1acbbf2..b1dab3bd3f673 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_host_detail_via_ip.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_host_detail_via_ip.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { Redirect, RouteComponentProps } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { replaceMetricTimeInQueryString } from '../metrics/containers/with_metrics_time'; +import { replaceMetricTimeInQueryString } from '../metrics/hooks/use_metrics_time'; import { useHostIpToName } from './use_host_ip_to_name'; import { getFromFromLocation, getToFromLocation } from './query_params'; import { LoadingPage } from '../../components/loading_page'; diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_detail.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_detail.tsx index 9eae632756a3f..72a41f5264244 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_detail.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_detail.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { Redirect, RouteComponentProps } from 'react-router-dom'; -import { replaceMetricTimeInQueryString } from '../metrics/containers/with_metrics_time'; +import { replaceMetricTimeInQueryString } from '../metrics/hooks/use_metrics_time'; import { getFromFromLocation, getToFromLocation } from './query_params'; import { InventoryItemType } from '../../../common/inventory_models/types'; import { LinkDescriptor } from '../../hooks/use_link_props'; diff --git a/x-pack/plugins/infra/public/pages/metrics/components/node_details_page.tsx b/x-pack/plugins/infra/public/pages/metrics/components/node_details_page.tsx index ea91c53faf675..dd2a5f2bdb39e 100644 --- a/x-pack/plugins/infra/public/pages/metrics/components/node_details_page.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/components/node_details_page.tsx @@ -22,7 +22,7 @@ import { MetricsTimeControls } from './time_controls'; import { SideNavContext, NavItem } from '../lib/side_nav_context'; import { PageBody } from './page_body'; import { euiStyled } from '../../../../../observability/public'; -import { MetricsTimeInput } from '../containers/with_metrics_time'; +import { MetricsTimeInput } from '../hooks/use_metrics_time'; import { InfraMetadata } from '../../../../common/http_api/metadata_api'; import { PageError } from './page_error'; import { MetadataContext } from '../../../pages/metrics/containers/metadata_context'; @@ -94,7 +94,7 @@ export const NodeDetailsPage = (props: Props) => { setRefreshInterval={props.setRefreshInterval} onChangeTimeRange={props.setTimeRange} setAutoReload={props.setAutoReload} - onRefresh={props.triggerRefresh} + onRefresh={refetch} /> </MetricsTitleTimeRangeContainer> </EuiPageHeaderSection> diff --git a/x-pack/plugins/infra/public/pages/metrics/components/page_body.tsx b/x-pack/plugins/infra/public/pages/metrics/components/page_body.tsx index 414b9c60adee3..e651d6b92d981 100644 --- a/x-pack/plugins/infra/public/pages/metrics/components/page_body.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/components/page_body.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { findLayout } from '../../../../common/inventory_models/layouts'; import { InventoryItemType } from '../../../../common/inventory_models/types'; -import { MetricsTimeInput } from '../containers/with_metrics_time'; +import { MetricsTimeInput } from '../hooks/use_metrics_time'; import { InfraLoadingPanel } from '../../../components/loading'; import { NoData } from '../../../components/empty_states'; import { NodeDetailsMetricData } from '../../../../common/http_api/node_details_api'; @@ -19,9 +19,9 @@ interface Props { refetch: () => void; type: InventoryItemType; metrics: NodeDetailsMetricData[]; - onChangeRangeTime?: (time: MetricsTimeInput) => void; - isLiveStreaming?: boolean; - stopLiveStreaming?: () => void; + onChangeRangeTime: (time: MetricsTimeInput) => void; + isLiveStreaming: boolean; + stopLiveStreaming: () => void; } export const PageBody = ({ diff --git a/x-pack/plugins/infra/public/pages/metrics/components/section.tsx b/x-pack/plugins/infra/public/pages/metrics/components/section.tsx index 2f9ed9f54df82..68003737a1f14 100644 --- a/x-pack/plugins/infra/public/pages/metrics/components/section.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/components/section.tsx @@ -41,6 +41,9 @@ export const Section: FunctionComponent<SectionProps> = ({ if (metric === null) { return accumulatedChildren; } + if (!child.props.label) { + return accumulatedChildren; + } return [ ...accumulatedChildren, { diff --git a/x-pack/plugins/infra/public/pages/metrics/components/time_controls.test.tsx b/x-pack/plugins/infra/public/pages/metrics/components/time_controls.test.tsx index 91e25fd8ef585..02ba506e8abe1 100644 --- a/x-pack/plugins/infra/public/pages/metrics/components/time_controls.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/components/time_controls.test.tsx @@ -19,7 +19,7 @@ jest.mock('../../../utils/use_kibana_ui_setting', () => ({ import React from 'react'; import { MetricsTimeControls } from './time_controls'; import { mount } from 'enzyme'; -import { MetricsTimeInput } from '../containers/with_metrics_time'; +import { MetricsTimeInput } from '../hooks/use_metrics_time'; describe('MetricsTimeControls', () => { it('should set a valid from and to value for Today', () => { diff --git a/x-pack/plugins/infra/public/pages/metrics/components/time_controls.tsx b/x-pack/plugins/infra/public/pages/metrics/components/time_controls.tsx index b1daaa0320fab..cdbdc9bb7ecdb 100644 --- a/x-pack/plugins/infra/public/pages/metrics/components/time_controls.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/components/time_controls.tsx @@ -7,7 +7,7 @@ import { EuiSuperDatePicker, OnRefreshChangeProps, OnTimeChangeProps } from '@elastic/eui'; import React, { useCallback } from 'react'; import { euiStyled } from '../../../../../observability/public'; -import { MetricsTimeInput } from '../containers/with_metrics_time'; +import { MetricsTimeInput } from '../hooks/use_metrics_time'; import { useKibanaUiSetting } from '../../../utils/use_kibana_ui_setting'; import { mapKibanaQuickRangesToDatePickerRanges } from '../../../utils/map_timepicker_quickranges_to_datepicker_ranges'; @@ -61,8 +61,8 @@ export const MetricsTimeControls = (props: MetricsTimeControlsProps) => { return ( <MetricsTimeControlsContainer> <EuiSuperDatePicker - start={currentTimeRange.from} - end={currentTimeRange.to} + start={currentTimeRange.from.toString()} + end={currentTimeRange.to.toString()} isPaused={!isLiveStreaming} refreshInterval={refreshInterval ? refreshInterval : 0} onTimeChange={handleTimeChange} diff --git a/x-pack/plugins/infra/public/pages/metrics/containers/metrics.gql_query.ts b/x-pack/plugins/infra/public/pages/metrics/containers/metrics.gql_query.ts deleted file mode 100644 index 1241c0d771382..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/containers/metrics.gql_query.ts +++ /dev/null @@ -1,38 +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 gql from 'graphql-tag'; - -export const metricsQuery = gql` - query MetricsQuery( - $sourceId: ID! - $timerange: InfraTimerangeInput! - $metrics: [InfraMetric!]! - $nodeId: ID! - $cloudId: ID - $nodeType: InfraNodeType! - ) { - source(id: $sourceId) { - id - metrics( - nodeIds: { nodeId: $nodeId, cloudId: $cloudId } - timerange: $timerange - metrics: $metrics - nodeType: $nodeType - ) { - id - series { - id - label - data { - timestamp - value - } - } - } - } - } -`; diff --git a/x-pack/plugins/infra/public/pages/metrics/containers/with_metrics_time.tsx b/x-pack/plugins/infra/public/pages/metrics/containers/with_metrics_time.tsx deleted file mode 100644 index 64d2ddb67139d..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/containers/with_metrics_time.tsx +++ /dev/null @@ -1,201 +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 createContainer from 'constate'; -import React, { useContext, useState, useCallback } from 'react'; -import { isNumber } from 'lodash'; -import moment from 'moment'; -import dateMath from '@elastic/datemath'; -import * as rt from 'io-ts'; -import { isRight } from 'fp-ts/lib/Either'; -import { replaceStateKeyInQueryString, UrlStateContainer } from '../../../utils/url_state'; -import { InfraTimerangeInput } from '../../../graphql/types'; - -export interface MetricsTimeInput { - from: string; - to: string; - interval: string; -} - -interface MetricsTimeState { - timeRange: MetricsTimeInput; - parsedTimeRange: InfraTimerangeInput; - setTimeRange: (timeRange: MetricsTimeInput) => void; - refreshInterval: number; - setRefreshInterval: (refreshInterval: number) => void; - isAutoReloading: boolean; - setAutoReload: (isAutoReloading: boolean) => void; - lastRefresh: number; - triggerRefresh: () => void; -} - -const parseRange = (range: MetricsTimeInput) => { - const parsedFrom = dateMath.parse(range.from); - const parsedTo = dateMath.parse(range.to, { roundUp: true }); - return { - ...range, - from: - (parsedFrom && parsedFrom.valueOf()) || - moment() - .subtract(1, 'hour') - .valueOf(), - to: (parsedTo && parsedTo.valueOf()) || moment().valueOf(), - }; -}; - -export const useMetricsTime = () => { - const defaultRange = { - from: 'now-1h', - to: 'now', - interval: '>=1m', - }; - const [isAutoReloading, setAutoReload] = useState(false); - const [refreshInterval, setRefreshInterval] = useState(5000); - const [lastRefresh, setLastRefresh] = useState<number>(moment().valueOf()); - const [timeRange, setTimeRange] = useState(defaultRange); - - const [parsedTimeRange, setParsedTimeRange] = useState(parseRange(defaultRange)); - - const updateTimeRange = useCallback((range: MetricsTimeInput) => { - setTimeRange(range); - setParsedTimeRange(parseRange(range)); - }, []); - - return { - timeRange, - setTimeRange: updateTimeRange, - parsedTimeRange, - refreshInterval, - setRefreshInterval, - isAutoReloading, - setAutoReload, - lastRefresh, - triggerRefresh: useCallback(() => setLastRefresh(moment().valueOf()), [setLastRefresh]), - }; -}; - -export const MetricsTimeContainer = createContainer(useMetricsTime); - -interface WithMetricsTimeProps { - children: (args: MetricsTimeState) => React.ReactElement; -} -export const WithMetricsTime: React.FunctionComponent<WithMetricsTimeProps> = ({ - children, -}: WithMetricsTimeProps) => { - const metricsTimeState = useContext(MetricsTimeContainer.Context); - return children({ ...metricsTimeState }); -}; - -/** - * Url State - */ - -interface MetricsTimeUrlState { - time?: MetricsTimeState['timeRange']; - autoReload?: boolean; - refreshInterval?: number; -} - -export const WithMetricsTimeUrlState = () => ( - <WithMetricsTime> - {({ - timeRange, - setTimeRange, - refreshInterval, - setRefreshInterval, - isAutoReloading, - setAutoReload, - }) => ( - <UrlStateContainer - urlState={{ - time: timeRange, - autoReload: isAutoReloading, - refreshInterval, - }} - urlStateKey="metricTime" - mapToUrlState={mapToUrlState} - onChange={newUrlState => { - if (newUrlState && newUrlState.time) { - setTimeRange(newUrlState.time); - } - if (newUrlState && newUrlState.autoReload) { - setAutoReload(true); - } else if ( - newUrlState && - typeof newUrlState.autoReload !== 'undefined' && - !newUrlState.autoReload - ) { - setAutoReload(false); - } - if (newUrlState && newUrlState.refreshInterval) { - setRefreshInterval(newUrlState.refreshInterval); - } - }} - onInitialize={initialUrlState => { - if (initialUrlState && initialUrlState.time) { - if ( - timeRange.from !== initialUrlState.time.from || - timeRange.to !== initialUrlState.time.to || - timeRange.interval !== initialUrlState.time.interval - ) { - setTimeRange(initialUrlState.time); - } - } - if (initialUrlState && initialUrlState.autoReload) { - setAutoReload(true); - } - if (initialUrlState && initialUrlState.refreshInterval) { - setRefreshInterval(initialUrlState.refreshInterval); - } - }} - /> - )} - </WithMetricsTime> -); - -const mapToUrlState = (value: any): MetricsTimeUrlState | undefined => - value - ? { - time: mapToTimeUrlState(value.time), - autoReload: mapToAutoReloadUrlState(value.autoReload), - refreshInterval: mapToRefreshInterval(value.refreshInterval), - } - : undefined; - -const MetricsTimeRT = rt.type({ - from: rt.union([rt.string, rt.number]), - to: rt.union([rt.string, rt.number]), - interval: rt.string, -}); - -const mapToTimeUrlState = (value: any) => { - const result = MetricsTimeRT.decode(value); - if (isRight(result)) { - const resultValue = result.right; - const to = isNumber(resultValue.to) ? moment(resultValue.to).toISOString() : resultValue.to; - const from = isNumber(resultValue.from) - ? moment(resultValue.from).toISOString() - : resultValue.from; - return { ...resultValue, from, to }; - } - return undefined; -}; - -const mapToAutoReloadUrlState = (value: any) => (typeof value === 'boolean' ? value : undefined); - -const mapToRefreshInterval = (value: any) => (typeof value === 'number' ? value : undefined); - -export const replaceMetricTimeInQueryString = (from: number, to: number) => - Number.isNaN(from) || Number.isNaN(to) - ? (value: string) => value - : replaceStateKeyInQueryString<MetricsTimeUrlState>('metricTime', { - autoReload: false, - time: { - interval: '>=1m', - from: moment(from).toISOString(), - to: moment(to).toISOString(), - }, - }); diff --git a/x-pack/plugins/infra/public/pages/metrics/containers/metrics_time.test.tsx b/x-pack/plugins/infra/public/pages/metrics/hooks/metrics_time.test.tsx similarity index 96% rename from x-pack/plugins/infra/public/pages/metrics/containers/metrics_time.test.tsx rename to x-pack/plugins/infra/public/pages/metrics/hooks/metrics_time.test.tsx index 350fa90810935..17fcc05406470 100644 --- a/x-pack/plugins/infra/public/pages/metrics/containers/metrics_time.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hooks/metrics_time.test.tsx @@ -6,7 +6,7 @@ import { mountHook } from 'test_utils/enzyme_helpers'; -import { useMetricsTime } from './with_metrics_time'; +import { useMetricsTime } from './use_metrics_time'; describe('useMetricsTime hook', () => { describe('timeRange state', () => { diff --git a/x-pack/plugins/infra/public/pages/metrics/hooks/use_metrics_time.ts b/x-pack/plugins/infra/public/pages/metrics/hooks/use_metrics_time.ts new file mode 100644 index 0000000000000..2ed86863535ff --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/hooks/use_metrics_time.ts @@ -0,0 +1,121 @@ +/* + * 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 createContainer from 'constate'; +import { useState, useCallback, useEffect } from 'react'; +import moment from 'moment'; +import dateMath from '@elastic/datemath'; +import * as rt from 'io-ts'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { constant, identity } from 'fp-ts/lib/function'; +import { useUrlState } from '../../../utils/use_url_state'; +import { replaceStateKeyInQueryString } from '../../../utils/url_state'; + +const parseRange = (range: MetricsTimeInput) => { + const parsedFrom = dateMath.parse(range.from.toString()); + const parsedTo = dateMath.parse(range.to.toString(), { roundUp: true }); + return { + ...range, + from: + (parsedFrom && parsedFrom.valueOf()) || + moment() + .subtract(1, 'hour') + .valueOf(), + to: (parsedTo && parsedTo.valueOf()) || moment().valueOf(), + }; +}; + +const DEFAULT_TIMERANGE: MetricsTimeInput = { + from: 'now-1h', + to: 'now', + interval: '>=1m', +}; + +const DEFAULT_URL_STATE: MetricsTimeUrlState = { + time: DEFAULT_TIMERANGE, + autoReload: false, + refreshInterval: 5000, +}; + +export const useMetricsTime = () => { + const [urlState, setUrlState] = useUrlState<MetricsTimeUrlState>({ + defaultState: DEFAULT_URL_STATE, + decodeUrlState, + encodeUrlState, + urlStateKey: 'metricTime', + }); + + const [isAutoReloading, setAutoReload] = useState(urlState.autoReload || false); + const [refreshInterval, setRefreshInterval] = useState(urlState.refreshInterval || 5000); + const [lastRefresh, setLastRefresh] = useState<number>(moment().valueOf()); + const [timeRange, setTimeRange] = useState(urlState.time || DEFAULT_TIMERANGE); + + useEffect(() => { + const newState = { + time: timeRange, + autoReload: isAutoReloading, + refreshInterval, + }; + return setUrlState(newState); + }, [isAutoReloading, refreshInterval, setUrlState, timeRange]); + + const [parsedTimeRange, setParsedTimeRange] = useState( + parseRange(urlState.time || DEFAULT_TIMERANGE) + ); + + const updateTimeRange = useCallback((range: MetricsTimeInput) => { + setTimeRange(range); + setParsedTimeRange(parseRange(range)); + }, []); + + return { + timeRange, + setTimeRange: updateTimeRange, + parsedTimeRange, + refreshInterval, + setRefreshInterval, + isAutoReloading, + setAutoReload, + lastRefresh, + triggerRefresh: useCallback(() => { + return setLastRefresh(moment().valueOf()); + }, [setLastRefresh]), + }; +}; + +export const MetricsTimeInputRT = rt.type({ + from: rt.union([rt.string, rt.number]), + to: rt.union([rt.string, rt.number]), + interval: rt.string, +}); +export type MetricsTimeInput = rt.TypeOf<typeof MetricsTimeInputRT>; + +export const MetricsTimeUrlStateRT = rt.partial({ + time: MetricsTimeInputRT, + autoReload: rt.boolean, + refreshInterval: rt.number, +}); +export type MetricsTimeUrlState = rt.TypeOf<typeof MetricsTimeUrlStateRT>; + +const encodeUrlState = MetricsTimeUrlStateRT.encode; +const decodeUrlState = (value: unknown) => + pipe(MetricsTimeUrlStateRT.decode(value), fold(constant(undefined), identity)); + +export const replaceMetricTimeInQueryString = (from: number, to: number) => + Number.isNaN(from) || Number.isNaN(to) + ? (value: string) => value + : replaceStateKeyInQueryString<MetricsTimeUrlState>('metricTime', { + autoReload: false, + time: { + interval: '>=1m', + from: moment(from).toISOString(), + to: moment(to).toISOString(), + }, + }); + +export const MetricsTimeContainer = createContainer(useMetricsTime); +export const [MetricsTimeProvider, useMetricsTimeContext] = MetricsTimeContainer; diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index 52c9825a4d614..531be40d2dc43 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -9,7 +9,6 @@ import { euiStyled, EuiTheme, withTheme } from '../../../../observability/public import { DocumentTitle } from '../../components/document_title'; import { Header } from '../../components/header'; import { ColumnarPage, PageContent } from '../../components/page'; -import { WithMetricsTime, WithMetricsTimeUrlState } from './containers/with_metrics_time'; import { withMetricPageProviders } from './page_providers'; import { useMetadata } from '../../containers/metadata/use_metadata'; import { Source } from '../../containers/source'; @@ -19,6 +18,7 @@ import { NavItem } from './lib/side_nav_context'; import { NodeDetailsPage } from './components/node_details_page'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { InventoryItemType } from '../../../common/inventory_models/types'; +import { useMetricsTimeContext } from './hooks/use_metrics_time'; import { useLinkProps } from '../../hooks/use_link_props'; const DetailPageContent = euiStyled(PageContent)` @@ -37,19 +37,29 @@ interface Props { } export const MetricDetail = withMetricPageProviders( - withTheme(({ match, theme }: Props) => { + withTheme(({ match }: Props) => { const uiCapabilities = useKibana().services.application?.capabilities; const nodeId = match.params.node; const nodeType = match.params.type as InventoryItemType; const inventoryModel = findInventoryModel(nodeType); const { sourceId } = useContext(Source.Context); + const { + timeRange, + parsedTimeRange, + setTimeRange, + refreshInterval, + setRefreshInterval, + isAutoReloading, + setAutoReload, + triggerRefresh, + } = useMetricsTimeContext(); const { name, filteredRequiredMetrics, loading: metadataLoading, cloudId, metadata, - } = useMetadata(nodeId, nodeType, inventoryModel.requiredMetrics, sourceId); + } = useMetadata(nodeId, nodeType, inventoryModel.requiredMetrics, sourceId, parsedTimeRange); const [sideNav, setSideNav] = useState<NavItem[]>([]); @@ -90,58 +100,41 @@ export const MetricDetail = withMetricPageProviders( } return ( - <WithMetricsTime> - {({ - timeRange, - parsedTimeRange, - setTimeRange, - refreshInterval, - setRefreshInterval, - isAutoReloading, - setAutoReload, - triggerRefresh, - }) => ( - <ColumnarPage> - <Header - breadcrumbs={breadcrumbs} - readOnlyBadge={!uiCapabilities?.infrastructure?.save} - /> - <WithMetricsTimeUrlState /> - <DocumentTitle - title={i18n.translate('xpack.infra.metricDetailPage.documentTitle', { - defaultMessage: 'Infrastructure | Metrics | {name}', - values: { - name, - }, - })} + <ColumnarPage> + <Header breadcrumbs={breadcrumbs} readOnlyBadge={!uiCapabilities?.infrastructure?.save} /> + <DocumentTitle + title={i18n.translate('xpack.infra.metricDetailPage.documentTitle', { + defaultMessage: 'Infrastructure | Metrics | {name}', + values: { + name, + }, + })} + /> + <DetailPageContent data-test-subj="infraMetricsPage"> + {metadata ? ( + <NodeDetailsPage + name={name} + requiredMetrics={filteredRequiredMetrics} + sourceId={sourceId} + timeRange={timeRange} + parsedTimeRange={parsedTimeRange} + nodeType={nodeType} + nodeId={nodeId} + cloudId={cloudId} + metadataLoading={metadataLoading} + isAutoReloading={isAutoReloading} + refreshInterval={refreshInterval} + sideNav={sideNav} + metadata={metadata} + addNavItem={addNavItem} + setRefreshInterval={setRefreshInterval} + setAutoReload={setAutoReload} + triggerRefresh={triggerRefresh} + setTimeRange={setTimeRange} /> - <DetailPageContent data-test-subj="infraMetricsPage"> - {metadata ? ( - <NodeDetailsPage - name={name} - requiredMetrics={filteredRequiredMetrics} - sourceId={sourceId} - timeRange={timeRange} - parsedTimeRange={parsedTimeRange} - nodeType={nodeType} - nodeId={nodeId} - cloudId={cloudId} - metadataLoading={metadataLoading} - isAutoReloading={isAutoReloading} - refreshInterval={refreshInterval} - sideNav={sideNav} - metadata={metadata} - addNavItem={addNavItem} - setRefreshInterval={setRefreshInterval} - setAutoReload={setAutoReload} - triggerRefresh={triggerRefresh} - setTimeRange={setTimeRange} - /> - ) : null} - </DetailPageContent> - </ColumnarPage> - )} - </WithMetricsTime> + ) : null} + </DetailPageContent> + </ColumnarPage> ); }) ); diff --git a/x-pack/plugins/infra/public/pages/metrics/page_providers.tsx b/x-pack/plugins/infra/public/pages/metrics/page_providers.tsx index 0abbd597dd65c..d3f10adec06ed 100644 --- a/x-pack/plugins/infra/public/pages/metrics/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/page_providers.tsx @@ -6,15 +6,15 @@ import React from 'react'; -import { MetricsTimeContainer } from './containers/with_metrics_time'; import { Source } from '../../containers/source'; +import { MetricsTimeProvider } from './hooks/use_metrics_time'; export const withMetricPageProviders = <T extends object>(Component: React.ComponentType<T>) => ( props: T ) => ( <Source.Provider sourceId="default"> - <MetricsTimeContainer.Provider> + <MetricsTimeProvider> <Component {...props} /> - </MetricsTimeContainer.Provider> + </MetricsTimeProvider> </Source.Provider> ); diff --git a/x-pack/plugins/infra/public/pages/metrics/types.ts b/x-pack/plugins/infra/public/pages/metrics/types.ts index fd6243292ec07..2cc261df28977 100644 --- a/x-pack/plugins/infra/public/pages/metrics/types.ts +++ b/x-pack/plugins/infra/public/pages/metrics/types.ts @@ -7,7 +7,7 @@ import rt from 'io-ts'; import { EuiTheme } from '../../../../observability/public'; import { InventoryFormatterTypeRT } from '../../../common/inventory_models/types'; -import { MetricsTimeInput } from './containers/with_metrics_time'; +import { MetricsTimeInput } from './hooks/use_metrics_time'; import { NodeDetailsMetricData } from '../../../common/http_api/node_details_api'; export interface LayoutProps { diff --git a/x-pack/plugins/infra/public/store/actions.ts b/x-pack/plugins/infra/public/store/actions.ts deleted file mode 100644 index 8a5d1f6c668d0..0000000000000 --- a/x-pack/plugins/infra/public/store/actions.ts +++ /dev/null @@ -1,7 +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 { waffleFilterActions, waffleTimeActions, waffleOptionsActions } from './local'; diff --git a/x-pack/plugins/infra/public/store/epics.ts b/x-pack/plugins/infra/public/store/epics.ts deleted file mode 100644 index b5e48a4ec6214..0000000000000 --- a/x-pack/plugins/infra/public/store/epics.ts +++ /dev/null @@ -1,11 +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 { combineEpics } from 'redux-observable'; - -import { createLocalEpic } from './local'; - -export const createRootEpic = <State>() => combineEpics(createLocalEpic<State>()); diff --git a/x-pack/plugins/infra/public/store/index.ts b/x-pack/plugins/infra/public/store/index.ts deleted file mode 100644 index 025da41ec40d5..0000000000000 --- a/x-pack/plugins/infra/public/store/index.ts +++ /dev/null @@ -1,11 +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 * from './actions'; -export * from './epics'; -export * from './reducer'; -export * from './selectors'; -export { createStore } from './store'; diff --git a/x-pack/plugins/infra/public/store/local/actions.ts b/x-pack/plugins/infra/public/store/local/actions.ts deleted file mode 100644 index 1c79d5a515cd4..0000000000000 --- a/x-pack/plugins/infra/public/store/local/actions.ts +++ /dev/null @@ -1,9 +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 { waffleFilterActions } from './waffle_filter'; -export { waffleTimeActions } from './waffle_time'; -export { waffleOptionsActions } from './waffle_options'; diff --git a/x-pack/plugins/infra/public/store/local/epic.ts b/x-pack/plugins/infra/public/store/local/epic.ts deleted file mode 100644 index e1a051355576f..0000000000000 --- a/x-pack/plugins/infra/public/store/local/epic.ts +++ /dev/null @@ -1,11 +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 { combineEpics } from 'redux-observable'; - -import { createWaffleTimeEpic } from './waffle_time'; - -export const createLocalEpic = <State>() => combineEpics(createWaffleTimeEpic<State>()); diff --git a/x-pack/plugins/infra/public/store/local/index.ts b/x-pack/plugins/infra/public/store/local/index.ts deleted file mode 100644 index c2843320bfd0c..0000000000000 --- a/x-pack/plugins/infra/public/store/local/index.ts +++ /dev/null @@ -1,10 +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 * from './actions'; -export * from './epic'; -export * from './reducer'; -export * from './selectors'; diff --git a/x-pack/plugins/infra/public/store/local/reducer.ts b/x-pack/plugins/infra/public/store/local/reducer.ts deleted file mode 100644 index 9e194a5d37f49..0000000000000 --- a/x-pack/plugins/infra/public/store/local/reducer.ts +++ /dev/null @@ -1,33 +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 { combineReducers } from 'redux'; - -import { initialWaffleFilterState, waffleFilterReducer, WaffleFilterState } from './waffle_filter'; -import { - initialWaffleOptionsState, - waffleOptionsReducer, - WaffleOptionsState, -} from './waffle_options'; -import { initialWaffleTimeState, waffleTimeReducer, WaffleTimeState } from './waffle_time'; - -export interface LocalState { - waffleFilter: WaffleFilterState; - waffleTime: WaffleTimeState; - waffleMetrics: WaffleOptionsState; -} - -export const initialLocalState: LocalState = { - waffleFilter: initialWaffleFilterState, - waffleTime: initialWaffleTimeState, - waffleMetrics: initialWaffleOptionsState, -}; - -export const localReducer = combineReducers<LocalState>({ - waffleFilter: waffleFilterReducer, - waffleTime: waffleTimeReducer, - waffleMetrics: waffleOptionsReducer, -}); diff --git a/x-pack/plugins/infra/public/store/local/selectors.ts b/x-pack/plugins/infra/public/store/local/selectors.ts deleted file mode 100644 index 56ffc53c2bc72..0000000000000 --- a/x-pack/plugins/infra/public/store/local/selectors.ts +++ /dev/null @@ -1,26 +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 { globalizeSelectors } from '../../utils/typed_redux'; -import { LocalState } from './reducer'; -import { waffleFilterSelectors as innerWaffleFilterSelectors } from './waffle_filter'; -import { waffleOptionsSelectors as innerWaffleOptionsSelectors } from './waffle_options'; -import { waffleTimeSelectors as innerWaffleTimeSelectors } from './waffle_time'; - -export const waffleFilterSelectors = globalizeSelectors( - (state: LocalState) => state.waffleFilter, - innerWaffleFilterSelectors -); - -export const waffleTimeSelectors = globalizeSelectors( - (state: LocalState) => state.waffleTime, - innerWaffleTimeSelectors -); - -export const waffleOptionsSelectors = globalizeSelectors( - (state: LocalState) => state.waffleMetrics, - innerWaffleOptionsSelectors -); diff --git a/x-pack/plugins/infra/public/store/local/waffle_filter/actions.ts b/x-pack/plugins/infra/public/store/local/waffle_filter/actions.ts deleted file mode 100644 index a23f9b3108b5b..0000000000000 --- a/x-pack/plugins/infra/public/store/local/waffle_filter/actions.ts +++ /dev/null @@ -1,19 +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 actionCreatorFactory from 'typescript-fsa'; - -import { FilterQuery, SerializedFilterQuery } from './reducer'; - -const actionCreator = actionCreatorFactory('x-pack/infra/local/waffle_filter'); - -export const setWaffleFilterQueryDraft = actionCreator<FilterQuery>( - 'SET_WAFFLE_FILTER_QUERY_DRAFT' -); - -export const applyWaffleFilterQuery = actionCreator<SerializedFilterQuery>( - 'APPLY_WAFFLE_FILTER_QUERY' -); diff --git a/x-pack/plugins/infra/public/store/local/waffle_filter/index.ts b/x-pack/plugins/infra/public/store/local/waffle_filter/index.ts deleted file mode 100644 index 558314f2aeda8..0000000000000 --- a/x-pack/plugins/infra/public/store/local/waffle_filter/index.ts +++ /dev/null @@ -1,11 +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 * as waffleFilterActions from './actions'; -import * as waffleFilterSelectors from './selectors'; - -export { waffleFilterActions, waffleFilterSelectors }; -export * from './reducer'; diff --git a/x-pack/plugins/infra/public/store/local/waffle_filter/reducer.ts b/x-pack/plugins/infra/public/store/local/waffle_filter/reducer.ts deleted file mode 100644 index 912ad96357334..0000000000000 --- a/x-pack/plugins/infra/public/store/local/waffle_filter/reducer.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { reducerWithInitialState } from 'typescript-fsa-reducers/dist'; - -import { applyWaffleFilterQuery, setWaffleFilterQueryDraft } from './actions'; - -export interface KueryFilterQuery { - kind: 'kuery'; - expression: string; -} - -export type FilterQuery = KueryFilterQuery; - -export interface SerializedFilterQuery { - query: FilterQuery | null; - serializedQuery: string | null; -} - -export interface WaffleFilterState { - filterQuery: SerializedFilterQuery | null; - filterQueryDraft: KueryFilterQuery | null; -} - -export const initialWaffleFilterState: WaffleFilterState = { - filterQuery: null, - filterQueryDraft: null, -}; - -export const waffleFilterReducer = reducerWithInitialState(initialWaffleFilterState) - .case(setWaffleFilterQueryDraft, (state, filterQueryDraft) => ({ - ...state, - filterQueryDraft, - })) - .case(applyWaffleFilterQuery, (state, filterQuery) => ({ - ...state, - filterQuery, - filterQueryDraft: filterQuery.query, - })) - .build(); diff --git a/x-pack/plugins/infra/public/store/local/waffle_filter/selectors.ts b/x-pack/plugins/infra/public/store/local/waffle_filter/selectors.ts deleted file mode 100644 index 047dabd3f0dd3..0000000000000 --- a/x-pack/plugins/infra/public/store/local/waffle_filter/selectors.ts +++ /dev/null @@ -1,33 +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 { createSelector } from 'reselect'; - -import { esKuery } from '../../../../../../../src/plugins/data/public'; -import { WaffleFilterState } from './reducer'; - -export const selectWaffleFilterQuery = (state: WaffleFilterState) => - state.filterQuery ? state.filterQuery.query : null; - -export const selectWaffleFilterQueryAsJson = (state: WaffleFilterState) => - state.filterQuery ? state.filterQuery.serializedQuery : null; - -export const selectWaffleFilterQueryDraft = (state: WaffleFilterState) => state.filterQueryDraft; - -export const selectIsWaffleFilterQueryDraftValid = createSelector( - selectWaffleFilterQueryDraft, - filterQueryDraft => { - if (filterQueryDraft && filterQueryDraft.kind === 'kuery') { - try { - esKuery.fromKueryExpression(filterQueryDraft.expression); - } catch (err) { - return false; - } - } - - return true; - } -); diff --git a/x-pack/plugins/infra/public/store/local/waffle_options/actions.ts b/x-pack/plugins/infra/public/store/local/waffle_options/actions.ts deleted file mode 100644 index 88229c31b2056..0000000000000 --- a/x-pack/plugins/infra/public/store/local/waffle_options/actions.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 actionCreatorFactory from 'typescript-fsa'; -import { - SnapshotGroupBy, - SnapshotMetricInput, - SnapshotCustomMetricInput, -} from '../../../../common/http_api/snapshot_api'; -import { InventoryItemType } from '../../../../common/inventory_models/types'; -import { InfraGroupByOptions, InfraWaffleMapBounds } from '../../../lib/lib'; - -const actionCreator = actionCreatorFactory('x-pack/infra/local/waffle_options'); - -export const changeMetric = actionCreator<SnapshotMetricInput>('CHANGE_METRIC'); -export const changeGroupBy = actionCreator<SnapshotGroupBy>('CHANGE_GROUP_BY'); -export const changeCustomOptions = actionCreator<InfraGroupByOptions[]>('CHANGE_CUSTOM_OPTIONS'); -export const changeNodeType = actionCreator<InventoryItemType>('CHANGE_NODE_TYPE'); -export const changeView = actionCreator<string>('CHANGE_VIEW'); -export const changeBoundsOverride = actionCreator<InfraWaffleMapBounds>('CHANGE_BOUNDS_OVERRIDE'); -export const changeAutoBounds = actionCreator<boolean>('CHANGE_AUTO_BOUNDS'); -export const changeAccount = actionCreator<string>('CHANGE_ACCOUNT'); -export const changeRegion = actionCreator<string>('CHANGE_REGION'); -export const changeCustomMetrics = actionCreator<SnapshotCustomMetricInput[]>( - 'CHANGE_CUSTOM_METRICS' -); diff --git a/x-pack/plugins/infra/public/store/local/waffle_options/index.ts b/x-pack/plugins/infra/public/store/local/waffle_options/index.ts deleted file mode 100644 index 3ecf108eb49d4..0000000000000 --- a/x-pack/plugins/infra/public/store/local/waffle_options/index.ts +++ /dev/null @@ -1,11 +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 * as waffleOptionsActions from './actions'; -import * as waffleOptionsSelectors from './selector'; - -export { waffleOptionsActions, waffleOptionsSelectors }; -export * from './reducer'; diff --git a/x-pack/plugins/infra/public/store/local/waffle_options/reducer.ts b/x-pack/plugins/infra/public/store/local/waffle_options/reducer.ts deleted file mode 100644 index 3789228a7c16b..0000000000000 --- a/x-pack/plugins/infra/public/store/local/waffle_options/reducer.ts +++ /dev/null @@ -1,113 +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 { combineReducers } from 'redux'; -import { reducerWithInitialState } from 'typescript-fsa-reducers'; -import { - SnapshotMetricInput, - SnapshotGroupBy, - SnapshotCustomMetricInput, -} from '../../../../common/http_api/snapshot_api'; -import { InfraGroupByOptions, InfraWaffleMapBounds } from '../../../lib/lib'; -import { - changeAutoBounds, - changeBoundsOverride, - changeCustomOptions, - changeGroupBy, - changeMetric, - changeNodeType, - changeView, - changeAccount, - changeRegion, - changeCustomMetrics, -} from './actions'; -import { InventoryItemType } from '../../../../common/inventory_models/types'; - -export interface WaffleOptionsState { - metric: SnapshotMetricInput; - groupBy: SnapshotGroupBy; - nodeType: InventoryItemType; - view: string; - customOptions: InfraGroupByOptions[]; - boundsOverride: InfraWaffleMapBounds; - autoBounds: boolean; - accountId: string; - region: string; - customMetrics: SnapshotCustomMetricInput[]; -} - -export const initialWaffleOptionsState: WaffleOptionsState = { - metric: { type: 'cpu' }, - groupBy: [], - nodeType: 'host', - view: 'map', - customOptions: [], - boundsOverride: { max: 1, min: 0 }, - autoBounds: true, - accountId: '', - region: '', - customMetrics: [], -}; - -const currentMetricReducer = reducerWithInitialState(initialWaffleOptionsState.metric).case( - changeMetric, - (current, target) => target -); - -const currentCustomOptionsReducer = reducerWithInitialState( - initialWaffleOptionsState.customOptions -).case(changeCustomOptions, (current, target) => target); - -const currentGroupByReducer = reducerWithInitialState(initialWaffleOptionsState.groupBy).case( - changeGroupBy, - (current, target) => target -); - -const currentNodeTypeReducer = reducerWithInitialState(initialWaffleOptionsState.nodeType).case( - changeNodeType, - (current, target) => target -); - -const currentViewReducer = reducerWithInitialState(initialWaffleOptionsState.view).case( - changeView, - (current, target) => target -); - -const currentBoundsOverrideReducer = reducerWithInitialState( - initialWaffleOptionsState.boundsOverride -).case(changeBoundsOverride, (current, target) => target); - -const currentAutoBoundsReducer = reducerWithInitialState(initialWaffleOptionsState.autoBounds).case( - changeAutoBounds, - (current, target) => target -); - -const currentAccountIdReducer = reducerWithInitialState(initialWaffleOptionsState.accountId).case( - changeAccount, - (current, target) => target -); - -const currentRegionReducer = reducerWithInitialState(initialWaffleOptionsState.region).case( - changeRegion, - (current, target) => target -); - -const currentCustomMetricsReducer = reducerWithInitialState( - initialWaffleOptionsState.customMetrics -).case(changeCustomMetrics, (current, target) => target); - -export const waffleOptionsReducer = combineReducers<WaffleOptionsState>({ - metric: currentMetricReducer, - groupBy: currentGroupByReducer, - nodeType: currentNodeTypeReducer, - view: currentViewReducer, - customOptions: currentCustomOptionsReducer, - boundsOverride: currentBoundsOverrideReducer, - autoBounds: currentAutoBoundsReducer, - accountId: currentAccountIdReducer, - region: currentRegionReducer, - customMetrics: currentCustomMetricsReducer, -}); diff --git a/x-pack/plugins/infra/public/store/local/waffle_options/selector.ts b/x-pack/plugins/infra/public/store/local/waffle_options/selector.ts deleted file mode 100644 index 4487af156df97..0000000000000 --- a/x-pack/plugins/infra/public/store/local/waffle_options/selector.ts +++ /dev/null @@ -1,18 +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 { WaffleOptionsState } from './reducer'; - -export const selectMetric = (state: WaffleOptionsState) => state.metric; -export const selectGroupBy = (state: WaffleOptionsState) => state.groupBy; -export const selectCustomOptions = (state: WaffleOptionsState) => state.customOptions; -export const selectNodeType = (state: WaffleOptionsState) => state.nodeType; -export const selectView = (state: WaffleOptionsState) => state.view; -export const selectBoundsOverride = (state: WaffleOptionsState) => state.boundsOverride; -export const selectAutoBounds = (state: WaffleOptionsState) => state.autoBounds; -export const selectAccountId = (state: WaffleOptionsState) => state.accountId; -export const selectRegion = (state: WaffleOptionsState) => state.region; -export const selectCustomMetrics = (state: WaffleOptionsState) => state.customMetrics; diff --git a/x-pack/plugins/infra/public/store/local/waffle_time/actions.ts b/x-pack/plugins/infra/public/store/local/waffle_time/actions.ts deleted file mode 100644 index fe79f2f536a93..0000000000000 --- a/x-pack/plugins/infra/public/store/local/waffle_time/actions.ts +++ /dev/null @@ -1,15 +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 actionCreatorFactory from 'typescript-fsa'; - -const actionCreator = actionCreatorFactory('x-pack/infra/local/waffle_time'); - -export const jumpToTime = actionCreator<number>('JUMP_TO_TIME'); - -export const startAutoReload = actionCreator('START_AUTO_RELOAD'); - -export const stopAutoReload = actionCreator('STOP_AUTO_RELOAD'); diff --git a/x-pack/plugins/infra/public/store/local/waffle_time/epic.ts b/x-pack/plugins/infra/public/store/local/waffle_time/epic.ts deleted file mode 100644 index 986d6b17a2424..0000000000000 --- a/x-pack/plugins/infra/public/store/local/waffle_time/epic.ts +++ /dev/null @@ -1,38 +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 { Action } from 'redux'; -import { Epic } from 'redux-observable'; -import { timer } from 'rxjs'; -import { exhaustMap, filter, map, takeUntil, withLatestFrom } from 'rxjs/operators'; - -import { jumpToTime, startAutoReload, stopAutoReload } from './actions'; - -interface WaffleTimeEpicDependencies<State> { - selectWaffleTimeUpdatePolicyInterval: (state: State) => number | null; -} - -export const createWaffleTimeEpic = <State>(): Epic< - Action, - Action, - State, - WaffleTimeEpicDependencies<State> -> => (action$, state$, { selectWaffleTimeUpdatePolicyInterval }) => { - const updateInterval$ = state$.pipe(map(selectWaffleTimeUpdatePolicyInterval), filter(isNotNull)); - - return action$.pipe( - filter(startAutoReload.match), - withLatestFrom(updateInterval$), - exhaustMap(([action, updateInterval]) => - timer(0, updateInterval).pipe( - map(() => jumpToTime(Date.now())), - takeUntil(action$.pipe(filter(stopAutoReload.match))) - ) - ) - ); -}; - -const isNotNull = <T>(value: T | null): value is T => value !== null; diff --git a/x-pack/plugins/infra/public/store/local/waffle_time/index.ts b/x-pack/plugins/infra/public/store/local/waffle_time/index.ts deleted file mode 100644 index 2b99a6d6d5760..0000000000000 --- a/x-pack/plugins/infra/public/store/local/waffle_time/index.ts +++ /dev/null @@ -1,12 +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 * as waffleTimeActions from './actions'; -import * as waffleTimeSelectors from './selectors'; - -export { waffleTimeActions, waffleTimeSelectors }; -export * from './epic'; -export * from './reducer'; diff --git a/x-pack/plugins/infra/public/store/local/waffle_time/reducer.ts b/x-pack/plugins/infra/public/store/local/waffle_time/reducer.ts deleted file mode 100644 index 026e5decf5d37..0000000000000 --- a/x-pack/plugins/infra/public/store/local/waffle_time/reducer.ts +++ /dev/null @@ -1,52 +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 { combineReducers } from 'redux'; -import { reducerWithInitialState } from 'typescript-fsa-reducers/dist'; - -import { jumpToTime, startAutoReload, stopAutoReload } from './actions'; - -interface ManualTimeUpdatePolicy { - policy: 'manual'; -} - -interface IntervalTimeUpdatePolicy { - policy: 'interval'; - interval: number; -} - -type TimeUpdatePolicy = ManualTimeUpdatePolicy | IntervalTimeUpdatePolicy; - -export interface WaffleTimeState { - currentTime: number; - updatePolicy: TimeUpdatePolicy; -} - -export const initialWaffleTimeState: WaffleTimeState = { - currentTime: Date.now(), - updatePolicy: { - policy: 'manual', - }, -}; - -const currentTimeReducer = reducerWithInitialState(initialWaffleTimeState.currentTime).case( - jumpToTime, - (currentTime, targetTime) => targetTime -); - -const updatePolicyReducer = reducerWithInitialState(initialWaffleTimeState.updatePolicy) - .case(startAutoReload, () => ({ - policy: 'interval', - interval: 5000, - })) - .case(stopAutoReload, () => ({ - policy: 'manual', - })); - -export const waffleTimeReducer = combineReducers<WaffleTimeState>({ - currentTime: currentTimeReducer, - updatePolicy: updatePolicyReducer, -}); diff --git a/x-pack/plugins/infra/public/store/local/waffle_time/selectors.ts b/x-pack/plugins/infra/public/store/local/waffle_time/selectors.ts deleted file mode 100644 index 0b6d01bdf5288..0000000000000 --- a/x-pack/plugins/infra/public/store/local/waffle_time/selectors.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { createSelector } from 'reselect'; - -import { WaffleTimeState } from './reducer'; - -export const selectCurrentTime = (state: WaffleTimeState) => state.currentTime; - -export const selectIsAutoReloading = (state: WaffleTimeState) => - state.updatePolicy.policy === 'interval'; - -export const selectTimeUpdatePolicyInterval = (state: WaffleTimeState) => - state.updatePolicy.policy === 'interval' ? state.updatePolicy.interval : null; - -export const selectCurrentTimeRange = createSelector(selectCurrentTime, currentTime => ({ - from: currentTime - 1000 * 60 * 5, - interval: '1m', - to: currentTime, -})); diff --git a/x-pack/plugins/infra/public/store/reducer.ts b/x-pack/plugins/infra/public/store/reducer.ts deleted file mode 100644 index 2536ddbee401b..0000000000000 --- a/x-pack/plugins/infra/public/store/reducer.ts +++ /dev/null @@ -1,21 +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 { combineReducers } from 'redux'; - -import { initialLocalState, localReducer, LocalState } from './local'; - -export interface State { - local: LocalState; -} - -export const initialState: State = { - local: initialLocalState, -}; - -export const reducer = combineReducers<State>({ - local: localReducer, -}); diff --git a/x-pack/plugins/infra/public/store/selectors.ts b/x-pack/plugins/infra/public/store/selectors.ts deleted file mode 100644 index f4011c232cba4..0000000000000 --- a/x-pack/plugins/infra/public/store/selectors.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { globalizeSelectors } from '../utils/typed_redux'; -import { - waffleFilterSelectors as localWaffleFilterSelectors, - waffleOptionsSelectors as localWaffleOptionsSelectors, - waffleTimeSelectors as localWaffleTimeSelectors, -} from './local'; -import { State } from './reducer'; -/** - * local selectors - */ - -const selectLocal = (state: State) => state.local; - -export const waffleFilterSelectors = globalizeSelectors(selectLocal, localWaffleFilterSelectors); -export const waffleTimeSelectors = globalizeSelectors(selectLocal, localWaffleTimeSelectors); -export const waffleOptionsSelectors = globalizeSelectors(selectLocal, localWaffleOptionsSelectors); diff --git a/x-pack/plugins/infra/public/store/store.ts b/x-pack/plugins/infra/public/store/store.ts deleted file mode 100644 index cae0622c5e4a1..0000000000000 --- a/x-pack/plugins/infra/public/store/store.ts +++ /dev/null @@ -1,50 +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 { Action, applyMiddleware, compose, createStore as createBasicStore } from 'redux'; -import { createEpicMiddleware } from 'redux-observable'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; - -import { createRootEpic, initialState, reducer, State, waffleTimeSelectors } from '.'; -import { InfraApolloClient, InfraObservableApi } from '../lib/lib'; - -declare global { - interface Window { - __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: typeof compose; - } -} - -export interface StoreDependencies { - apolloClient: Observable<InfraApolloClient>; - observableApi: Observable<InfraObservableApi>; -} - -export function createStore({ apolloClient, observableApi }: StoreDependencies) { - const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; - - const middlewareDependencies = { - postToApi$: observableApi.pipe(map(({ post }) => post)), - apolloClient$: apolloClient, - selectWaffleTimeUpdatePolicyInterval: waffleTimeSelectors.selectTimeUpdatePolicyInterval, - }; - - const epicMiddleware = createEpicMiddleware<Action, Action, State, typeof middlewareDependencies>( - { - dependencies: middlewareDependencies, - } - ); - - const store = createBasicStore( - reducer, - initialState, - composeEnhancers(applyMiddleware(epicMiddleware)) - ); - - epicMiddleware.run(createRootEpic<State>()); - - return store; -} diff --git a/x-pack/plugins/infra/public/utils/redux_context.tsx b/x-pack/plugins/infra/public/utils/redux_context.tsx deleted file mode 100644 index f249d72a6b56f..0000000000000 --- a/x-pack/plugins/infra/public/utils/redux_context.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useSelector } from 'react-redux'; -import React, { createContext } from 'react'; -import { State, initialState } from '../store'; - -export const ReduxStateContext = createContext(initialState); - -export const ReduxStateContextProvider = ({ children }: { children: JSX.Element }) => { - const state = useSelector((store: State) => store); - return <ReduxStateContext.Provider value={state}>{children}</ReduxStateContext.Provider>; -}; diff --git a/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts index 8ddd3935bcc33..038fd457fb6c7 100644 --- a/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts @@ -20,7 +20,7 @@ export interface InfraServerPluginDeps { home: HomeServerPluginSetup; spaces: SpacesPluginSetup; usageCollection: UsageCollectionSetup; - metrics: VisTypeTimeseriesSetup; + visTypeTimeseries: VisTypeTimeseriesSetup; features: FeaturesPluginSetup; apm: APMPluginContract; alerting: AlertingPluginContract; diff --git a/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts index b73acd6703054..eda1fbfa5f4ce 100644 --- a/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts @@ -245,7 +245,7 @@ export class KibanaFramework { timerange: { min: number; max: number }, filters: any[] ): Promise<InfraTSVBResponse> { - const { getVisData } = this.plugins.metrics; + const { getVisData } = this.plugins.visTypeTimeseries; if (typeof getVisData !== 'function') { throw new Error('TSVB is not available'); } diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index 38cd0cec145f9..000d0823311b3 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -8,62 +8,77 @@ import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold import { Comparator, AlertStates } from './types'; import * as mocks from './test_mocks'; import { AlertExecutorOptions } from '../../../../../alerting/server'; +import { + alertsMock, + AlertServicesMock, + AlertInstanceMock, +} from '../../../../../alerting/server/mocks'; const executor = createMetricThresholdExecutor('test') as (opts: { params: AlertExecutorOptions['params']; services: { callCluster: AlertExecutorOptions['params']['callCluster'] }; }) => Promise<void>; -const alertInstances = new Map(); -const services = { - callCluster(_: string, { body, index }: any) { - if (index === 'alternatebeat-*') return mocks.changedSourceIdResponse; - const metric = body.query.bool.filter[1]?.exists.field; - if (body.aggs.groupings) { - if (body.aggs.groupings.composite.after) { - return mocks.compositeEndResponse; - } - if (metric === 'test.metric.2') { - return mocks.alternateCompositeResponse; - } - return mocks.basicCompositeResponse; +const services: AlertServicesMock = alertsMock.createAlertServices(); +services.callCluster.mockImplementation((_: string, { body, index }: any) => { + if (index === 'alternatebeat-*') return mocks.changedSourceIdResponse; + const metric = body.query.bool.filter[1]?.exists.field; + if (body.aggs.groupings) { + if (body.aggs.groupings.composite.after) { + return mocks.compositeEndResponse; } if (metric === 'test.metric.2') { - return mocks.alternateMetricResponse; + return mocks.alternateCompositeResponse; } - return mocks.basicMetricResponse; - }, - alertInstanceFactory(instanceID: string) { - let state: any; - const actionQueue: any[] = []; - const instance = { - actionQueue: [], - get state() { - return state; - }, - get mostRecentAction() { - return actionQueue.pop(); - }, - }; - alertInstances.set(instanceID, instance); + return mocks.basicCompositeResponse; + } + if (metric === 'test.metric.2') { + return mocks.alternateMetricResponse; + } + return mocks.basicMetricResponse; +}); +services.savedObjectsClient.get.mockImplementation(async (type: string, sourceId: string) => { + if (sourceId === 'alternate') return { - instanceID, - scheduleActions(id: string, action: any) { - actionQueue.push({ id, action }); - }, - replaceState(newState: any) { - state = newState; - }, + id: 'alternate', + attributes: { metricAlias: 'alternatebeat-*' }, + type, + references: [], }; - }, - savedObjectsClient: { - get(_: string, sourceId: string) { - if (sourceId === 'alternate') - return { id: 'alternate', attributes: { metricAlias: 'alternatebeat-*' } }; - return { id: 'default', attributes: { metricAlias: 'metricbeat-*' } }; - }, - }, -}; + return { id: 'default', attributes: { metricAlias: 'metricbeat-*' }, type, references: [] }; +}); + +interface AlertTestInstance { + instance: AlertInstanceMock; + actionQueue: any[]; + state: any; +} +const alertInstances = new Map<string, AlertTestInstance>(); +services.alertInstanceFactory.mockImplementation((instanceID: string) => { + const alertInstance: AlertTestInstance = { + instance: alertsMock.createAlertInstanceFactory(), + actionQueue: [], + state: {}, + }; + alertInstances.set(instanceID, alertInstance); + alertInstance.instance.replaceState.mockImplementation((newState: any) => { + alertInstance.state = newState; + return alertInstance.instance; + }); + alertInstance.instance.scheduleActions.mockImplementation((id: string, action: any) => { + alertInstance.actionQueue.push({ id, action }); + return alertInstance.instance; + }); + return alertInstance.instance; +}); + +function mostRecentAction(id: string) { + return alertInstances.get(id)!.actionQueue.pop(); +} + +function getState(id: string) { + return alertInstances.get(id)!.state; +} const baseCriterion = { aggType: 'avg', @@ -90,65 +105,65 @@ describe('The metric threshold alert type', () => { }); test('alerts as expected with the > comparator', async () => { await execute(Comparator.GT, [0.75]); - expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.GT, [1.5]); - expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); test('alerts as expected with the < comparator', async () => { await execute(Comparator.LT, [1.5]); - expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.LT, [0.75]); - expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); test('alerts as expected with the >= comparator', async () => { await execute(Comparator.GT_OR_EQ, [0.75]); - expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.GT_OR_EQ, [1.0]); - expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.GT_OR_EQ, [1.5]); - expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); test('alerts as expected with the <= comparator', async () => { await execute(Comparator.LT_OR_EQ, [1.5]); - expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.LT_OR_EQ, [1.0]); - expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.LT_OR_EQ, [0.75]); - expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); test('alerts as expected with the between comparator', async () => { await execute(Comparator.BETWEEN, [0, 1.5]); - expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.BETWEEN, [0, 0.75]); - expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); test('reports expected values to the action context', async () => { await execute(Comparator.GT, [0.75]); - const mostRecentAction = alertInstances.get(instanceID).mostRecentAction; - expect(mostRecentAction.action.group).toBe('*'); - expect(mostRecentAction.action.valueOf.condition0).toBe(1); - expect(mostRecentAction.action.thresholdOf.condition0).toStrictEqual([0.75]); - expect(mostRecentAction.action.metricOf.condition0).toBe('test.metric.1'); + const { action } = mostRecentAction(instanceID); + expect(action.group).toBe('*'); + expect(action.valueOf.condition0).toBe(1); + expect(action.thresholdOf.condition0).toStrictEqual([0.75]); + expect(action.metricOf.condition0).toBe('test.metric.1'); }); test('fetches the index pattern dynamically', async () => { await execute(Comparator.LT, [17], 'alternate'); - expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.LT, [1.5], 'alternate'); - expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); }); @@ -171,29 +186,29 @@ describe('The metric threshold alert type', () => { const instanceIdB = 'test-b'; test('sends an alert when all groups pass the threshold', async () => { await execute(Comparator.GT, [0.75]); - expect(alertInstances.get(instanceIdA).mostRecentAction.id).toBe(FIRED_ACTIONS.id); - expect(alertInstances.get(instanceIdA).state.alertState).toBe(AlertStates.ALERT); - expect(alertInstances.get(instanceIdB).mostRecentAction.id).toBe(FIRED_ACTIONS.id); - expect(alertInstances.get(instanceIdB).state.alertState).toBe(AlertStates.ALERT); + expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceIdA).alertState).toBe(AlertStates.ALERT); + expect(mostRecentAction(instanceIdB).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceIdB).alertState).toBe(AlertStates.ALERT); }); test('sends an alert when only some groups pass the threshold', async () => { await execute(Comparator.LT, [1.5]); - expect(alertInstances.get(instanceIdA).mostRecentAction.id).toBe(FIRED_ACTIONS.id); - expect(alertInstances.get(instanceIdA).state.alertState).toBe(AlertStates.ALERT); - expect(alertInstances.get(instanceIdB).mostRecentAction).toBe(undefined); - expect(alertInstances.get(instanceIdB).state.alertState).toBe(AlertStates.OK); + expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceIdA).alertState).toBe(AlertStates.ALERT); + expect(mostRecentAction(instanceIdB)).toBe(undefined); + expect(getState(instanceIdB).alertState).toBe(AlertStates.OK); }); test('sends no alert when no groups pass the threshold', async () => { await execute(Comparator.GT, [5]); - expect(alertInstances.get(instanceIdA).mostRecentAction).toBe(undefined); - expect(alertInstances.get(instanceIdA).state.alertState).toBe(AlertStates.OK); - expect(alertInstances.get(instanceIdB).mostRecentAction).toBe(undefined); - expect(alertInstances.get(instanceIdB).state.alertState).toBe(AlertStates.OK); + expect(mostRecentAction(instanceIdA)).toBe(undefined); + expect(getState(instanceIdA).alertState).toBe(AlertStates.OK); + expect(mostRecentAction(instanceIdB)).toBe(undefined); + expect(getState(instanceIdB).alertState).toBe(AlertStates.OK); }); test('reports group values to the action context', async () => { await execute(Comparator.GT, [0.75]); - expect(alertInstances.get(instanceIdA).mostRecentAction.action.group).toBe('a'); - expect(alertInstances.get(instanceIdB).mostRecentAction.action.group).toBe('b'); + expect(mostRecentAction(instanceIdA).action.group).toBe('a'); + expect(mostRecentAction(instanceIdB).action.group).toBe('b'); }); }); @@ -226,34 +241,34 @@ describe('The metric threshold alert type', () => { test('sends an alert when all criteria cross the threshold', async () => { const instanceID = 'test-*'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0]); - expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); }); test('sends no alert when some, but not all, criteria cross the threshold', async () => { const instanceID = 'test-*'; await execute(Comparator.LT_OR_EQ, [1.0], [3.0]); - expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); test('alerts only on groups that meet all criteria when querying with a groupBy parameter', async () => { const instanceIdA = 'test-a'; const instanceIdB = 'test-b'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0], 'something'); - expect(alertInstances.get(instanceIdA).mostRecentAction.id).toBe(FIRED_ACTIONS.id); - expect(alertInstances.get(instanceIdA).state.alertState).toBe(AlertStates.ALERT); - expect(alertInstances.get(instanceIdB).mostRecentAction).toBe(undefined); - expect(alertInstances.get(instanceIdB).state.alertState).toBe(AlertStates.OK); + expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceIdA).alertState).toBe(AlertStates.ALERT); + expect(mostRecentAction(instanceIdB)).toBe(undefined); + expect(getState(instanceIdB).alertState).toBe(AlertStates.OK); }); test('sends all criteria to the action context', async () => { const instanceID = 'test-*'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0]); - const mostRecentAction = alertInstances.get(instanceID).mostRecentAction; - expect(mostRecentAction.action.valueOf.condition0).toBe(1); - expect(mostRecentAction.action.valueOf.condition1).toBe(3.5); - expect(mostRecentAction.action.thresholdOf.condition0).toStrictEqual([1.0]); - expect(mostRecentAction.action.thresholdOf.condition1).toStrictEqual([3.0]); - expect(mostRecentAction.action.metricOf.condition0).toBe('test.metric.1'); - expect(mostRecentAction.action.metricOf.condition1).toBe('test.metric.2'); + const { action } = mostRecentAction(instanceID); + expect(action.valueOf.condition0).toBe(1); + expect(action.valueOf.condition1).toBe(3.5); + expect(action.thresholdOf.condition0).toStrictEqual([1.0]); + expect(action.thresholdOf.condition1).toStrictEqual([3.0]); + expect(action.metricOf.condition0).toBe('test.metric.1'); + expect(action.metricOf.condition1).toBe('test.metric.2'); }); }); describe('querying with the count aggregator', () => { @@ -275,11 +290,11 @@ describe('The metric threshold alert type', () => { }); test('alerts based on the doc_count value instead of the aggregatedValue', async () => { await execute(Comparator.GT, [2]); - expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute(Comparator.LT, [1.5]); - expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); - expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); }); }); diff --git a/x-pack/plugins/infra/server/routes/metadata/index.ts b/x-pack/plugins/infra/server/routes/metadata/index.ts index 03d28110d612a..c45f191b1130d 100644 --- a/x-pack/plugins/infra/server/routes/metadata/index.ts +++ b/x-pack/plugins/infra/server/routes/metadata/index.ts @@ -38,7 +38,7 @@ export const initMetadataRoute = (libs: InfraBackendLibs) => { }, async (requestContext, request, response) => { try { - const { nodeId, nodeType, sourceId } = pipe( + const { nodeId, nodeType, sourceId, timeRange } = pipe( InfraMetadataRequestRT.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); @@ -52,7 +52,8 @@ export const initMetadataRoute = (libs: InfraBackendLibs) => { requestContext, configuration, nodeId, - nodeType + nodeType, + timeRange ); const metricFeatures = pickFeatureName(metricsMetadata.buckets).map( nameToFeature('metrics') @@ -62,7 +63,13 @@ export const initMetadataRoute = (libs: InfraBackendLibs) => { const cloudInstanceId = get<string>(info, 'cloud.instance.id'); const cloudMetricsMetadata = cloudInstanceId - ? await getCloudMetricsMetadata(framework, requestContext, configuration, cloudInstanceId) + ? await getCloudMetricsMetadata( + framework, + requestContext, + configuration, + cloudInstanceId, + timeRange + ) : { buckets: [] }; const cloudMetricsFeatures = pickFeatureName(cloudMetricsMetadata.buckets).map( nameToFeature('metrics') diff --git a/x-pack/plugins/infra/server/routes/metadata/lib/get_cloud_metric_metadata.ts b/x-pack/plugins/infra/server/routes/metadata/lib/get_cloud_metric_metadata.ts index 75ca3ae3caee2..54a1ca0aaa7e0 100644 --- a/x-pack/plugins/infra/server/routes/metadata/lib/get_cloud_metric_metadata.ts +++ b/x-pack/plugins/infra/server/routes/metadata/lib/get_cloud_metric_metadata.ts @@ -21,7 +21,8 @@ export const getCloudMetricsMetadata = async ( framework: KibanaFramework, requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, - instanceId: string + instanceId: string, + timeRange: { from: number; to: number } ): Promise<InfraCloudMetricsAdapterResponse> => { const metricQuery = { allowNoIndices: true, @@ -30,7 +31,18 @@ export const getCloudMetricsMetadata = async ( body: { query: { bool: { - filter: [{ match: { 'cloud.instance.id': instanceId } }], + filter: [ + { match: { 'cloud.instance.id': instanceId } }, + { + range: { + [sourceConfiguration.fields.timestamp]: { + gte: timeRange.from, + lte: timeRange.to, + format: 'epoch_millis', + }, + }, + }, + ], should: CLOUD_METRICS_MODULES.map(module => ({ match: { 'event.module': module } })), }, }, diff --git a/x-pack/plugins/infra/server/routes/metadata/lib/get_metric_metadata.ts b/x-pack/plugins/infra/server/routes/metadata/lib/get_metric_metadata.ts index 191339565b813..7753d3161039b 100644 --- a/x-pack/plugins/infra/server/routes/metadata/lib/get_metric_metadata.ts +++ b/x-pack/plugins/infra/server/routes/metadata/lib/get_metric_metadata.ts @@ -26,7 +26,8 @@ export const getMetricMetadata = async ( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, nodeId: string, - nodeType: InventoryItemType + nodeType: InventoryItemType, + timeRange: { from: number; to: number } ): Promise<InfraMetricsAdapterResponse> => { const fields = findInventoryFields(nodeType, sourceConfiguration.fields); const metricQuery = { @@ -41,6 +42,15 @@ export const getMetricMetadata = async ( { match: { [fields.id]: nodeId }, }, + { + range: { + [sourceConfiguration.fields.timestamp]: { + gte: timeRange.from, + lte: timeRange.to, + format: 'epoch_millis', + }, + }, + }, ], }, }, diff --git a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts index b897c03e89f82..3496ea782ee99 100644 --- a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts +++ b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts @@ -38,6 +38,10 @@ describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { fooVar: { value: 'foo-value' }, fooVar2: { value: [1, 2] }, }, + pkg_stream: { + fooKey: 'fooValue1', + fooKey2: ['fooValue2'], + }, }, { id: 'test-logs-bar', @@ -95,7 +99,7 @@ describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { }); }); - it('returns agent datasource config with flattened input and stream configs', () => { + it('returns agent datasource config with flattened input and package stream', () => { expect(storedDatasourceToAgentDatasource({ ...mockDatasource, inputs: [mockInput] })).toEqual({ id: 'mock-datasource', namespace: 'default', @@ -105,34 +109,18 @@ describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { { type: 'test-logs', enabled: true, - inputVar: 'input-value', - inputVar3: { - testField: 'test', - }, streams: [ { id: 'test-logs-foo', enabled: true, dataset: 'foo', - fooVar: 'foo-value', - fooVar2: [1, 2], + fooKey: 'fooValue1', + fooKey2: ['fooValue2'], }, { id: 'test-logs-bar', enabled: true, dataset: 'bar', - barVar: 'bar-value', - barVar2: [1, 2], - barVar3: [ - { - namespace: 'mockNamespace', - anotherProp: 'test', - }, - { - namespace: 'mockNamespace2', - anotherProp: 'test2', - }, - ], }, ], }, @@ -160,17 +148,13 @@ describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { { type: 'test-logs', enabled: true, - inputVar: 'input-value', - inputVar3: { - testField: 'test', - }, streams: [ { id: 'test-logs-foo', enabled: true, dataset: 'foo', - fooVar: 'foo-value', - fooVar2: [1, 2], + fooKey: 'fooValue1', + fooKey2: ['fooValue2'], }, ], }, diff --git a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts index 20bbbec8919d6..b509878b7f945 100644 --- a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts +++ b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts @@ -3,38 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { safeLoad } from 'js-yaml'; -import { - Datasource, - NewDatasource, - DatasourceConfigRecord, - DatasourceConfigRecordEntry, - FullAgentConfigDatasource, -} from '../types'; +import { Datasource, NewDatasource, FullAgentConfigDatasource } from '../types'; import { DEFAULT_OUTPUT } from '../constants'; -const configReducer = ( - configResult: DatasourceConfigRecord, - configEntry: [string, DatasourceConfigRecordEntry] -): DatasourceConfigRecord => { - const [configName, { type: configType, value: configValue }] = configEntry; - if (configValue !== undefined && configValue !== '') { - if (configType === 'yaml') { - try { - const yamlValue = safeLoad(configValue); - if (yamlValue) { - configResult[configName] = yamlValue; - } - } catch (e) { - // Silently swallow parsing error - } - } else { - configResult[configName] = configValue; - } - } - return configResult; -}; - export const storedDatasourceToAgentDatasource = ( datasource: Datasource | NewDatasource ): FullAgentConfigDatasource => { @@ -50,14 +21,14 @@ export const storedDatasourceToAgentDatasource = ( .map(input => { const fullInput = { ...input, - ...Object.entries(input.config || {}).reduce(configReducer, {}), streams: input.streams .filter(stream => stream.enabled) .map(stream => { const fullStream = { ...stream, - ...Object.entries(stream.config || {}).reduce(configReducer, {}), + ...stream.pkg_stream, }; + delete fullStream.pkg_stream; delete fullStream.config; return fullStream; }), diff --git a/x-pack/plugins/ingest_manager/common/types/models/datasource.ts b/x-pack/plugins/ingest_manager/common/types/models/datasource.ts index ee4d24ab11777..48243a12120f9 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/datasource.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/datasource.ts @@ -23,6 +23,7 @@ export interface DatasourceInputStream { dataset: string; processors?: string[]; config?: DatasourceConfigRecord; + pkg_stream?: any; } export interface DatasourceInput { diff --git a/x-pack/plugins/ingest_manager/common/types/models/epm.ts b/x-pack/plugins/ingest_manager/common/types/models/epm.ts index 5524e7505d74b..53ad0310ea613 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts @@ -19,7 +19,7 @@ export enum InstallStatus { uninstalling = 'uninstalling', } -export type DetailViewPanelName = 'overview' | 'data-sources'; +export type DetailViewPanelName = 'overview' | 'data-sources' | 'settings'; export type ServiceName = 'kibana' | 'elasticsearch'; export type AssetType = KibanaAssetType | ElasticsearchAssetType | AgentAssetType; @@ -218,6 +218,7 @@ export type PackageInfo = Installable< export interface Installation extends SavedObjectAttributes { installed: AssetReference[]; + es_index_patterns: Record<string, string>; name: string; version: string; } diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/datasource.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/datasource.ts index f630602503f0a..61f1f15d49259 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/datasource.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/datasource.ts @@ -4,10 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ import { Datasource, NewDatasource } from '../models'; -import { ListWithKuery } from './common'; export interface GetDatasourcesRequest { - query: ListWithKuery; + query: { + page: number; + perPage: number; + kuery?: string; + }; +} + +export interface GetDatasourcesResponse { + items: Datasource[]; + total: number; + page: number; + perPage: number; + success: boolean; } export interface GetOneDatasourceRequest { @@ -16,6 +27,11 @@ export interface GetOneDatasourceRequest { }; } +export interface GetOneDatasourceResponse { + item: Datasource; + success: boolean; +} + export interface CreateDatasourceRequest { body: NewDatasource; } @@ -29,6 +45,8 @@ export type UpdateDatasourceRequest = GetOneDatasourceRequest & { body: NewDatasource; }; +export type UpdateDatasourceResponse = CreateDatasourceResponse; + export interface DeleteDatasourcesRequest { body: { datasourceIds: string[]; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/shell/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/shell/index.tsx index e6990927b926e..cb65e31fb74b5 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/shell/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/shell/index.tsx @@ -43,7 +43,7 @@ export const ShellEnrollmentInstructions: React.FunctionComponent<Props> = ({ // apiKey.api_key // } sh -c "$(curl ${kibanaUrl}/api/ingest_manager/fleet/install/${currentPlatform})"`; - const quickInstallInstructions = `./agent enroll ${kibanaUrl} ${apiKey.api_key}`; + const quickInstallInstructions = `./elastic-agent enroll ${kibanaUrl} ${apiKey.api_key}`; return ( <> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/header.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/header.tsx index e1f29fdbeb323..1aab6d901a992 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/header.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/header.tsx @@ -7,14 +7,15 @@ import React, { memo } from 'react'; import styled from 'styled-components'; import { EuiFlexGroup, EuiFlexItem, EuiTabs, EuiTab, EuiSpacer } from '@elastic/eui'; import { Props as EuiTabProps } from '@elastic/eui/src/components/tabs/tab'; +import { EuiFlexItemProps } from '@elastic/eui/src/components/flex/flex_item'; const Container = styled.div` border-bottom: ${props => props.theme.eui.euiBorderThin}; background-color: ${props => props.theme.eui.euiPageBackgroundColor}; `; -const Wrapper = styled.div` - max-width: 1200px; +const Wrapper = styled.div<{ maxWidth?: number }>` + max-width: ${props => props.maxWidth || 1200}px; margin-left: auto; margin-right: auto; padding-top: ${props => props.theme.eui.paddingSizes.xl}; @@ -30,22 +31,36 @@ const Tabs = styled(EuiTabs)` `; export interface HeaderProps { + restrictHeaderWidth?: number; leftColumn?: JSX.Element; rightColumn?: JSX.Element; + rightColumnGrow?: EuiFlexItemProps['grow']; tabs?: EuiTabProps[]; } -const HeaderColumns: React.FC<Omit<HeaderProps, 'tabs'>> = memo(({ leftColumn, rightColumn }) => ( - <EuiFlexGroup alignItems="center"> - {leftColumn ? <EuiFlexItem>{leftColumn}</EuiFlexItem> : null} - {rightColumn ? <EuiFlexItem>{rightColumn}</EuiFlexItem> : null} - </EuiFlexGroup> -)); +const HeaderColumns: React.FC<Omit<HeaderProps, 'tabs'>> = memo( + ({ leftColumn, rightColumn, rightColumnGrow }) => ( + <EuiFlexGroup alignItems="center"> + {leftColumn ? <EuiFlexItem>{leftColumn}</EuiFlexItem> : null} + {rightColumn ? <EuiFlexItem grow={rightColumnGrow}>{rightColumn}</EuiFlexItem> : null} + </EuiFlexGroup> + ) +); -export const Header: React.FC<HeaderProps> = ({ leftColumn, rightColumn, tabs }) => ( +export const Header: React.FC<HeaderProps> = ({ + leftColumn, + rightColumn, + rightColumnGrow, + tabs, + restrictHeaderWidth, +}) => ( <Container> - <Wrapper> - <HeaderColumns leftColumn={leftColumn} rightColumn={rightColumn} /> + <Wrapper maxWidth={restrictHeaderWidth}> + <HeaderColumns + leftColumn={leftColumn} + rightColumn={rightColumn} + rightColumnGrow={rightColumnGrow} + /> <EuiFlexGroup> {tabs ? ( <EuiFlexItem> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts index d0072f0355993..0d19ecd0cb735 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts @@ -3,12 +3,14 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { sendRequest } from './use_request'; +import { sendRequest, useRequest } from './use_request'; import { datasourceRouteService } from '../../services'; import { CreateDatasourceRequest, CreateDatasourceResponse } from '../../types'; import { DeleteDatasourcesRequest, DeleteDatasourcesResponse, + GetDatasourcesRequest, + GetDatasourcesResponse, } from '../../../../../common/types/rest_spec'; export const sendCreateDatasource = (body: CreateDatasourceRequest['body']) => { @@ -26,3 +28,11 @@ export const sendDeleteDatasource = (body: DeleteDatasourcesRequest['body']) => body: JSON.stringify(body), }); }; + +export function useGetDatasources(query: GetDatasourcesRequest['query']) { + return useRequest<GetDatasourcesResponse>({ + method: 'get', + path: datasourceRouteService.getListPath(), + query, + }); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/confirm_modal.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/confirm_modal.tsx new file mode 100644 index 0000000000000..aa7eab8f5be8d --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/confirm_modal.tsx @@ -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 React from 'react'; +import { EuiCallOut, EuiOverlayMask, EuiConfirmModal, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { AgentConfig } from '../../../../types'; + +export const ConfirmCreateDatasourceModal: React.FunctionComponent<{ + onConfirm: () => void; + onCancel: () => void; + agentCount: number; + agentConfig: AgentConfig; +}> = ({ onConfirm, onCancel, agentCount, agentConfig }) => { + return ( + <EuiOverlayMask> + <EuiConfirmModal + title={ + <FormattedMessage + id="xpack.ingestManager.createDatasource.confirmModalTitle" + defaultMessage="Save and deploy changes" + /> + } + onCancel={onCancel} + onConfirm={onConfirm} + cancelButtonText={ + <FormattedMessage + id="xpack.ingestManager.deleteApiKeys.confirmModal.cancelButtonLabel" + defaultMessage="Cancel" + /> + } + confirmButtonText={ + <FormattedMessage + id="xpack.ingestManager.createDatasource.confirmModalConfirmButtonLabel" + defaultMessage="Save and deploy changes" + /> + } + buttonColor="primary" + > + <EuiCallOut + iconType="iInCircle" + title={i18n.translate('xpack.ingestManager.createDatasource.confirmModalCalloutTitle', { + defaultMessage: + 'This action will update {agentCount, plural, one {# agent} other {# agents}}', + values: { + agentCount, + }, + })} + > + <FormattedMessage + id="xpack.ingestManager.createDatasource.confirmModalCalloutDescription" + defaultMessage="Fleet has detected that the selected agent configuration, {configName}, is already in use by + some of your agents. As a result of this action, Fleet will deploy updates to all agents + that use this configuration." + values={{ + configName: <b>{agentConfig.name}</b>, + }} + /> + </EuiCallOut> + <EuiSpacer size="l" /> + <FormattedMessage + id="xpack.ingestManager.createDatasource.confirmModalDescription" + defaultMessage="This action can not be undone. Are you sure you wish to continue?" + /> + </EuiConfirmModal> + </EuiOverlayMask> + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/index.ts index 3bfca75668911..aa564690a6092 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/index.ts @@ -5,4 +5,5 @@ */ export { CreateDatasourcePageLayout } from './layout'; export { DatasourceInputPanel } from './datasource_input_panel'; +export { ConfirmCreateDatasourceModal } from './confirm_modal'; export { DatasourceInputVarField } from './datasource_input_var_field'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx index dd242f366e8c0..73a7ba8ec119d 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx @@ -19,108 +19,107 @@ import { WithHeaderLayout } from '../../../../layouts'; import { AgentConfig, PackageInfo } from '../../../../types'; import { PackageIcon } from '../../../../components/package_icon'; import { CreateDatasourceFrom, CreateDatasourceStep } from '../types'; -import { CreateDatasourceStepsNavigation } from './navigation'; export const CreateDatasourcePageLayout: React.FunctionComponent<{ from: CreateDatasourceFrom; basePath: string; cancelUrl: string; maxStep: CreateDatasourceStep | ''; - currentStep: CreateDatasourceStep; agentConfig?: AgentConfig; packageInfo?: PackageInfo; - restrictWidth?: number; -}> = ({ - from, - basePath, - cancelUrl, - maxStep, - currentStep, - agentConfig, - packageInfo, - restrictWidth, - children, -}) => { - return ( - <WithHeaderLayout - restrictWidth={restrictWidth} - leftColumn={ - <EuiFlexGroup direction="column" gutterSize="s" alignItems="flexStart"> - <EuiFlexItem> - <EuiButtonEmpty size="s" iconType="cross" flush="left" href={cancelUrl}> +}> = ({ from, basePath, cancelUrl, maxStep, agentConfig, packageInfo, children }) => { + const leftColumn = ( + <EuiFlexGroup direction="column" gutterSize="s" alignItems="flexStart"> + <EuiFlexItem> + <EuiButtonEmpty size="s" iconType="arrowLeft" flush="left" href={cancelUrl}> + <FormattedMessage + id="xpack.ingestManager.createDatasource.cancelLinkText" + defaultMessage="Cancel" + /> + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem> + <EuiText> + <h1> + <FormattedMessage + id="xpack.ingestManager.createDatasource.pageTitle" + defaultMessage="Add data source" + /> + </h1> + </EuiText> + </EuiFlexItem> + <EuiFlexItem> + <EuiSpacer size="s" /> + <EuiText color="subdued" size="s"> + {from === 'config' ? ( + <FormattedMessage + id="xpack.ingestManager.createDatasource.pageDescriptionfromConfig" + defaultMessage="Follow the instructions below to add an integration to this agent configuration." + /> + ) : ( + <FormattedMessage + id="xpack.ingestManager.createDatasource.pageDescriptionfromPackage" + defaultMessage="Follow the instructions below to add this integration to an agent configuration." + /> + )} + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + ); + const rightColumn = ( + <EuiFlexGroup justifyContent="flexEnd" direction={'row'} gutterSize="xl"> + <EuiFlexItem grow={false}> + <EuiSpacer size="s" /> + {agentConfig && from === 'config' ? ( + <EuiDescriptionList style={{ textAlign: 'right' }} textStyle="reverse"> + <EuiDescriptionListTitle> + <FormattedMessage + id="xpack.ingestManager.createDatasource.agentConfigurationNameLabel" + defaultMessage="Configuration" + /> + </EuiDescriptionListTitle> + <EuiDescriptionListDescription> + {agentConfig?.name || '-'} + </EuiDescriptionListDescription> + </EuiDescriptionList> + ) : null} + {packageInfo && from === 'package' ? ( + <EuiDescriptionList style={{ textAlign: 'right' }} textStyle="reverse"> + <EuiDescriptionListTitle> <FormattedMessage - id="xpack.ingestManager.createDatasource.cancelLinkText" - defaultMessage="Cancel" + id="xpack.ingestManager.createDatasource.packageNameLabel" + defaultMessage="Integration" /> - </EuiButtonEmpty> - </EuiFlexItem> - <EuiFlexItem> - <EuiText> - <h1> - <FormattedMessage - id="xpack.ingestManager.createDatasource.pageTitle" - defaultMessage="Create data source" - /> - </h1> - </EuiText> - </EuiFlexItem> - <EuiFlexItem> - <EuiSpacer size="s" /> - <EuiFlexGroup direction={from === 'config' ? 'row' : 'rowReverse'} gutterSize="xl"> - {agentConfig || from === 'config' ? ( + </EuiDescriptionListTitle> + <EuiDescriptionListDescription> + <EuiFlexGroup justifyContent="flexEnd" alignItems="center" gutterSize="s"> <EuiFlexItem grow={false}> - <EuiDescriptionList textStyle="reverse"> - <EuiDescriptionListTitle> - <FormattedMessage - id="xpack.ingestManager.createDatasource.agentConfigurationNameLabel" - defaultMessage="Configuration" - /> - </EuiDescriptionListTitle> - <EuiDescriptionListDescription> - {agentConfig?.name || '-'} - </EuiDescriptionListDescription> - </EuiDescriptionList> + <PackageIcon + packageName={packageInfo?.name || ''} + version={packageInfo?.version || ''} + icons={packageInfo?.icons} + size="m" + /> </EuiFlexItem> - ) : null} - {packageInfo || from === 'package' ? ( <EuiFlexItem grow={false}> - <EuiDescriptionList textStyle="reverse"> - <EuiDescriptionListTitle> - <FormattedMessage - id="xpack.ingestManager.createDatasource.packageNameLabel" - defaultMessage="Integration" - /> - </EuiDescriptionListTitle> - <EuiDescriptionListDescription> - <EuiFlexGroup alignItems="center" gutterSize="s"> - <EuiFlexItem grow={false}> - <PackageIcon - packageName={packageInfo?.name || ''} - version={packageInfo?.version || ''} - icons={packageInfo?.icons} - size="m" - /> - </EuiFlexItem> - <EuiFlexItem grow={false}> - {packageInfo?.title || packageInfo?.name || '-'} - </EuiFlexItem> - </EuiFlexGroup> - </EuiDescriptionListDescription> - </EuiDescriptionList> + {packageInfo?.title || packageInfo?.name || '-'} </EuiFlexItem> - ) : null} - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - } - rightColumn={ - <CreateDatasourceStepsNavigation - from={from} - basePath={basePath} - maxStep={maxStep} - currentStep={currentStep} - /> - } + </EuiFlexGroup> + </EuiDescriptionListDescription> + </EuiDescriptionList> + ) : null} + </EuiFlexItem> + </EuiFlexGroup> + ); + + const maxWidth = 770; + return ( + <WithHeaderLayout + restrictHeaderWidth={maxWidth} + restrictWidth={maxWidth} + leftColumn={leftColumn} + rightColumn={rightColumn} + rightColumnGrow={false} > {children} </WithHeaderLayout> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/navigation.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/navigation.tsx deleted file mode 100644 index 7dae981e65c30..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/navigation.tsx +++ /dev/null @@ -1,85 +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 React from 'react'; -import styled from 'styled-components'; -import { useHistory } from 'react-router-dom'; -import { i18n } from '@kbn/i18n'; -import { EuiStepsHorizontal } from '@elastic/eui'; -import { CreateDatasourceFrom, CreateDatasourceStep } from '../types'; -import { WeightedCreateDatasourceSteps, CREATE_DATASOURCE_STEP_PATHS } from '../constants'; - -const StepsHorizontal = styled(EuiStepsHorizontal)` - background: none; -`; - -export const CreateDatasourceStepsNavigation: React.FunctionComponent<{ - from: CreateDatasourceFrom; - basePath: string; - maxStep: CreateDatasourceStep | ''; - currentStep: CreateDatasourceStep; -}> = ({ from, basePath, maxStep, currentStep }) => { - const history = useHistory(); - - const steps = [ - from === 'config' - ? { - title: i18n.translate('xpack.ingestManager.createDatasource.stepSelectPackageLabel', { - defaultMessage: 'Select integration', - }), - isSelected: currentStep === 'selectPackage', - isComplete: - WeightedCreateDatasourceSteps.indexOf('selectPackage') <= - WeightedCreateDatasourceSteps.indexOf(maxStep), - onClick: () => { - history.push(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.selectPackage}`); - }, - } - : { - title: i18n.translate('xpack.ingestManager.createDatasource.stepSelectConfigLabel', { - defaultMessage: 'Select configuration', - }), - isSelected: currentStep === 'selectConfig', - isComplete: - WeightedCreateDatasourceSteps.indexOf('selectConfig') <= - WeightedCreateDatasourceSteps.indexOf(maxStep), - onClick: () => { - history.push(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.selectConfig}`); - }, - }, - { - title: i18n.translate('xpack.ingestManager.createDatasource.stepConfigureDatasourceLabel', { - defaultMessage: 'Configure data source', - }), - isSelected: currentStep === 'configure', - isComplete: - WeightedCreateDatasourceSteps.indexOf('configure') <= - WeightedCreateDatasourceSteps.indexOf(maxStep), - disabled: - WeightedCreateDatasourceSteps.indexOf(maxStep) < - WeightedCreateDatasourceSteps.indexOf('configure') - 1, - onClick: () => { - history.push(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.configure}`); - }, - }, - { - title: i18n.translate('xpack.ingestManager.createDatasource.stepReviewLabel', { - defaultMessage: 'Review', - }), - isSelected: currentStep === 'review', - isComplete: - WeightedCreateDatasourceSteps.indexOf('review') <= - WeightedCreateDatasourceSteps.indexOf(maxStep), - disabled: - WeightedCreateDatasourceSteps.indexOf(maxStep) < - WeightedCreateDatasourceSteps.indexOf('review') - 1, - onClick: () => { - history.push(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.review}`); - }, - }, - ]; - - return <StepsHorizontal steps={steps} />; -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/constants.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/constants.ts index eea18179560a1..49223a8eb4531 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/constants.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/constants.ts @@ -9,10 +9,3 @@ export const WeightedCreateDatasourceSteps = [ 'configure', 'review', ]; - -export const CREATE_DATASOURCE_STEP_PATHS = { - selectConfig: '/select-config', - selectPackage: '/select-package', - configure: '/configure', - review: '/review', -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/index.tsx index 461bb750ca6f5..1ad579d591b21 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/index.tsx @@ -3,45 +3,74 @@ * 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, { useState } from 'react'; -import { - useRouteMatch, - HashRouter as Router, - Switch, - Route, - Redirect, - useHistory, -} from 'react-router-dom'; +import React, { useState, useEffect } from 'react'; +import { useRouteMatch, useHistory } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiButtonEmpty } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiButton, + EuiSteps, + EuiBottomBar, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { EuiStepProps } from '@elastic/eui/src/components/steps/step'; import { AGENT_CONFIG_DETAILS_PATH } from '../../../constants'; import { AgentConfig, PackageInfo, NewDatasource } from '../../../types'; -import { useLink, sendCreateDatasource } from '../../../hooks'; +import { + useLink, + sendCreateDatasource, + useCore, + useConfig, + sendGetAgentStatus, +} from '../../../hooks'; import { useLinks as useEPMLinks } from '../../epm/hooks'; -import { CreateDatasourcePageLayout } from './components'; +import { CreateDatasourcePageLayout, ConfirmCreateDatasourceModal } from './components'; import { CreateDatasourceFrom, CreateDatasourceStep } from './types'; -import { CREATE_DATASOURCE_STEP_PATHS } from './constants'; -import { DatasourceValidationResults, validateDatasource } from './services'; +import { DatasourceValidationResults, validateDatasource, validationHasErrors } from './services'; import { StepSelectPackage } from './step_select_package'; import { StepSelectConfig } from './step_select_config'; import { StepConfigureDatasource } from './step_configure_datasource'; -import { StepReviewDatasource } from './step_review'; + +import { StepDefineDatasource } from './step_define_datasource'; export const CreateDatasourcePage: React.FunctionComponent = () => { + const { notifications } = useCore(); + const { + fleet: { enabled: isFleetEnabled }, + } = useConfig(); const { params: { configId, pkgkey }, - path: matchPath, url: basePath, } = useRouteMatch(); const history = useHistory(); const from: CreateDatasourceFrom = configId ? 'config' : 'package'; const [maxStep, setMaxStep] = useState<CreateDatasourceStep | ''>(''); - const [isSaving, setIsSaving] = useState<boolean>(false); // Agent config and package info states const [agentConfig, setAgentConfig] = useState<AgentConfig>(); const [packageInfo, setPackageInfo] = useState<PackageInfo>(); + const agentConfigId = agentConfig?.id; + // Retrieve agent count + useEffect(() => { + const getAgentCount = async () => { + if (agentConfigId) { + const { data } = await sendGetAgentStatus({ configId: agentConfigId }); + if (data?.results.total) { + setAgentCount(data.results.total); + } + } + }; + + if (isFleetEnabled && agentConfigId) { + getAgentCount(); + } + }, [agentConfigId, isFleetEnabled]); + const [agentCount, setAgentCount] = useState<number>(0); + // New datasource state const [datasource, setDatasource] = useState<NewDatasource>({ name: '', @@ -60,6 +89,7 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { if (updatedPackageInfo) { setPackageInfo(updatedPackageInfo); } else { + setFormState('INVALID'); setPackageInfo(undefined); setMaxStep(''); } @@ -73,6 +103,7 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { if (updatedAgentConfig) { setAgentConfig(updatedAgentConfig); } else { + setFormState('INVALID'); setAgentConfig(undefined); setMaxStep(''); } @@ -81,6 +112,8 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { console.debug('Agent config updated', updatedAgentConfig); }; + const hasErrors = validationResults ? validationHasErrors(validationResults) : false; + // Update datasource method const updateDatasource = (updatedFields: Partial<NewDatasource>) => { const newDatasource = { @@ -88,9 +121,18 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { ...updatedFields, }; setDatasource(newDatasource); + // eslint-disable-next-line no-console console.debug('Datasource updated', newDatasource); - updateDatasourceValidation(newDatasource); + const newValidationResults = updateDatasourceValidation(newDatasource); + const hasPackage = newDatasource.package; + const hasValidationErrors = newValidationResults + ? validationHasErrors(newValidationResults) + : false; + const hasAgentConfig = newDatasource.config_id && newDatasource.config_id !== ''; + if (hasPackage && hasAgentConfig && !hasValidationErrors) { + setFormState('VALID'); + } }; const updateDatasourceValidation = (newDatasource?: NewDatasource) => { @@ -99,6 +141,8 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { setValidationResults(newValidationResult); // eslint-disable-next-line no-console console.debug('Datasource validation results', newValidationResult); + + return newValidationResult; } }; @@ -112,34 +156,37 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { }); const cancelUrl = from === 'config' ? CONFIG_URL : PACKAGE_URL; - // Redirect to first step - const redirectToFirstStep = - from === 'config' ? ( - <Redirect to={`${basePath}${CREATE_DATASOURCE_STEP_PATHS.selectPackage}`} /> - ) : ( - <Redirect to={`${basePath}${CREATE_DATASOURCE_STEP_PATHS.selectConfig}`} /> - ); - - // Url to first and second steps - const SELECT_PACKAGE_URL = useLink(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.selectPackage}`); - const SELECT_CONFIG_URL = useLink(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.selectConfig}`); - const CONFIGURE_DATASOURCE_URL = useLink(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.configure}`); - const firstStepUrl = from === 'config' ? SELECT_PACKAGE_URL : SELECT_CONFIG_URL; - const secondStepUrl = CONFIGURE_DATASOURCE_URL; - - // Redirect to second step - const redirectToSecondStep = ( - <Redirect to={`${basePath}${CREATE_DATASOURCE_STEP_PATHS.configure}`} /> - ); - // Save datasource + const [formState, setFormState] = useState< + 'VALID' | 'INVALID' | 'CONFIRM' | 'LOADING' | 'SUBMITTED' + >('INVALID'); const saveDatasource = async () => { - setIsSaving(true); + setFormState('LOADING'); const result = await sendCreateDatasource(datasource); - setIsSaving(false); + setFormState('SUBMITTED'); return result; }; + const onSubmit = async () => { + if (formState === 'VALID' && hasErrors) { + setFormState('INVALID'); + return; + } + if (agentCount !== 0 && formState !== 'CONFIRM') { + setFormState('CONFIRM'); + return; + } + const { error } = await saveDatasource(); + if (!error) { + history.push(`${AGENT_CONFIG_DETAILS_PATH}${agentConfig ? agentConfig.id : configId}`); + } else { + notifications.toasts.addError(error, { + title: 'Error', + }); + setFormState('VALID'); + } + }; + const layoutProps = { from, basePath, @@ -147,135 +194,108 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { maxStep, agentConfig, packageInfo, - restrictWidth: 770, }; - return ( - <Router> - <Switch> - {/* Redirect to first step from `/` */} - {from === 'config' ? ( - <Redirect - exact - from={`${matchPath}`} - to={`${matchPath}${CREATE_DATASOURCE_STEP_PATHS.selectPackage}`} + const steps: EuiStepProps[] = [ + from === 'package' + ? { + title: i18n.translate('xpack.ingestManager.createDatasource.stepSelectAgentConfigTitle', { + defaultMessage: 'Select an agent configuration', + }), + children: ( + <StepSelectConfig + pkgkey={pkgkey} + updatePackageInfo={updatePackageInfo} + agentConfig={agentConfig} + updateAgentConfig={updateAgentConfig} + /> + ), + } + : { + title: i18n.translate('xpack.ingestManager.createDatasource.stepSelectPackageTitle', { + defaultMessage: 'Select an integration', + }), + children: ( + <StepSelectPackage + agentConfigId={configId} + updateAgentConfig={updateAgentConfig} + packageInfo={packageInfo} + updatePackageInfo={updatePackageInfo} + /> + ), + }, + { + title: i18n.translate('xpack.ingestManager.createDatasource.stepDefineDatasourceTitle', { + defaultMessage: 'Define your data source', + }), + status: !packageInfo || !agentConfig ? 'disabled' : undefined, + children: + agentConfig && packageInfo ? ( + <StepDefineDatasource + agentConfig={agentConfig} + packageInfo={packageInfo} + datasource={datasource} + updateDatasource={updateDatasource} /> - ) : ( - <Redirect - exact - from={`${matchPath}`} - to={`${matchPath}${CREATE_DATASOURCE_STEP_PATHS.selectConfig}`} + ) : null, + }, + { + title: i18n.translate('xpack.ingestManager.createDatasource.stepConfgiureDatasourceTitle', { + defaultMessage: 'Select the data you want to collect', + }), + status: !packageInfo || !agentConfig ? 'disabled' : undefined, + children: + agentConfig && packageInfo ? ( + <StepConfigureDatasource + agentConfig={agentConfig} + packageInfo={packageInfo} + datasource={datasource} + updateDatasource={updateDatasource} + validationResults={validationResults!} + submitAttempted={formState === 'INVALID'} /> - )} - - {/* First step, either render select package or select config depending on entry */} - {from === 'config' ? ( - <Route path={`${matchPath}${CREATE_DATASOURCE_STEP_PATHS.selectPackage}`}> - <CreateDatasourcePageLayout {...layoutProps} currentStep="selectPackage"> - <StepSelectPackage - agentConfigId={configId} - updateAgentConfig={updateAgentConfig} - packageInfo={packageInfo} - updatePackageInfo={updatePackageInfo} - cancelUrl={cancelUrl} - onNext={() => { - setMaxStep('selectPackage'); - history.push(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.configure}`); - }} - /> - </CreateDatasourcePageLayout> - </Route> - ) : ( - <Route path={`${matchPath}${CREATE_DATASOURCE_STEP_PATHS.selectConfig}`}> - <CreateDatasourcePageLayout {...layoutProps} currentStep="selectConfig"> - <StepSelectConfig - pkgkey={pkgkey} - updatePackageInfo={updatePackageInfo} - agentConfig={agentConfig} - updateAgentConfig={updateAgentConfig} - cancelUrl={cancelUrl} - onNext={() => { - setMaxStep('selectConfig'); - history.push(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.configure}`); - }} - /> - </CreateDatasourcePageLayout> - </Route> - )} - - {/* Second step to configure data source, redirect to first step if agent config */} - {/* or package info isn't defined (i.e. after full page reload) */} - <Route path={`${matchPath}${CREATE_DATASOURCE_STEP_PATHS.configure}`}> - <CreateDatasourcePageLayout {...layoutProps} currentStep="configure"> - {!agentConfig || !packageInfo ? ( - redirectToFirstStep - ) : ( - <StepConfigureDatasource - agentConfig={agentConfig} - packageInfo={packageInfo} - datasource={datasource} - updateDatasource={updateDatasource} - validationResults={validationResults!} - backLink={ - <EuiButtonEmpty href={firstStepUrl} iconType="arrowLeft" iconSide="left"> - {from === 'config' ? ( - <FormattedMessage - id="xpack.ingestManager.createDatasource.changePackageLinkText" - defaultMessage="Change integration" - /> - ) : ( - <FormattedMessage - id="xpack.ingestManager.createDatasource.changeConfigLinkText" - defaultMessage="Change configuration" - /> - )} - </EuiButtonEmpty> - } - cancelUrl={cancelUrl} - onNext={() => { - setMaxStep('configure'); - history.push(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.review}`); - }} + ) : null, + }, + ]; + return ( + <CreateDatasourcePageLayout {...layoutProps}> + {formState === 'CONFIRM' && agentConfig && ( + <ConfirmCreateDatasourceModal + agentCount={agentCount} + agentConfig={agentConfig} + onConfirm={onSubmit} + onCancel={() => setFormState('VALID')} + /> + )} + <EuiSteps steps={steps} /> + <EuiSpacer size="l" /> + <EuiBottomBar css={{ zIndex: 5 }} paddingSize="s"> + <EuiFlexGroup gutterSize="s" justifyContent="flexEnd"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty color="ghost" href={cancelUrl}> + <FormattedMessage + id="xpack.ingestManager.createDatasource.cancelButton" + defaultMessage="Cancel" /> - )} - </CreateDatasourcePageLayout> - </Route> - - {/* Third step to review, redirect to second step if data source name is missing */} - {/* (i.e. after full page reload) */} - <Route path={`${matchPath}${CREATE_DATASOURCE_STEP_PATHS.review}`}> - <CreateDatasourcePageLayout {...layoutProps} currentStep="review"> - {!agentConfig || !datasource.name ? ( - redirectToSecondStep - ) : ( - <StepReviewDatasource - agentConfig={agentConfig} - datasource={datasource} - cancelUrl={cancelUrl} - isSubmitLoading={isSaving} - backLink={ - <EuiButtonEmpty href={secondStepUrl} iconType="arrowLeft" iconSide="left"> - <FormattedMessage - id="xpack.ingestManager.createDatasource.editDatasourceLinkText" - defaultMessage="Edit data source" - /> - </EuiButtonEmpty> - } - onSubmit={async () => { - const { error } = await saveDatasource(); - if (!error) { - history.push( - `${AGENT_CONFIG_DETAILS_PATH}${agentConfig ? agentConfig.id : configId}` - ); - } else { - // TODO: Handle save datasource error - } - }} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton + onClick={onSubmit} + isLoading={formState === 'LOADING'} + disabled={formState !== 'VALID'} + iconType="save" + color="primary" + fill + > + <FormattedMessage + id="xpack.ingestManager.createDatasource.saveButton" + defaultMessage="Save data source" /> - )} - </CreateDatasourcePageLayout> - </Route> - </Switch> - </Router> + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiBottomBar> + </CreateDatasourcePageLayout> ); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_configure_datasource.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_configure_datasource.tsx index 105d6c66a5704..843891b63ca01 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_configure_datasource.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_configure_datasource.tsx @@ -3,23 +3,18 @@ * 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, Fragment } from 'react'; -import { i18n } from '@kbn/i18n'; +import React, { useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiSteps, EuiPanel, EuiFlexGroup, EuiFlexItem, - EuiFormRow, - EuiButtonEmpty, EuiSpacer, EuiEmptyPrompt, EuiText, - EuiButton, - EuiComboBox, EuiCallOut, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { AgentConfig, PackageInfo, @@ -30,7 +25,7 @@ import { import { Loading } from '../../../components'; import { packageToConfigDatasourceInputs } from '../../../services'; import { DatasourceValidationResults, validationHasErrors } from './services'; -import { DatasourceInputPanel, DatasourceInputVarField } from './components'; +import { DatasourceInputPanel } from './components'; export const StepConfigureDatasource: React.FunctionComponent<{ agentConfig: AgentConfig; @@ -38,24 +33,17 @@ export const StepConfigureDatasource: React.FunctionComponent<{ datasource: NewDatasource; updateDatasource: (fields: Partial<NewDatasource>) => void; validationResults: DatasourceValidationResults; - backLink: JSX.Element; - cancelUrl: string; - onNext: () => void; + submitAttempted: boolean; }> = ({ agentConfig, packageInfo, datasource, updateDatasource, validationResults, - backLink, - cancelUrl, - onNext, + submitAttempted, }) => { // Form show/hide states - const [isShowingAdvancedDefine, setIsShowingAdvancedDefine] = useState<boolean>(false); - // Form submit state - const [submitAttempted, setSubmitAttempted] = useState<boolean>(false); const hasErrors = validationResults ? validationHasErrors(validationResults) : false; // Update datasource's package and config info @@ -95,107 +83,6 @@ export const StepConfigureDatasource: React.FunctionComponent<{ } }, [datasource.package, datasource.config_id, agentConfig, packageInfo, updateDatasource]); - // Step A, define datasource - const renderDefineDatasource = () => ( - <EuiPanel> - <EuiFlexGroup> - <EuiFlexItem grow={1}> - <DatasourceInputVarField - varDef={{ - name: 'name', - title: i18n.translate( - 'xpack.ingestManager.createDatasource.stepConfigure.datasourceNameInputLabel', - { - defaultMessage: 'Data source name', - } - ), - type: 'text', - required: true, - }} - value={datasource.name} - onChange={(newValue: any) => { - updateDatasource({ - name: newValue, - }); - }} - errors={validationResults!.name} - forceShowErrors={submitAttempted} - /> - </EuiFlexItem> - <EuiFlexItem grow={1}> - <DatasourceInputVarField - varDef={{ - name: 'description', - title: i18n.translate( - 'xpack.ingestManager.createDatasource.stepConfigure.datasourceDescriptionInputLabel', - { - defaultMessage: 'Description', - } - ), - type: 'text', - required: false, - }} - value={datasource.description} - onChange={(newValue: any) => { - updateDatasource({ - description: newValue, - }); - }} - errors={validationResults!.description} - forceShowErrors={submitAttempted} - /> - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer size="m" /> - <EuiButtonEmpty - flush="left" - size="xs" - iconType={isShowingAdvancedDefine ? 'arrowUp' : 'arrowDown'} - onClick={() => setIsShowingAdvancedDefine(!isShowingAdvancedDefine)} - > - <FormattedMessage - id="xpack.ingestManager.createDatasource.stepConfigure.advancedOptionsToggleLinkText" - defaultMessage="Advanced options" - /> - </EuiButtonEmpty> - {/* Todo: Populate list of existing namespaces */} - {isShowingAdvancedDefine ? ( - <Fragment> - <EuiSpacer size="m" /> - <EuiFlexGroup> - <EuiFlexItem grow={1}> - <EuiFormRow - label={ - <FormattedMessage - id="xpack.ingestManager.createDatasource.stepConfigure.datasourceNamespaceInputLabel" - defaultMessage="Namespace" - /> - } - > - <EuiComboBox - noSuggestions - singleSelection={true} - selectedOptions={datasource.namespace ? [{ label: datasource.namespace }] : []} - onCreateOption={(newNamespace: string) => { - updateDatasource({ - namespace: newNamespace, - }); - }} - onChange={(newNamespaces: Array<{ label: string }>) => { - updateDatasource({ - namespace: newNamespaces.length ? newNamespaces[0].label : '', - }); - }} - /> - </EuiFormRow> - </EuiFlexItem> - <EuiFlexItem grow={1} /> - </EuiFlexGroup> - </Fragment> - ) : null} - </EuiPanel> - ); - // Step B, configure inputs (and their streams) // Assume packages only export one datasource for now const renderConfigureInputs = () => @@ -252,41 +139,10 @@ export const StepConfigureDatasource: React.FunctionComponent<{ return validationResults ? ( <EuiFlexGroup direction="column" gutterSize="none"> - <EuiFlexItem> - <EuiFlexGroup direction="column" gutterSize="none"> - <EuiFlexItem> - <EuiFlexGroup justifyContent="flexEnd"> - <EuiFlexItem grow={false}>{backLink}</EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - <EuiFlexItem> - <EuiSteps - steps={[ - { - title: i18n.translate( - 'xpack.ingestManager.createDatasource.stepConfigure.defineDatasourceTitle', - { - defaultMessage: 'Define your datasource', - } - ), - children: renderDefineDatasource(), - }, - { - title: i18n.translate( - 'xpack.ingestManager.createDatasource.stepConfigure.chooseDataTitle', - { - defaultMessage: 'Choose the data you want to collect', - } - ), - children: renderConfigureInputs(), - }, - ]} - /> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> + <EuiFlexItem>{renderConfigureInputs()}</EuiFlexItem> {hasErrors && submitAttempted ? ( <EuiFlexItem> + <EuiSpacer size="m" /> <EuiCallOut title={i18n.translate( 'xpack.ingestManager.createDatasource.stepConfigure.validationErrorTitle', @@ -306,36 +162,6 @@ export const StepConfigureDatasource: React.FunctionComponent<{ <EuiSpacer size="m" /> </EuiFlexItem> ) : null} - <EuiFlexItem> - <EuiFlexGroup justifyContent="flexEnd"> - <EuiFlexItem grow={false}> - <EuiButtonEmpty href={cancelUrl}> - <FormattedMessage - id="xpack.ingestManager.createDatasource.cancelLinkText" - defaultMessage="Cancel" - /> - </EuiButtonEmpty> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButton - fill - iconType="arrowRight" - iconSide="right" - onClick={() => { - setSubmitAttempted(true); - if (!hasErrors) { - onNext(); - } - }} - > - <FormattedMessage - id="xpack.ingestManager.createDatasource.continueButtonText" - defaultMessage="Continue" - /> - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> </EuiFlexGroup> ) : ( <Loading /> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_define_datasource.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_define_datasource.tsx new file mode 100644 index 0000000000000..792389381eaf0 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_define_datasource.tsx @@ -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 React, { useEffect, useState, Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGrid, + EuiFlexItem, + EuiFormRow, + EuiFieldText, + EuiButtonEmpty, + EuiSpacer, + EuiText, + EuiComboBox, +} from '@elastic/eui'; +import { AgentConfig, PackageInfo, Datasource, NewDatasource } from '../../../types'; +import { packageToConfigDatasourceInputs } from '../../../services'; + +export const StepDefineDatasource: React.FunctionComponent<{ + agentConfig: AgentConfig; + packageInfo: PackageInfo; + datasource: NewDatasource; + updateDatasource: (fields: Partial<NewDatasource>) => void; +}> = ({ agentConfig, packageInfo, datasource, updateDatasource }) => { + // Form show/hide states + const [isShowingAdvancedDefine, setIsShowingAdvancedDefine] = useState<boolean>(false); + + // Update datasource's package and config info + useEffect(() => { + const dsPackage = datasource.package; + const currentPkgKey = dsPackage ? `${dsPackage.name}-${dsPackage.version}` : ''; + const pkgKey = `${packageInfo.name}-${packageInfo.version}`; + + // If package has changed, create shell datasource with input&stream values based on package info + if (currentPkgKey !== pkgKey) { + // Existing datasources on the agent config using the package name, retrieve highest number appended to datasource name + const dsPackageNamePattern = new RegExp(`${packageInfo.name}-(\\d+)`); + const dsWithMatchingNames = (agentConfig.datasources as Datasource[]) + .filter(ds => Boolean(ds.name.match(dsPackageNamePattern))) + .map(ds => parseInt(ds.name.match(dsPackageNamePattern)![1], 10)) + .sort(); + + updateDatasource({ + name: `${packageInfo.name}-${ + dsWithMatchingNames.length ? dsWithMatchingNames[dsWithMatchingNames.length - 1] + 1 : 1 + }`, + package: { + name: packageInfo.name, + title: packageInfo.title, + version: packageInfo.version, + }, + inputs: packageToConfigDatasourceInputs(packageInfo), + }); + } + + // If agent config has changed, update datasource's config ID and namespace + if (datasource.config_id !== agentConfig.id) { + updateDatasource({ + config_id: agentConfig.id, + namespace: agentConfig.namespace, + }); + } + }, [datasource.package, datasource.config_id, agentConfig, packageInfo, updateDatasource]); + + return ( + <> + <EuiFlexGrid columns={2}> + <EuiFlexItem> + <EuiFormRow + label={ + <FormattedMessage + id="xpack.ingestManager.createDatasource.stepConfigure.datasourceNameInputLabel" + defaultMessage="Data source name" + /> + } + > + <EuiFieldText + value={datasource.name} + onChange={e => + updateDatasource({ + name: e.target.value, + }) + } + /> + </EuiFormRow> + </EuiFlexItem> + <EuiFlexItem> + <EuiFormRow + label={ + <FormattedMessage + id="xpack.ingestManager.createDatasource.stepConfigure.datasourceDescriptionInputLabel" + defaultMessage="Description" + /> + } + labelAppend={ + <EuiText size="xs" color="subdued"> + <FormattedMessage + id="xpack.ingestManager.createDatasource.stepConfigure.inputVarFieldOptionalLabel" + defaultMessage="Optional" + /> + </EuiText> + } + > + <EuiFieldText + value={datasource.description} + onChange={e => + updateDatasource({ + description: e.target.value, + }) + } + /> + </EuiFormRow> + </EuiFlexItem> + </EuiFlexGrid> + <EuiSpacer size="m" /> + <EuiButtonEmpty + flush="left" + size="xs" + iconType={isShowingAdvancedDefine ? 'arrowUp' : 'arrowDown'} + onClick={() => setIsShowingAdvancedDefine(!isShowingAdvancedDefine)} + > + <FormattedMessage + id="xpack.ingestManager.createDatasource.stepConfigure.advancedOptionsToggleLinkText" + defaultMessage="Advanced options" + /> + </EuiButtonEmpty> + {/* Todo: Populate list of existing namespaces */} + {isShowingAdvancedDefine ? ( + <Fragment> + <EuiSpacer size="m" /> + <EuiFlexGrid columns={2}> + <EuiFlexItem> + <EuiFormRow + label={ + <FormattedMessage + id="xpack.ingestManager.createDatasource.stepConfigure.datasourceNamespaceInputLabel" + defaultMessage="Namespace" + /> + } + > + <EuiComboBox + noSuggestions + singleSelection={true} + selectedOptions={datasource.namespace ? [{ label: datasource.namespace }] : []} + onCreateOption={(newNamespace: string) => { + updateDatasource({ + namespace: newNamespace, + }); + }} + onChange={(newNamespaces: Array<{ label: string }>) => { + updateDatasource({ + namespace: newNamespaces.length ? newNamespaces[0].label : '', + }); + }} + /> + </EuiFormRow> + </EuiFlexItem> + </EuiFlexGrid> + </Fragment> + ) : null} + </> + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_review.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_review.tsx deleted file mode 100644 index 20af5954c1436..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_review.tsx +++ /dev/null @@ -1,202 +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 React, { Fragment, useState, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiButton, - EuiTitle, - EuiCallOut, - EuiText, - EuiCheckbox, - EuiTabbedContent, - EuiCodeBlock, - EuiSpacer, -} from '@elastic/eui'; -import { dump } from 'js-yaml'; -import { NewDatasource, AgentConfig } from '../../../types'; -import { useConfig, sendGetAgentStatus } from '../../../hooks'; -import { storedDatasourceToAgentDatasource } from '../../../services'; - -const KEYS_TO_SINK = ['inputs', 'streams']; - -export const StepReviewDatasource: React.FunctionComponent<{ - agentConfig: AgentConfig; - datasource: NewDatasource; - backLink: JSX.Element; - cancelUrl: string; - onSubmit: () => void; - isSubmitLoading: boolean; -}> = ({ agentConfig, datasource, backLink, cancelUrl, onSubmit, isSubmitLoading }) => { - // Agent count info states - const [agentCount, setAgentCount] = useState<number>(0); - const [agentCountChecked, setAgentCountChecked] = useState<boolean>(false); - - // Config information - const { - fleet: { enabled: isFleetEnabled }, - } = useConfig(); - - // Retrieve agent count - useEffect(() => { - const getAgentCount = async () => { - const { data } = await sendGetAgentStatus({ configId: agentConfig.id }); - if (data?.results.total) { - setAgentCount(data.results.total); - } - }; - - if (isFleetEnabled) { - getAgentCount(); - } - }, [agentConfig.id, isFleetEnabled]); - - const showAgentDisclaimer = isFleetEnabled && agentCount; - const fullAgentDatasource = storedDatasourceToAgentDatasource(datasource); - - return ( - <EuiFlexGroup direction="column"> - <EuiFlexItem> - <EuiFlexGroup justifyContent="spaceBetween"> - <EuiFlexItem grow={false}> - <EuiTitle size="s"> - <h3> - <FormattedMessage - id="xpack.ingestManager.createDatasource.stepReview.reviewTitle" - defaultMessage="Review changes" - /> - </h3> - </EuiTitle> - </EuiFlexItem> - <EuiFlexItem grow={false}>{backLink}</EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - - {/* Agents affected warning */} - {showAgentDisclaimer ? ( - <EuiFlexItem> - <EuiCallOut - title={ - <FormattedMessage - id="xpack.ingestManager.createDatasource.stepReview.agentsAffectedCalloutTitle" - defaultMessage="This action will affect {count, plural, one {# agent} other {# agents}}" - values={{ - count: agentCount, - }} - /> - } - > - <EuiText> - <p> - <FormattedMessage - id="xpack.ingestManager.createDatasource.stepReview.agentsAffectedCalloutText" - defaultMessage="Fleet has detected that the selected agent configuration, {configName} is already in use by some your agents. As a result of this action, Fleet will update all agents that are enrolled with this configuration." - values={{ - configName: <strong>{agentConfig.name}</strong>, - }} - /> - </p> - </EuiText> - </EuiCallOut> - </EuiFlexItem> - ) : null} - - {/* Preview and YAML view */} - {/* TODO: Implement preview tab */} - <EuiFlexItem> - <EuiTabbedContent - tabs={[ - { - id: 'yaml', - name: i18n.translate('xpack.ingestManager.agentConfigInfo.yamlTabName', { - defaultMessage: 'YAML', - }), - content: ( - <Fragment> - <EuiSpacer size="s" /> - <EuiCodeBlock language="yaml" isCopyable overflowHeight={450}> - {dump(fullAgentDatasource, { - sortKeys: (a: string, b: string) => { - // Make YAML output prettier by sinking certain fields - if (KEYS_TO_SINK.indexOf(a) > -1) { - return 1; - } - if (KEYS_TO_SINK.indexOf(b) > -1) { - return -1; - } - return 0; - }, - })} - </EuiCodeBlock> - </Fragment> - ), - }, - ]} - /> - </EuiFlexItem> - - {/* Confirm agents affected */} - {showAgentDisclaimer ? ( - <EuiFlexItem> - <EuiFlexGroup direction="column" gutterSize="s"> - <EuiFlexItem> - <EuiTitle size="xs"> - <h4> - <FormattedMessage - id="xpack.ingestManager.createDatasource.stepReview.confirmAgentDisclaimerTitle" - defaultMessage="Confirm your decision" - /> - </h4> - </EuiTitle> - </EuiFlexItem> - <EuiFlexItem> - <EuiCheckbox - id="CreateDatasourceAgentDisclaimer" - label={ - <FormattedMessage - id="xpack.ingestManager.createDatasource.stepReview.confirmAgentDisclaimerText" - defaultMessage="I understand that this action will update all agents that are enrolled with this configuration." - /> - } - checked={agentCountChecked} - onChange={e => setAgentCountChecked(e.target.checked)} - /> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - ) : null} - - <EuiFlexItem> - <EuiFlexGroup justifyContent="flexEnd"> - <EuiFlexItem grow={false}> - <EuiButtonEmpty href={cancelUrl}> - <FormattedMessage - id="xpack.ingestManager.createDatasource.cancelLinkText" - defaultMessage="Cancel" - /> - </EuiButtonEmpty> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButton - fill - onClick={() => onSubmit()} - isLoading={isSubmitLoading} - disabled={isSubmitLoading || Boolean(showAgentDisclaimer && !agentCountChecked)} - > - <FormattedMessage - id="xpack.ingestManager.createDatasource.addDatasourceButtonText" - defaultMessage="Add datasource to configuration" - /> - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - ); -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_config.tsx index 2ddfc170069a1..6cbe56e628903 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_config.tsx @@ -6,19 +6,8 @@ import React, { useEffect, useState, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiButtonEmpty, - EuiButton, - EuiFlexGroup, - EuiFlexItem, - EuiTitle, - EuiSelectable, - EuiSpacer, - EuiTextColor, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSelectable, EuiSpacer, EuiTextColor } from '@elastic/eui'; import { Error } from '../../../components'; -import { AGENT_CONFIG_PATH } from '../../../constants'; -import { useCapabilities, useLink } from '../../../hooks'; import { AgentConfig, PackageInfo, GetAgentConfigsResponseItem } from '../../../types'; import { useGetPackageInfoByKey, useGetAgentConfigs, sendGetOneAgentConfig } from '../../../hooks'; @@ -27,20 +16,13 @@ export const StepSelectConfig: React.FunctionComponent<{ updatePackageInfo: (packageInfo: PackageInfo | undefined) => void; agentConfig: AgentConfig | undefined; updateAgentConfig: (config: AgentConfig | undefined) => void; - cancelUrl: string; - onNext: () => void; -}> = ({ pkgkey, updatePackageInfo, agentConfig, updateAgentConfig, cancelUrl, onNext }) => { - const hasWriteCapabilites = useCapabilities().write; +}> = ({ pkgkey, updatePackageInfo, agentConfig, updateAgentConfig }) => { // Selected config state const [selectedConfigId, setSelectedConfigId] = useState<string | undefined>( agentConfig ? agentConfig.id : undefined ); - const [selectedConfigLoading, setSelectedConfigLoading] = useState<boolean>(false); const [selectedConfigError, setSelectedConfigError] = useState<Error>(); - // Todo: replace with create agent config flyout - const CREATE_NEW_CONFIG_URI = useLink(AGENT_CONFIG_PATH); - // Fetch package info const { data: packageInfoData, error: packageInfoError } = useGetPackageInfoByKey(pkgkey); @@ -70,9 +52,7 @@ export const StepSelectConfig: React.FunctionComponent<{ useEffect(() => { const fetchAgentConfigInfo = async () => { if (selectedConfigId) { - setSelectedConfigLoading(true); const { data, error } = await sendGetOneAgentConfig(selectedConfigId); - setSelectedConfigLoading(false); if (error) { setSelectedConfigError(error); updateAgentConfig(undefined); @@ -122,33 +102,6 @@ export const StepSelectConfig: React.FunctionComponent<{ return ( <EuiFlexGroup direction="column"> - <EuiFlexItem> - <EuiFlexGroup justifyContent="spaceBetween" alignItems="center"> - <EuiFlexItem grow={false}> - <EuiTitle size="s"> - <h3> - <FormattedMessage - id="xpack.ingestManager.createDatasource.StepSelectConfig.selectAgentConfigTitle" - defaultMessage="Select an agent configuration" - /> - </h3> - </EuiTitle> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButtonEmpty - isDisabled={!hasWriteCapabilites} - iconType="plusInCircle" - href={CREATE_NEW_CONFIG_URI} - size="s" - > - <FormattedMessage - id="xpack.ingestManager.createDatasource.StepSelectConfig.createNewConfigButtonText" - defaultMessage="Create new configuration" - /> - </EuiButtonEmpty> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> <EuiFlexItem> <EuiSelectable searchable @@ -227,33 +180,6 @@ export const StepSelectConfig: React.FunctionComponent<{ /> </EuiFlexItem> ) : null} - <EuiFlexItem> - <EuiFlexGroup justifyContent="flexEnd"> - <EuiFlexItem grow={false}> - <EuiButtonEmpty href={cancelUrl}> - <FormattedMessage - id="xpack.ingestManager.createDatasource.cancelLinkText" - defaultMessage="Cancel" - /> - </EuiButtonEmpty> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButton - fill - iconType="arrowRight" - iconSide="right" - isLoading={selectedConfigLoading} - disabled={!selectedConfigId || !!selectedConfigError || selectedConfigLoading} - onClick={() => onNext()} - > - <FormattedMessage - id="xpack.ingestManager.createDatasource.continueButtonText" - defaultMessage="Continue" - /> - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> </EuiFlexGroup> ); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx index 496e1d3c0fd7b..8dabb3bc98110 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx @@ -6,15 +6,7 @@ import React, { useEffect, useState, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiButtonEmpty, - EuiButton, - EuiFlexGroup, - EuiFlexItem, - EuiTitle, - EuiSelectable, - EuiSpacer, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSelectable, EuiSpacer } from '@elastic/eui'; import { Error } from '../../../components'; import { AgentConfig, PackageInfo } from '../../../types'; import { useGetOneAgentConfig, useGetPackages, sendGetPackageInfoByKey } from '../../../hooks'; @@ -25,14 +17,11 @@ export const StepSelectPackage: React.FunctionComponent<{ updateAgentConfig: (config: AgentConfig | undefined) => void; packageInfo?: PackageInfo; updatePackageInfo: (packageInfo: PackageInfo | undefined) => void; - cancelUrl: string; - onNext: () => void; -}> = ({ agentConfigId, updateAgentConfig, packageInfo, updatePackageInfo, cancelUrl, onNext }) => { +}> = ({ agentConfigId, updateAgentConfig, packageInfo, updatePackageInfo }) => { // Selected package state const [selectedPkgKey, setSelectedPkgKey] = useState<string | undefined>( packageInfo ? `${packageInfo.name}-${packageInfo.version}` : undefined ); - const [selectedPkgLoading, setSelectedPkgLoading] = useState<boolean>(false); const [selectedPkgError, setSelectedPkgError] = useState<Error>(); // Fetch agent config info @@ -57,9 +46,7 @@ export const StepSelectPackage: React.FunctionComponent<{ useEffect(() => { const fetchPackageInfo = async () => { if (selectedPkgKey) { - setSelectedPkgLoading(true); const { data, error } = await sendGetPackageInfoByKey(selectedPkgKey); - setSelectedPkgLoading(false); if (error) { setSelectedPkgError(error); updatePackageInfo(undefined); @@ -109,16 +96,6 @@ export const StepSelectPackage: React.FunctionComponent<{ return ( <EuiFlexGroup direction="column"> - <EuiFlexItem> - <EuiTitle size="s"> - <h3> - <FormattedMessage - id="xpack.ingestManager.createDatasource.stepSelectPackage.selectPackageTitle" - defaultMessage="Select integration" - /> - </h3> - </EuiTitle> - </EuiFlexItem> <EuiFlexItem> <EuiSelectable searchable @@ -186,33 +163,6 @@ export const StepSelectPackage: React.FunctionComponent<{ /> </EuiFlexItem> ) : null} - <EuiFlexItem> - <EuiFlexGroup justifyContent="flexEnd"> - <EuiFlexItem grow={false}> - <EuiButtonEmpty href={cancelUrl}> - <FormattedMessage - id="xpack.ingestManager.createDatasource.cancelLinkText" - defaultMessage="Cancel" - /> - </EuiButtonEmpty> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButton - fill - iconType="arrowRight" - iconSide="right" - isLoading={selectedPkgLoading} - disabled={!selectedPkgKey || !!selectedPkgError || selectedPkgLoading} - onClick={() => onNext()} - > - <FormattedMessage - id="xpack.ingestManager.createDatasource.continueButtonText" - defaultMessage="Continue" - /> - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> </EuiFlexGroup> ); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/index.tsx index 48986481b6061..fbc00fbadcfaa 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/index.tsx @@ -7,7 +7,7 @@ export { useLinks } from './use_links'; export { useLocalSearch, searchIdField } from './use_local_search'; export { PackageInstallProvider, - useDeletePackage, + useUninstallPackage, useGetPackageInstallStatus, useInstallPackage, useSetPackageInstallStatus, diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/use_package_install.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/use_package_install.tsx index 537a2616f1786..0c5f45cdc47a7 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/use_package_install.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/use_package_install.tsx @@ -6,8 +6,8 @@ import createContainer from 'constate'; import React, { useCallback, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { NotificationsStart } from 'src/core/public'; -import { useLinks } from '.'; import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; import { PackageInfo } from '../../../types'; import { sendInstallPackage, sendRemovePackage } from '../../../hooks'; @@ -25,7 +25,6 @@ type InstallPackageProps = Pick<PackageInfo, 'name' | 'version' | 'title'>; function usePackageInstall({ notifications }: { notifications: NotificationsStart }) { const [packages, setPackage] = useState<PackagesInstall>({}); - const { toDetailView } = useLinks(); const setPackageInstallStatus = useCallback( ({ name, status }: { name: PackageInfo['name']; status: InstallStatus }) => { @@ -46,34 +45,43 @@ function usePackageInstall({ notifications }: { notifications: NotificationsStar if (res.error) { setPackageInstallStatus({ name, status: InstallStatus.notInstalled }); notifications.toasts.addWarning({ - title: `Failed to install ${title} package`, - text: - 'Something went wrong while trying to install this package. Please try again later.', + title: toMountPoint( + <FormattedMessage + id="xpack.ingestManager.integrations.packageInstallErrorTitle" + defaultMessage="Failed to install {title} package" + values={{ title }} + /> + ), + text: toMountPoint( + <FormattedMessage + id="xpack.ingestManager.integrations.packageInstallErrorDescription" + defaultMessage="Something went wrong while trying to install this package. Please try again later." + /> + ), iconType: 'alert', }); } else { setPackageInstallStatus({ name, status: InstallStatus.installed }); - const SuccessMsg = <p>Successfully installed {name}</p>; notifications.toasts.addSuccess({ - title: `Installed ${title} package`, - text: toMountPoint(SuccessMsg), + title: toMountPoint( + <FormattedMessage + id="xpack.ingestManager.integrations.packageInstallSuccessTitle" + defaultMessage="Installed {title}" + values={{ title }} + /> + ), + text: toMountPoint( + <FormattedMessage + id="xpack.ingestManager.integrations.packageInstallSuccessDescription" + defaultMessage="Successfully installed {title}" + values={{ title }} + /> + ), }); - - // TODO: this should probably live somewhere else and use <Redirect />, - // this hook could return the request state and a component could - // use that state. the component should be able to unsubscribe to prevent memory leaks - const packageUrl = toDetailView({ name, version }); - const dataSourcesUrl = toDetailView({ - name, - version, - panel: 'data-sources', - withAppRoot: false, - }); - if (window.location.href.includes(packageUrl)) window.location.hash = dataSourcesUrl; } }, - [notifications.toasts, setPackageInstallStatus, toDetailView] + [notifications.toasts, setPackageInstallStatus] ); const getPackageInstallStatus = useCallback( @@ -83,7 +91,7 @@ function usePackageInstall({ notifications }: { notifications: NotificationsStar [packages] ); - const deletePackage = useCallback( + const uninstallPackage = useCallback( async ({ name, version, title }: Pick<PackageInfo, 'name' | 'version' | 'title'>) => { setPackageInstallStatus({ name, status: InstallStatus.uninstalling }); const pkgkey = `${name}-${version}`; @@ -92,30 +100,43 @@ function usePackageInstall({ notifications }: { notifications: NotificationsStar if (res.error) { setPackageInstallStatus({ name, status: InstallStatus.installed }); notifications.toasts.addWarning({ - title: `Failed to delete ${title} package`, - text: 'Something went wrong while trying to delete this package. Please try again later.', + title: toMountPoint( + <FormattedMessage + id="xpack.ingestManager.integrations.packageUninstallErrorTitle" + defaultMessage="Failed to uninstall {title} package" + values={{ title }} + /> + ), + text: toMountPoint( + <FormattedMessage + id="xpack.ingestManager.integrations.packageUninstallErrorDescription" + defaultMessage="Something went wrong while trying to uninstall this package. Please try again later." + /> + ), iconType: 'alert', }); } else { setPackageInstallStatus({ name, status: InstallStatus.notInstalled }); - const SuccessMsg = <p>Successfully deleted {title}</p>; - notifications.toasts.addSuccess({ - title: `Deleted ${title} package`, - text: toMountPoint(SuccessMsg), - }); - - const packageUrl = toDetailView({ name, version }); - const dataSourcesUrl = toDetailView({ - name, - version, - panel: 'data-sources', + title: toMountPoint( + <FormattedMessage + id="xpack.ingestManager.integrations.packageUninstallSuccessTitle" + defaultMessage="Uninstalled {title}" + values={{ title }} + /> + ), + text: toMountPoint( + <FormattedMessage + id="xpack.ingestManager.integrations.packageUninstallSuccessDescription" + defaultMessage="Successfully uninstalled {title}" + values={{ title }} + /> + ), }); - if (window.location.href.includes(packageUrl)) window.location.href = dataSourcesUrl; } }, - [notifications.toasts, setPackageInstallStatus, toDetailView] + [notifications.toasts, setPackageInstallStatus] ); return { @@ -123,7 +144,7 @@ function usePackageInstall({ notifications }: { notifications: NotificationsStar installPackage, setPackageInstallStatus, getPackageInstallStatus, - deletePackage, + uninstallPackage, }; } @@ -132,11 +153,11 @@ export const [ useInstallPackage, useSetPackageInstallStatus, useGetPackageInstallStatus, - useDeletePackage, + useUninstallPackage, ] = createContainer( usePackageInstall, value => value.installPackage, value => value.setPackageInstallStatus, value => value.getPackageInstallStatus, - value => value.deletePackage + value => value.uninstallPackage ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_delete.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_delete.tsx deleted file mode 100644 index 2b3be04ac476b..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_delete.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { EuiCallOut, EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; -import React from 'react'; - -interface ConfirmPackageDeleteProps { - onCancel: () => void; - onConfirm: () => void; - packageName: string; - numOfAssets: number; -} -export const ConfirmPackageDelete = (props: ConfirmPackageDeleteProps) => { - const { onCancel, onConfirm, packageName, numOfAssets } = props; - return ( - <EuiOverlayMask> - <EuiConfirmModal - title={`Delete ${packageName}?`} - onCancel={onCancel} - onConfirm={onConfirm} - cancelButtonText="Cancel" - confirmButtonText="Delete package" - defaultFocusedButton="confirm" - buttonColor="danger" - > - <EuiCallOut title={`This package will delete ${numOfAssets} assets.`} color="danger" /> - </EuiConfirmModal> - </EuiOverlayMask> - ); -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_install.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_install.tsx index 137d9cf226b4d..ac30815a941ee 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_install.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_install.tsx @@ -5,6 +5,7 @@ */ import { EuiCallOut, EuiConfirmModal, EuiOverlayMask, EuiSpacer } from '@elastic/eui'; import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; interface ConfirmPackageInstallProps { onCancel: () => void; @@ -17,18 +18,46 @@ export const ConfirmPackageInstall = (props: ConfirmPackageInstallProps) => { return ( <EuiOverlayMask> <EuiConfirmModal - title={`Install ${packageName}?`} + title={ + <FormattedMessage + id="xpack.ingestManager.integrations.settings.confirmInstallModal.installTitle" + defaultMessage="Install {packageName}" + values={{ packageName }} + /> + } onCancel={onCancel} onConfirm={onConfirm} - cancelButtonText="Cancel" - confirmButtonText="Install package" + cancelButtonText={ + <FormattedMessage + id="xpack.ingestManager.integrations.settings.confirmInstallModal.cancelButtonLabel" + defaultMessage="Cancel" + /> + } + confirmButtonText={ + <FormattedMessage + id="xpack.ingestManager.integrations.settings.confirmInstallModal.installButtonLabel" + defaultMessage="Install {packageName}" + values={{ packageName }} + /> + } defaultFocusedButton="confirm" > - <EuiCallOut title={`This package will install ${numOfAssets} assets.`} /> + <EuiCallOut + iconType="iInCircle" + title={ + <FormattedMessage + id="xpack.ingestManager.integrations.settings.confirmInstallModal.installCalloutTitle" + defaultMessage="This action will install {numOfAssets} assets" + values={{ numOfAssets }} + /> + } + /> <EuiSpacer size="l" /> <p> - and will only be accessible to users who have permission to view this Space. Elasticsearch - assets are installed globally and will be accessible to all Kibana users. + <FormattedMessage + id="xpack.ingestManager.integrations.settings.confirmInstallModal.installDescription" + defaultMessage="Kibana assets will be installed in the current Space (Default) and will only be accessible to users who have permission to view this Space. Elasticsearch assets are installed globally and will be accessible to all Kibana users." + /> </p> </EuiConfirmModal> </EuiOverlayMask> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_uninstall.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_uninstall.tsx new file mode 100644 index 0000000000000..14b9bf77c3a00 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_uninstall.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 { EuiCallOut, EuiConfirmModal, EuiOverlayMask, EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface ConfirmPackageUninstallProps { + onCancel: () => void; + onConfirm: () => void; + packageName: string; + numOfAssets: number; +} +export const ConfirmPackageUninstall = (props: ConfirmPackageUninstallProps) => { + const { onCancel, onConfirm, packageName, numOfAssets } = props; + return ( + <EuiOverlayMask> + <EuiConfirmModal + title={ + <FormattedMessage + id="xpack.ingestManager.integrations.settings.confirmUninstallModal.uninstallTitle" + defaultMessage="Uninstall {packageName}" + values={{ packageName }} + /> + } + onCancel={onCancel} + onConfirm={onConfirm} + cancelButtonText={ + <FormattedMessage + id="xpack.ingestManager.integrations.settings.confirmUninstallModal.cancelButtonLabel" + defaultMessage="Cancel" + /> + } + confirmButtonText={ + <FormattedMessage + id="xpack.ingestManager.integrations.settings.confirmUninstallModal.uninstallButtonLabel" + defaultMessage="Uninstall {packageName}" + values={{ packageName }} + /> + } + defaultFocusedButton="confirm" + buttonColor="danger" + > + <EuiCallOut + color="danger" + title={ + <FormattedMessage + id="xpack.ingestManager.integrations.settings.confirmUninstallModal.uninstallCallout.title" + defaultMessage="This action will remove {numOfAssets} assets" + values={{ numOfAssets }} + /> + } + > + <p> + <FormattedMessage + id="xpack.ingestManager.integrations.settings.confirmUninstallModal.uninstallCallout.description" + defaultMessage="Kibana and Elasticsearch assets that were created by this Integration will be removed. Agents configurations and any data sent by your agents will not be effected." + /> + </p> + </EuiCallOut> + <EuiSpacer size="l" /> + <p> + <FormattedMessage + id="xpack.ingestManager.integrations.settings.confirmUninstallModal.uninstallDescription" + defaultMessage="This action cannot be undone. Are you sure you wish to continue?" + /> + </p> + </EuiConfirmModal> + </EuiOverlayMask> + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/content.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/content.tsx index 384cbbeed378e..0d4b395895322 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/content.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/content.tsx @@ -15,6 +15,7 @@ import { CenterColumn, LeftColumn, RightColumn } from './layout'; import { OverviewPanel } from './overview_panel'; import { SideNavLinks } from './side_nav_links'; import { DataSourcesPanel } from './data_sources_panel'; +import { SettingsPanel } from './settings_panel'; type ContentProps = PackageInfo & Pick<DetailParams, 'panel'> & { hasIconPanel: boolean }; export function Content(props: ContentProps) { @@ -49,8 +50,10 @@ export function Content(props: ContentProps) { type ContentPanelProps = PackageInfo & Pick<DetailParams, 'panel'>; export function ContentPanel(props: ContentPanelProps) { - const { panel, name, version } = props; + const { panel, name, version, assets, title } = props; switch (panel) { + case 'settings': + return <SettingsPanel name={name} version={version} assets={assets} title={title} />; case 'data-sources': return <DataSourcesPanel name={name} version={version} />; case 'overview': diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/installation_button.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/installation_button.tsx index 8a8afed5570ed..cbbf1ce53c4ea 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/installation_button.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/installation_button.tsx @@ -5,21 +5,21 @@ */ import { EuiButton } from '@elastic/eui'; import React, { Fragment, useCallback, useMemo, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { PackageInfo, InstallStatus } from '../../../../types'; import { useCapabilities } from '../../../../hooks'; -import { useDeletePackage, useGetPackageInstallStatus, useInstallPackage } from '../../hooks'; -import { ConfirmPackageDelete } from './confirm_package_delete'; +import { useUninstallPackage, useGetPackageInstallStatus, useInstallPackage } from '../../hooks'; +import { ConfirmPackageUninstall } from './confirm_package_uninstall'; import { ConfirmPackageInstall } from './confirm_package_install'; -interface InstallationButtonProps { - package: PackageInfo; -} - +type InstallationButtonProps = Pick<PackageInfo, 'assets' | 'name' | 'title' | 'version'> & { + disabled: boolean; +}; export function InstallationButton(props: InstallationButtonProps) { - const { assets, name, title, version } = props.package; + const { assets, name, title, version, disabled = true } = props; const hasWriteCapabilites = useCapabilities().write; const installPackage = useInstallPackage(); - const deletePackage = useDeletePackage(); + const uninstallPackage = useUninstallPackage(); const getPackageInstallStatus = useGetPackageInstallStatus(); const installationStatus = getPackageInstallStatus(name); @@ -36,11 +36,12 @@ export function InstallationButton(props: InstallationButtonProps) { toggleModal(); }, [installPackage, name, title, toggleModal, version]); - const handleClickDelete = useCallback(() => { - deletePackage({ name, version, title }); + const handleClickUninstall = useCallback(() => { + uninstallPackage({ name, version, title }); toggleModal(); - }, [deletePackage, name, title, toggleModal, version]); + }, [uninstallPackage, name, title, toggleModal, version]); + // counts the number of assets in the package const numOfAssets = useMemo( () => Object.entries(assets).reduce( @@ -56,30 +57,68 @@ export function InstallationButton(props: InstallationButtonProps) { ); const installButton = ( - <EuiButton isLoading={isInstalling} fill={true} onClick={toggleModal}> - {isInstalling ? 'Installing' : 'Install package'} + <EuiButton iconType={'importAction'} isLoading={isInstalling} onClick={toggleModal}> + {isInstalling ? ( + <FormattedMessage + id="xpack.ingestManager.integrations.installPackage.installingPackageButtonLabel" + defaultMessage="Installing {title} assets" + values={{ + title, + }} + /> + ) : ( + <FormattedMessage + id="xpack.ingestManager.integrations.installPackage.installPackageButtonLabel" + defaultMessage="Install {title} assets" + values={{ + title, + }} + /> + )} </EuiButton> ); - const installedButton = ( - <EuiButton isLoading={isRemoving} fill={true} onClick={toggleModal} color="danger"> - {isInstalling ? 'Deleting' : 'Delete package'} + const uninstallButton = ( + <EuiButton + iconType={'trash'} + isLoading={isRemoving} + onClick={toggleModal} + color="danger" + disabled={disabled || isRemoving ? true : false} + > + {isRemoving ? ( + <FormattedMessage + id="xpack.ingestManager.integrations.uninstallPackage.uninstallingPackageButtonLabel" + defaultMessage="Uninstalling {title}" + values={{ + title, + }} + /> + ) : ( + <FormattedMessage + id="xpack.ingestManager.integrations.uninstallPackage.uninstallPackageButtonLabel" + defaultMessage="Uninstall {title}" + values={{ + title, + }} + /> + )} </EuiButton> ); - const deletionModal = ( - <ConfirmPackageDelete + const uninstallModal = ( + <ConfirmPackageUninstall // this is number of which would be installed // deleted includes ingest-pipelines etc so could be larger // not sure how to do this at the moment so using same value numOfAssets={numOfAssets} packageName={title} onCancel={toggleModal} - onConfirm={handleClickDelete} + onConfirm={handleClickUninstall} /> ); - const installationModal = ( + const installModal = ( <ConfirmPackageInstall numOfAssets={numOfAssets} packageName={title} @@ -90,8 +129,8 @@ export function InstallationButton(props: InstallationButtonProps) { return hasWriteCapabilites ? ( <Fragment> - {isInstalled ? installedButton : installButton} - {isModalVisible && (isInstalled ? deletionModal : installationModal)} + {isInstalled || isRemoving ? uninstallButton : installButton} + {isModalVisible && (isInstalled ? uninstallModal : installModal)} </Fragment> ) : null; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/settings_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/settings_panel.tsx new file mode 100644 index 0000000000000..ff7ecf97714b6 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/settings_panel.tsx @@ -0,0 +1,118 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; +import { useGetPackageInstallStatus } from '../../hooks'; +import { InstallStatus, PackageInfo } from '../../../../types'; +import { InstallationButton } from './installation_button'; +import { useGetDatasources } from '../../../../hooks'; + +export const SettingsPanel = ( + props: Pick<PackageInfo, 'assets' | 'name' | 'title' | 'version'> +) => { + const getPackageInstallStatus = useGetPackageInstallStatus(); + const { data: datasourcesData } = useGetDatasources({ + perPage: 0, + page: 1, + kuery: `datasources.package.name:${props.name}`, + }); + const { name, title } = props; + const packageInstallStatus = getPackageInstallStatus(name); + const packageHasDatasources = !!datasourcesData?.total; + + return ( + <EuiText> + <EuiTitle> + <h3> + <FormattedMessage + id="xpack.ingestManager.integrations.settings.packageSettingsTitle" + defaultMessage="Settings" + /> + </h3> + </EuiTitle> + <EuiSpacer size="s" /> + {packageInstallStatus === InstallStatus.notInstalled || + packageInstallStatus === InstallStatus.installing ? ( + <div> + <EuiTitle> + <h4> + <FormattedMessage + id="xpack.ingestManager.integrations.settings.packageInstallTitle" + defaultMessage="Install {title}" + values={{ + title, + }} + /> + </h4> + </EuiTitle> + <EuiSpacer size="s" /> + <p> + <FormattedMessage + id="xpack.ingestManager.integrations.settings.packageInstallDescription" + defaultMessage="Install this integration to setup Kibana and Elasticsearch assets designed for {title} data." + values={{ + title, + }} + /> + </p> + </div> + ) : ( + <div> + <EuiTitle> + <h4> + <FormattedMessage + id="xpack.ingestManager.integrations.settings.packageUninstallTitle" + defaultMessage="Uninstall {title}" + values={{ + title, + }} + /> + </h4> + </EuiTitle> + <EuiSpacer size="s" /> + <p> + <FormattedMessage + id="xpack.ingestManager.integrations.settings.packageUninstallDescription" + defaultMessage="Remove Kibana and Elasticsearch assets that were installed by this Integration." + /> + </p> + </div> + )} + <EuiFlexGroup> + <EuiFlexItem grow={false}> + <p> + <InstallationButton + {...props} + disabled={!datasourcesData ? true : packageHasDatasources} + /> + </p> + </EuiFlexItem> + </EuiFlexGroup> + {packageHasDatasources && ( + <p> + <FormattedMessage + id="xpack.ingestManager.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteDetail" + defaultMessage="{strongNote} {title} cannot be uninstalled because there are active agents that use this integration. To uninstall, remove all {title} data sources from your agent configurations." + values={{ + title, + strongNote: ( + <strong> + <FormattedMessage + id="xpack.ingestManager.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteLabel" + defaultMessage="Note:" + /> + </strong> + ), + }} + /> + </p> + )} + </EuiText> + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/side_nav_links.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/side_nav_links.tsx index 39a6fca2e4318..05729ccfc1af4 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/side_nav_links.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/side_nav_links.tsx @@ -17,6 +17,7 @@ export type NavLinkProps = Pick<PackageInfo, 'name' | 'version'> & { const PanelDisplayNames: Record<DetailViewPanelName, string> = { overview: 'Overview', 'data-sources': 'Data Sources', + settings: 'Settings', }; export function SideNavLinks({ name, version, active }: NavLinkProps) { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx index 1bc20c2baf660..a0244c601cd96 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx @@ -86,7 +86,7 @@ export const EnrollmentInstructions: React.FunctionComponent<Props> = ({ selecte steps={[ { title: i18n.translate('xpack.ingestManager.agentEnrollment.stepSetupAgents', { - defaultMessage: 'Setup Beats agent', + defaultMessage: 'Setup Elastic agent', }), children: ( <ShellEnrollmentInstructions diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx index 22314b6231d1e..d363c472f2305 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx @@ -364,7 +364,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { <h2> <FormattedMessage id="xpack.ingestManager.agentList.noAgentsPrompt" - defaultMessage="No agents installed" + defaultMessage="No agents enrolled" /> </h2> } @@ -373,7 +373,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { <EuiButton fill iconType="plusInCircle" onClick={() => setIsEnrollmentFlyoutOpen(true)}> <FormattedMessage id="xpack.ingestManager.agentList.addButton" - defaultMessage="Install new agent" + defaultMessage="Enroll new agent" /> </EuiButton> ) : null diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/enrollment_token_list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/enrollment_token_list_page/index.tsx index e4f7202aeee10..7520f88215efe 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/enrollment_token_list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/enrollment_token_list_page/index.tsx @@ -72,7 +72,7 @@ const ApiKeyField: React.FunctionComponent<{ apiKeyId: string }> = ({ apiKeyId } {key} </EuiText> ) : ( - <EuiText color="subdued">••••••••••••••••••••••••••</EuiText> + <EuiText color="subdued">•••••••••••••••••••••</EuiText> )} </EuiFlexItem> <EuiFlexItem grow={false}> @@ -151,11 +151,10 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { defaultMessage: 'Name', }), truncateText: true, - width: '300px', textOnly: true, render: (name: string) => { return ( - <EuiText style={NO_WRAP_TRUNCATE_STYLE} title={name}> + <EuiText size="s" style={NO_WRAP_TRUNCATE_STYLE} title={name}> {name} </EuiText> ); @@ -166,7 +165,7 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { name: i18n.translate('xpack.ingestManager.enrollmentTokensList.secretTitle', { defaultMessage: 'Secret', }), - width: '245px', + width: '215px', render: (apiKeyId: string) => { return <ApiKeyField apiKeyId={apiKeyId} />; }, @@ -186,7 +185,7 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { name: i18n.translate('xpack.ingestManager.enrollmentTokensList.createdAtTitle', { defaultMessage: 'Created on', }), - width: '200px', + width: '150px', render: (createdAt: string) => { return createdAt ? ( <FormattedDate year="numeric" month="short" day="2-digit" value={createdAt} /> @@ -198,7 +197,7 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { name: i18n.translate('xpack.ingestManager.enrollmentTokensList.activeTitle', { defaultMessage: 'Active', }), - width: '80px', + width: '70px', render: (active: boolean) => { return ( <EuiText textAlign="center"> @@ -212,7 +211,7 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { name: i18n.translate('xpack.ingestManager.enrollmentTokensList.actionsTitle', { defaultMessage: 'Actions', }), - width: '100px', + width: '70px', render: (_: any, apiKey: EnrollmentAPIKey) => { return ( apiKey.active && ( diff --git a/x-pack/plugins/ingest_manager/server/index.ts b/x-pack/plugins/ingest_manager/server/index.ts index df7c3d7cf0fbf..7859c44ccfd89 100644 --- a/x-pack/plugins/ingest_manager/server/index.ts +++ b/x-pack/plugins/ingest_manager/server/index.ts @@ -7,6 +7,9 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { PluginInitializerContext } from 'src/core/server'; import { IngestManagerPlugin } from './plugin'; +export { ESIndexPatternService } from './services'; +export { IngestManagerSetupContract } from './plugin'; + export const config = { exposeToBrowser: { epm: true, diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index 45c847fe1f68a..4dd070a7414f0 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -11,7 +11,9 @@ import { Plugin, PluginInitializerContext, SavedObjectsServiceStart, -} from 'src/core/server'; + RecursiveReadonly, +} from 'kibana/server'; +import { deepFreeze } from '../../../../src/core/utils'; import { LicensingPluginSetup } from '../../licensing/server'; import { EncryptedSavedObjectsPluginStart } from '../../encrypted_saved_objects/server'; import { SecurityPluginSetup } from '../../security/server'; @@ -38,7 +40,18 @@ import { } from './routes'; import { IngestManagerConfigType } from '../common'; -import { appContextService } from './services'; +import { + appContextService, + ESIndexPatternService, + ESIndexPatternSavedObjectService, +} from './services'; + +/** + * Describes public IngestManager plugin contract returned at the `setup` stage. + */ +export interface IngestManagerSetupContract { + esIndexPatternService: ESIndexPatternService; +} export interface IngestManagerSetupDeps { licensing: LicensingPluginSetup; @@ -63,7 +76,7 @@ const allSavedObjectTypes = [ ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, ]; -export class IngestManagerPlugin implements Plugin { +export class IngestManagerPlugin implements Plugin<IngestManagerSetupContract> { private config$: Observable<IngestManagerConfigType>; private security: SecurityPluginSetup | undefined; @@ -71,7 +84,10 @@ export class IngestManagerPlugin implements Plugin { this.config$ = this.initializerContext.config.create<IngestManagerConfigType>(); } - public async setup(core: CoreSetup, deps: IngestManagerSetupDeps) { + public async setup( + core: CoreSetup, + deps: IngestManagerSetupDeps + ): Promise<RecursiveReadonly<IngestManagerSetupContract>> { if (deps.security) { this.security = deps.security; } @@ -130,6 +146,9 @@ export class IngestManagerPlugin implements Plugin { basePath: core.http.basePath, }); } + return deepFreeze({ + esIndexPatternService: new ESIndexPatternSavedObjectService(), + }); } public async start( diff --git a/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts index 7ae562cf130ab..56d6053a1451b 100644 --- a/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { TypeOf } from '@kbn/config-schema'; +import Boom from 'boom'; import { RequestHandler } from 'src/core/server'; import { appContextService, datasourceService } from '../../services'; import { ensureInstalledPackage } from '../../services/epm/packages'; @@ -75,6 +76,7 @@ export const createDatasourceHandler: RequestHandler< const soClient = context.core.savedObjects.client; const callCluster = context.core.elasticsearch.adminClient.callAsCurrentUser; const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; + const newData = { ...request.body }; try { // Make sure the datasource package is installed if (request.body.package?.name) { @@ -83,10 +85,18 @@ export const createDatasourceHandler: RequestHandler< pkgName: request.body.package.name, callCluster, }); + + newData.inputs = (await datasourceService.assignPackageStream( + { + pkgName: request.body.package.name, + pkgVersion: request.body.package.version, + }, + request.body.inputs + )) as TypeOf<typeof CreateDatasourceRequestSchema.body>['inputs']; } // Create datasource - const datasource = await datasourceService.create(soClient, request.body, { user }); + const datasource = await datasourceService.create(soClient, newData, { user }); const body: CreateDatasourceResponse = { item: datasource, success: true }; return response.ok({ body, @@ -107,14 +117,33 @@ export const updateDatasourceHandler: RequestHandler< const soClient = context.core.savedObjects.client; const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; try { - const datasource = await datasourceService.update( + const datasource = await datasourceService.get(soClient, request.params.datasourceId); + + if (!datasource) { + throw Boom.notFound('Datasource not found'); + } + + const newData = { ...request.body }; + const pkg = newData.package || datasource.package; + const inputs = newData.inputs || datasource.inputs; + if (pkg && (newData.inputs || newData.package)) { + newData.inputs = (await datasourceService.assignPackageStream( + { + pkgName: pkg.name, + pkgVersion: pkg.version, + }, + inputs + )) as TypeOf<typeof CreateDatasourceRequestSchema.body>['inputs']; + } + + const updatedDatasource = await datasourceService.update( soClient, request.params.datasourceId, - request.body, + newData, { user } ); return response.ok({ - body: { item: datasource, success: true }, + body: { item: updatedDatasource, success: true }, }); } catch (e) { return response.customError({ diff --git a/x-pack/plugins/ingest_manager/server/saved_objects.ts b/x-pack/plugins/ingest_manager/server/saved_objects.ts index 6800cb4056700..dc0b4695603e4 100644 --- a/x-pack/plugins/ingest_manager/server/saved_objects.ts +++ b/x-pack/plugins/ingest_manager/server/saved_objects.ts @@ -137,6 +137,7 @@ export const savedObjectMappings = { dataset: { type: 'keyword' }, processors: { type: 'keyword' }, config: { type: 'flattened' }, + pkg_stream: { type: 'flattened' }, }, }, }, @@ -149,6 +150,10 @@ export const savedObjectMappings = { name: { type: 'keyword' }, version: { type: 'keyword' }, internal: { type: 'boolean' }, + es_index_patterns: { + dynamic: false, + type: 'object', + }, installed: { type: 'nested', properties: { diff --git a/x-pack/plugins/ingest_manager/server/services/datasource.test.ts b/x-pack/plugins/ingest_manager/server/services/datasource.test.ts new file mode 100644 index 0000000000000..09c59998388d1 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/datasource.test.ts @@ -0,0 +1,137 @@ +/* + * 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 { datasourceService } from './datasource'; + +async function mockedGetAssetsData(_a: any, _b: any, dataset: string) { + if (dataset === 'dataset1') { + return [ + { + buffer: Buffer.from(` +type: log +metricset: ["dataset1"] +paths: +{{#each paths}} +- {{this}} +{{/each}} +`), + }, + ]; + } + return []; +} + +jest.mock('./epm/packages/assets', () => { + return { + getAssetsDataForPackageKey: mockedGetAssetsData, + }; +}); + +describe('Datasource service', () => { + describe('assignPackageStream', () => { + it('should work with cofig variables from the stream', async () => { + const inputs = await datasourceService.assignPackageStream( + { + pkgName: 'package', + pkgVersion: '1.0.0', + }, + [ + { + type: 'log', + enabled: true, + streams: [ + { + id: 'dataset01', + dataset: 'package.dataset1', + enabled: true, + config: { + paths: { + value: ['/var/log/set.log'], + }, + }, + }, + ], + }, + ] + ); + + expect(inputs).toEqual([ + { + type: 'log', + enabled: true, + streams: [ + { + id: 'dataset01', + dataset: 'package.dataset1', + enabled: true, + config: { + paths: { + value: ['/var/log/set.log'], + }, + }, + pkg_stream: { + metricset: ['dataset1'], + paths: ['/var/log/set.log'], + type: 'log', + }, + }, + ], + }, + ]); + }); + + it('should work with config variables at the input level', async () => { + const inputs = await datasourceService.assignPackageStream( + { + pkgName: 'package', + pkgVersion: '1.0.0', + }, + [ + { + type: 'log', + enabled: true, + config: { + paths: { + value: ['/var/log/set.log'], + }, + }, + streams: [ + { + id: 'dataset01', + dataset: 'package.dataset1', + enabled: true, + }, + ], + }, + ] + ); + + expect(inputs).toEqual([ + { + type: 'log', + enabled: true, + config: { + paths: { + value: ['/var/log/set.log'], + }, + }, + streams: [ + { + id: 'dataset01', + dataset: 'package.dataset1', + enabled: true, + pkg_stream: { + metricset: ['dataset1'], + paths: ['/var/log/set.log'], + type: 'log', + }, + }, + ], + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/services/datasource.ts b/x-pack/plugins/ingest_manager/server/services/datasource.ts index 1b8f2a690b94d..f27252aaa9a84 100644 --- a/x-pack/plugins/ingest_manager/server/services/datasource.ts +++ b/x-pack/plugins/ingest_manager/server/services/datasource.ts @@ -4,16 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ import { SavedObjectsClientContract } from 'src/core/server'; +import { safeLoad } from 'js-yaml'; import { AuthenticatedUser } from '../../../security/server'; -import { DeleteDatasourcesResponse, packageToConfigDatasource } from '../../common'; +import { + DeleteDatasourcesResponse, + packageToConfigDatasource, + DatasourceInput, + DatasourceInputStream, +} from '../../common'; import { DATASOURCE_SAVED_OBJECT_TYPE } from '../constants'; import { NewDatasource, Datasource, ListWithKuery } from '../types'; import { agentConfigService } from './agent_config'; import { getPackageInfo, getInstallation } from './epm/packages'; import { outputService } from './output'; +import { getAssetsDataForPackageKey } from './epm/packages/assets'; +import { createStream } from './epm/agent/agent'; const SAVED_OBJECT_TYPE = DATASOURCE_SAVED_OBJECT_TYPE; +function getDataset(st: string) { + return st.split('.')[1]; +} + class DatasourceService { public async create( soClient: SavedObjectsClientContract, @@ -187,6 +199,61 @@ class DatasourceService { } } } + + public async assignPackageStream( + pkgInfo: { pkgName: string; pkgVersion: string }, + inputs: DatasourceInput[] + ): Promise<DatasourceInput[]> { + const inputsPromises = inputs.map(input => _assignPackageStreamToInput(pkgInfo, input)); + return Promise.all(inputsPromises); + } +} + +const _isAgentStream = (p: string) => !!p.match(/agent\/stream\/stream\.yml/); + +async function _assignPackageStreamToInput( + pkgInfo: { pkgName: string; pkgVersion: string }, + input: DatasourceInput +) { + const streamsPromises = input.streams.map(stream => + _assignPackageStreamToStream(pkgInfo, input, stream) + ); + + const streams = await Promise.all(streamsPromises); + return { ...input, streams }; +} + +async function _assignPackageStreamToStream( + pkgInfo: { pkgName: string; pkgVersion: string }, + input: DatasourceInput, + stream: DatasourceInputStream +) { + if (!stream.enabled) { + return { ...stream, pkg_stream: undefined }; + } + const dataset = getDataset(stream.dataset); + const assetsData = await getAssetsDataForPackageKey(pkgInfo, _isAgentStream, dataset); + + const [pkgStream] = assetsData; + if (!pkgStream || !pkgStream.buffer) { + throw new Error(`Stream template not found for dataset ${dataset}`); + } + + // Populate template variables from input config and stream config + const data: { [k: string]: string | string[] } = {}; + if (input.config) { + for (const key of Object.keys(input.config)) { + data[key] = input.config[key].value; + } + } + if (stream.config) { + for (const key of Object.keys(stream.config)) { + data[key] = stream.config[key].value; + } + } + const yaml = safeLoad(createStream(data, pkgStream.buffer.toString())); + stream.pkg_stream = yaml; + return { ...stream }; } export const datasourceService = new DatasourceService(); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts index 4f75ba0332418..21de625532f03 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts @@ -4,29 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ -import fs from 'fs'; -import * as yaml from 'js-yaml'; -import path from 'path'; -import { createInput } from './agent'; +import { createStream } from './agent'; -test('test converting input and manifest into template', () => { - const manifest = yaml.safeLoad( - fs.readFileSync(path.join(__dirname, 'tests/manifest.yml'), 'utf8') - ); +test('Test creating a stream from template', () => { + const streamTemplate = ` +input: log +paths: +{{#each paths}} + - {{this}} +{{/each}} +exclude_files: [".gz$"] +processors: + - add_locale: ~ + `; + const vars = { + paths: ['/usr/local/var/log/nginx/access.log'], + }; - const inputTemplate = fs.readFileSync(path.join(__dirname, 'tests/input.yml'), 'utf8'); - const output = createInput(manifest.vars, inputTemplate); + const output = createStream(vars, streamTemplate); - // Golden file path - const generatedFile = path.join(__dirname, './tests/input.generated.yaml'); - - // Regenerate the file if `-generate` flag is used - if (process.argv.includes('-generate')) { - fs.writeFileSync(generatedFile, output); - } - - const outputData = fs.readFileSync(generatedFile, 'utf-8'); - - // Check that content file and generated file are equal - expect(outputData).toBe(output); + expect(output).toBe(` +input: log +paths: + - /usr/local/var/log/nginx/access.log +exclude_files: [".gz$"] +processors: + - add_locale: ~ + `); }); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts index c7dd3dab38bc1..5d9a6d409aa1a 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts @@ -5,19 +5,12 @@ */ import Handlebars from 'handlebars'; -import { RegistryVarsEntry } from '../../../types'; -/** - * This takes a dataset object as input and merges it with the input template. - * It returns the resolved template as a string. - */ -export function createInput(vars: RegistryVarsEntry[], inputTemplate: string): string { - const view: Record<RegistryVarsEntry['name'], RegistryVarsEntry['default']> = {}; - - for (const v of vars) { - view[v.name] = v.default; - } +interface StreamVars { + [k: string]: string | string[]; +} - const template = Handlebars.compile(inputTemplate); - return template(view); +export function createStream(vars: StreamVars, streamTemplate: string) { + const template = Handlebars.compile(streamTemplate); + return template(vars); } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/input.generated.yaml b/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/input.generated.yaml deleted file mode 100644 index 451ed554ce259..0000000000000 --- a/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/input.generated.yaml +++ /dev/null @@ -1,5 +0,0 @@ -type: log -paths: - - "/var/log/nginx/access.log*" - -tags: nginx diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/input.yml b/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/input.yml deleted file mode 100644 index 65a23fc2fa9ad..0000000000000 --- a/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/input.yml +++ /dev/null @@ -1,7 +0,0 @@ -type: log -paths: -{{#each paths}} - - "{{this}}" -{{/each}} - -tags: {{tags}} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/manifest.yml b/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/manifest.yml deleted file mode 100644 index 46a38179fe132..0000000000000 --- a/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/manifest.yml +++ /dev/null @@ -1,20 +0,0 @@ -title: Nginx Acess Logs -release: beta -type: logs -ingest_pipeline: default - -vars: - - name: paths - # Should we define this as array? How will the UI best make sense of it? - type: textarea - default: - - /var/log/nginx/access.log* - # I suggest to use ECS fields for this config options here: https://github.com/elastic/ecs/blob/master/schemas/os.yml - # This would need to be based on a predefined definition on what can be filtered on - os.darwin: - - /usr/local/var/log/nginx/access.log* - os.windows: - - c:/programdata/nginx/logs/*access.log* - - name: tags - default: [nginx] - type: text diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts index 71c9acc6c10da..832e4772beb0d 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts @@ -136,6 +136,22 @@ export function generateTemplateName(dataset: Dataset): string { return getDatasetAssetBaseName(dataset); } +/** + * Returns a map of the dataset path fields to elasticsearch index pattern. + * @param datasets an array of Dataset objects + */ +export function generateESIndexPatterns(datasets: Dataset[] | undefined): Record<string, string> { + if (!datasets) { + return {}; + } + + const patterns: Record<string, string> = {}; + for (const dataset of datasets) { + patterns[dataset.path] = generateTemplateName(dataset) + '-*'; + } + return patterns; +} + function getBaseTemplate(type: string, templateName: string, mappings: Mappings): IndexTemplate { return { // We need to decide which order we use for the templates diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts index 7026d9eae24c3..50d347c69cc24 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts @@ -71,3 +71,13 @@ export async function getAssetsData( return entries; } + +export async function getAssetsDataForPackageKey( + { pkgName, pkgVersion }: { pkgName: string; pkgVersion: string }, + filter = (path: string): boolean => true, + datasetName?: string +): Promise<Registry.ArchiveEntry[]> { + const registryPkgInfo = await Registry.fetchInfo(pkgName, pkgVersion); + + return getAssetsData(registryPkgInfo, filter, datasetName); +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts index 0e2c2a3d26073..d76584225877c 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts @@ -67,7 +67,7 @@ export async function getPackageInfo(options: { pkgVersion: string; }): Promise<PackageInfo> { const { savedObjectsClient, pkgName, pkgVersion } = options; - const [item, savedObject] = await Promise.all([ + const [item, savedObject, assets] = await Promise.all([ Registry.fetchInfo(pkgName, pkgVersion), getInstallationObject({ savedObjectsClient, pkgName }), Registry.getArchiveInfo(pkgName, pkgVersion), @@ -80,7 +80,7 @@ export async function getPackageInfo(options: { const updated = { ...item, title: item.title || nameAsTitle(item.name), - assets: Registry.groupPathsByService(item?.assets || []), + assets: Registry.groupPathsByService(assets || []), }; return createInstallableFrom(updated, savedObject); } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts index e250b4f176819..0a7642752b3e9 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts @@ -18,6 +18,7 @@ import * as Registry from '../registry'; import { getObject } from './get_objects'; import { getInstallation } from './index'; import { installTemplates } from '../elasticsearch/template/install'; +import { generateESIndexPatterns } from '../elasticsearch/template/template'; import { installPipelines } from '../elasticsearch/ingest_pipeline/install'; import { installILMPolicy } from '../elasticsearch/ilm/install'; @@ -117,17 +118,18 @@ export async function installPackage(options: { installTemplatePromises, ]); - const toSave = res.flat(); + const toSaveAssetRefs: AssetReference[] = res.flat(); + const toSaveESIndexPatterns = generateESIndexPatterns(registryPackageInfo.datasets); // Save those references in the package manager's state saved object - await saveInstallationReferences({ + return await saveInstallationReferences({ savedObjectsClient, pkgkey, pkgName, pkgVersion, internal, - toSave, + toSaveAssetRefs, + toSaveESIndexPatterns, }); - return toSave; } // TODO: make it an exhaustive list @@ -156,25 +158,44 @@ export async function saveInstallationReferences(options: { pkgName: string; pkgVersion: string; internal: boolean; - toSave: AssetReference[]; + toSaveAssetRefs: AssetReference[]; + toSaveESIndexPatterns: Record<string, string>; }) { - const { savedObjectsClient, pkgName, pkgVersion, internal, toSave } = options; + const { + savedObjectsClient, + pkgName, + pkgVersion, + internal, + toSaveAssetRefs, + toSaveESIndexPatterns, + } = options; const installation = await getInstallation({ savedObjectsClient, pkgName }); - const savedRefs = installation?.installed || []; + const savedAssetRefs = installation?.installed || []; + const toInstallESIndexPatterns = Object.assign( + installation?.es_index_patterns || {}, + toSaveESIndexPatterns + ); + const mergeRefsReducer = (current: AssetReference[], pending: AssetReference) => { const hasRef = current.find(c => c.id === pending.id && c.type === pending.type); if (!hasRef) current.push(pending); return current; }; - const toInstall = toSave.reduce(mergeRefsReducer, savedRefs); + const toInstallAssetsRefs = toSaveAssetRefs.reduce(mergeRefsReducer, savedAssetRefs); await savedObjectsClient.create<Installation>( PACKAGES_SAVED_OBJECT_TYPE, - { installed: toInstall, name: pkgName, version: pkgVersion, internal }, + { + installed: toInstallAssetsRefs, + es_index_patterns: toInstallESIndexPatterns, + name: pkgName, + version: pkgVersion, + internal, + }, { id: pkgName, overwrite: true } ); - return toInstall; + return toInstallAssetsRefs; } async function installKibanaSavedObjects({ diff --git a/x-pack/plugins/ingest_manager/server/services/es_index_pattern.ts b/x-pack/plugins/ingest_manager/server/services/es_index_pattern.ts new file mode 100644 index 0000000000000..167e22873979c --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/es_index_pattern.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { SavedObjectsClientContract } from 'kibana/server'; +import { getInstallation } from './epm/packages/get'; + +export interface ESIndexPatternService { + getESIndexPattern( + savedObjectsClient: SavedObjectsClientContract, + pkgName: string, + datasetPath: string + ): Promise<string | undefined>; +} + +export class ESIndexPatternSavedObjectService implements ESIndexPatternService { + public async getESIndexPattern( + savedObjectsClient: SavedObjectsClientContract, + pkgName: string, + datasetPath: string + ): Promise<string | undefined> { + const installation = await getInstallation({ savedObjectsClient, pkgName }); + return installation?.es_index_patterns[datasetPath]; + } +} diff --git a/x-pack/plugins/ingest_manager/server/services/index.ts b/x-pack/plugins/ingest_manager/server/services/index.ts index dd0c898afa425..d64f1b0c2b6fb 100644 --- a/x-pack/plugins/ingest_manager/server/services/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ export { appContextService } from './app_context'; +export { ESIndexPatternService, ESIndexPatternSavedObjectService } from './es_index_pattern'; // Saved object services export { datasourceService } from './datasource'; diff --git a/x-pack/plugins/ingest_manager/server/services/setup.ts b/x-pack/plugins/ingest_manager/server/services/setup.ts index bbaf083fb8396..167a24481aba5 100644 --- a/x-pack/plugins/ingest_manager/server/services/setup.ts +++ b/x-pack/plugins/ingest_manager/server/services/setup.ts @@ -123,8 +123,18 @@ async function addPackageToConfig( pkgName: packageToInstall.name, pkgVersion: packageToInstall.version, }); - await datasourceService.create( - soClient, - packageToConfigDatasource(packageInfo, config.id, defaultOutput.id, undefined, config.namespace) + + const newDatasource = packageToConfigDatasource( + packageInfo, + config.id, + defaultOutput.id, + undefined, + config.namespace + ); + newDatasource.inputs = await datasourceService.assignPackageStream( + { pkgName: packageToInstall.name, pkgVersion: packageToInstall.version }, + newDatasource.inputs ); + + await datasourceService.create(soClient, newDatasource); } diff --git a/x-pack/plugins/lens/config.ts b/x-pack/plugins/lens/config.ts new file mode 100644 index 0000000000000..84cf02a7ea541 --- /dev/null +++ b/x-pack/plugins/lens/config.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 { schema, TypeOf } from '@kbn/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), +}); + +export type ConfigSchema = TypeOf<typeof configSchema>; diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index 6abdaad7903be..ce544b31b88ef 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -3,7 +3,15 @@ "version": "8.0.0", "kibanaVersion": "kibana", "server": true, - "ui": false, - "optionalPlugins": ["usageCollection", "taskManager"], + "ui": true, + "requiredPlugins": [ + "data", + "expressions", + "navigation", + "kibanaLegacy", + "uiActions", + "visualizations" + ], + "optionalPlugins": ["embeddable", "usageCollection", "taskManager"], "configPath": ["xpack", "lens"] } diff --git a/x-pack/legacy/plugins/lens/public/_mixins.scss b/x-pack/plugins/lens/public/_mixins.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/_mixins.scss rename to x-pack/plugins/lens/public/_mixins.scss diff --git a/x-pack/legacy/plugins/lens/public/_variables.scss b/x-pack/plugins/lens/public/_variables.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/_variables.scss rename to x-pack/plugins/lens/public/_variables.scss diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/_app.scss b/x-pack/plugins/lens/public/app_plugin/_app.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/app_plugin/_app.scss rename to x-pack/plugins/lens/public/app_plugin/_app.scss diff --git a/x-pack/plugins/lens/public/app_plugin/_index.scss b/x-pack/plugins/lens/public/app_plugin/_index.scss new file mode 100644 index 0000000000000..e72e824224956 --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/_index.scss @@ -0,0 +1 @@ +@import 'app'; diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx similarity index 94% rename from x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx rename to x-pack/plugins/lens/public/app_plugin/app.test.tsx index be72dd4b4edef..41d0e3a7aa9a0 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -9,33 +9,33 @@ import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { App } from './app'; import { EditorFrameInstance } from '../types'; -import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { Storage } from '../../../../../src/plugins/kibana_utils/public'; import { Document, SavedObjectStore } from '../persistence'; import { mount } from 'enzyme'; +import { SavedObjectSaveModal } from '../../../../../src/plugins/saved_objects/public'; import { esFilters, FilterManager, IFieldType, IIndexPattern, -} from '../../../../../../src/plugins/data/public'; -import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; +} from '../../../../../src/plugins/data/public'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; const dataStartMock = dataPluginMock.createStartContract(); -import { TopNavMenuData } from '../../../../../../src/plugins/navigation/public'; +import { navigationPluginMock } from '../../../../../src/plugins/navigation/public/mocks'; +import { TopNavMenuData } from '../../../../../src/plugins/navigation/public'; import { coreMock } from 'src/core/public/mocks'; -jest.mock('ui/new_platform'); jest.mock('../persistence'); jest.mock('src/core/public'); -import { npStart } from 'ui/new_platform'; -jest - .spyOn(npStart.plugins.navigation.ui.TopNavMenu.prototype, 'constructor') - .mockImplementation(() => { - return <div className="topNavMenu" />; - }); +const navigationStartMock = navigationPluginMock.createStartContract(); + +jest.spyOn(navigationStartMock.ui.TopNavMenu.prototype, 'constructor').mockImplementation(() => { + return <div className="topNavMenu" />; +}); -const { TopNavMenu } = npStart.plugins.navigation.ui; +const { TopNavMenu } = navigationStartMock.ui; function createMockFrame(): jest.Mocked<EditorFrameInstance> { return { @@ -99,6 +99,7 @@ describe('Lens App', () => { function makeDefaultArgs(): jest.Mocked<{ editorFrame: EditorFrameInstance; data: typeof dataStartMock; + navigation: typeof navigationStartMock; core: typeof core; storage: Storage; docId?: string; @@ -107,6 +108,7 @@ describe('Lens App', () => { addToDashboardMode?: boolean; }> { return ({ + navigation: navigationStartMock, editorFrame: createMockFrame(), core: { ...core, @@ -140,6 +142,7 @@ describe('Lens App', () => { }, redirectTo: jest.fn(id => {}), } as unknown) as jest.Mocked<{ + navigation: typeof navigationStartMock; editorFrame: EditorFrameInstance; data: typeof dataStartMock; core: typeof core; @@ -338,6 +341,7 @@ describe('Lens App', () => { let defaultArgs: jest.Mocked<{ editorFrame: EditorFrameInstance; + navigation: typeof navigationStartMock; data: typeof dataStartMock; core: typeof core; storage: Storage; @@ -647,6 +651,27 @@ describe('Lens App', () => { }, }); }); + + it('does not show the copy button on first save', async () => { + const args = defaultArgs; + args.editorFrame = frame; + + instance = mount(<App {...args} />); + + const onChange = frame.mount.mock.calls[0][1].onChange; + await act(async () => + onChange({ + filterableIndexPatterns: [], + doc: ({ expression: 'valid expression' } as unknown) as Document, + }) + ); + instance.update(); + + await act(async () => getButton(instance).run(instance.getDOMNode())); + instance.update(); + + expect(instance.find(SavedObjectSaveModal).prop('showCopyOnSave')).toEqual(false); + }); }); }); @@ -654,6 +679,7 @@ describe('Lens App', () => { let defaultArgs: jest.Mocked<{ editorFrame: EditorFrameInstance; data: typeof dataStartMock; + navigation: typeof navigationStartMock; core: typeof core; storage: Storage; docId?: string; diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx new file mode 100644 index 0000000000000..28135dd12a724 --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -0,0 +1,419 @@ +/* + * 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 _ from 'lodash'; +import React, { useState, useEffect, useCallback } from 'react'; +import { I18nProvider } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { Query, DataPublicPluginStart } from 'src/plugins/data/public'; +import { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; +import { AppMountContext, NotificationsStart } from 'kibana/public'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; +import { SavedObjectSaveModal } from '../../../../../src/plugins/saved_objects/public'; +import { Document, SavedObjectStore } from '../persistence'; +import { EditorFrameInstance } from '../types'; +import { NativeRenderer } from '../native_renderer'; +import { trackUiEvent } from '../lens_ui_telemetry'; +import { + esFilters, + Filter, + IndexPattern as IndexPatternInstance, + IndexPatternsContract, + SavedQuery, +} from '../../../../../src/plugins/data/public'; + +interface State { + isLoading: boolean; + isSaveModalVisible: boolean; + indexPatternsForTopNav: IndexPatternInstance[]; + persistedDoc?: Document; + lastKnownDoc?: Document; + + // Properties needed to interface with TopNav + dateRange: { + fromDate: string; + toDate: string; + }; + query: Query; + filters: Filter[]; + savedQuery?: SavedQuery; +} + +export function App({ + editorFrame, + data, + core, + storage, + docId, + docStorage, + redirectTo, + addToDashboardMode, + navigation, +}: { + editorFrame: EditorFrameInstance; + data: DataPublicPluginStart; + navigation: NavigationPublicPluginStart; + core: AppMountContext['core']; + storage: IStorageWrapper; + docId?: string; + docStorage: SavedObjectStore; + redirectTo: (id?: string) => void; + addToDashboardMode?: boolean; +}) { + const language = + storage.get('kibana.userQueryLanguage') || core.uiSettings.get('search:queryLanguage'); + + const [state, setState] = useState<State>(() => { + const currentRange = data.query.timefilter.timefilter.getTime(); + return { + isLoading: !!docId, + isSaveModalVisible: false, + indexPatternsForTopNav: [], + query: { query: '', language }, + dateRange: { + fromDate: currentRange.from, + toDate: currentRange.to, + }, + filters: [], + }; + }); + + const { lastKnownDoc } = state; + + useEffect(() => { + // Clear app-specific filters when navigating to Lens. Necessary because Lens + // can be loaded without a full page refresh + data.query.filterManager.setAppFilters([]); + + const filterSubscription = data.query.filterManager.getUpdates$().subscribe({ + next: () => { + setState(s => ({ ...s, filters: data.query.filterManager.getFilters() })); + trackUiEvent('app_filters_updated'); + }, + }); + + const timeSubscription = data.query.timefilter.timefilter.getTimeUpdate$().subscribe({ + next: () => { + const currentRange = data.query.timefilter.timefilter.getTime(); + setState(s => ({ + ...s, + dateRange: { + fromDate: currentRange.from, + toDate: currentRange.to, + }, + })); + }, + }); + + return () => { + filterSubscription.unsubscribe(); + timeSubscription.unsubscribe(); + }; + }, []); + + // Sync Kibana breadcrumbs any time the saved document's title changes + useEffect(() => { + core.chrome.setBreadcrumbs([ + { + href: core.http.basePath.prepend(`/app/kibana#/visualize`), + text: i18n.translate('xpack.lens.breadcrumbsTitle', { + defaultMessage: 'Visualize', + }), + }, + { + text: state.persistedDoc + ? state.persistedDoc.title + : i18n.translate('xpack.lens.breadcrumbsCreate', { defaultMessage: 'Create' }), + }, + ]); + }, [state.persistedDoc && state.persistedDoc.title]); + + useEffect(() => { + if (docId && (!state.persistedDoc || state.persistedDoc.id !== docId)) { + setState(s => ({ ...s, isLoading: true })); + docStorage + .load(docId) + .then(doc => { + getAllIndexPatterns( + doc.state.datasourceMetaData.filterableIndexPatterns, + data.indexPatterns, + core.notifications + ) + .then(indexPatterns => { + // Don't overwrite any pinned filters + data.query.filterManager.setAppFilters(doc.state.filters); + setState(s => ({ + ...s, + isLoading: false, + persistedDoc: doc, + lastKnownDoc: doc, + query: doc.state.query, + indexPatternsForTopNav: indexPatterns, + })); + }) + .catch(() => { + setState(s => ({ ...s, isLoading: false })); + + redirectTo(); + }); + }) + .catch(() => { + setState(s => ({ ...s, isLoading: false })); + + core.notifications.toasts.addDanger( + i18n.translate('xpack.lens.app.docLoadingError', { + defaultMessage: 'Error loading saved document', + }) + ); + + redirectTo(); + }); + } + }, [docId]); + + const isSaveable = + lastKnownDoc && + lastKnownDoc.expression && + lastKnownDoc.expression.length > 0 && + core.application.capabilities.visualize.save; + + const onError = useCallback( + (e: { message: string }) => + core.notifications.toasts.addDanger({ + title: e.message, + }), + [] + ); + + const { TopNavMenu } = navigation.ui; + + const confirmButton = addToDashboardMode ? ( + <FormattedMessage + id="xpack.lens.app.saveAddToDashboard" + defaultMessage="Save and add to dashboard" + /> + ) : null; + + return ( + <I18nProvider> + <KibanaContextProvider + services={{ + appName: 'lens', + data, + storage, + ...core, + }} + > + <div className="lnsApp"> + <div className="lnsApp__header"> + <TopNavMenu + config={[ + { + label: i18n.translate('xpack.lens.app.save', { + defaultMessage: 'Save', + }), + run: () => { + if (isSaveable && lastKnownDoc) { + setState(s => ({ ...s, isSaveModalVisible: true })); + } + }, + testId: 'lnsApp_saveButton', + disableButton: !isSaveable, + }, + ]} + data-test-subj="lnsApp_topNav" + screenTitle={'lens'} + onQuerySubmit={payload => { + const { dateRange, query } = payload; + + if ( + dateRange.from !== state.dateRange.fromDate || + dateRange.to !== state.dateRange.toDate + ) { + data.query.timefilter.timefilter.setTime(dateRange); + trackUiEvent('app_date_change'); + } else { + trackUiEvent('app_query_change'); + } + + setState(s => ({ + ...s, + dateRange: { + fromDate: dateRange.from, + toDate: dateRange.to, + }, + query: query || s.query, + })); + }} + appName={'lens'} + indexPatterns={state.indexPatternsForTopNav} + showSearchBar={true} + showDatePicker={true} + showQueryBar={true} + showFilterBar={true} + showSaveQuery={core.application.capabilities.visualize.saveQuery as boolean} + savedQuery={state.savedQuery} + onSaved={savedQuery => { + setState(s => ({ ...s, savedQuery })); + }} + onSavedQueryUpdated={savedQuery => { + const savedQueryFilters = savedQuery.attributes.filters || []; + const globalFilters = data.query.filterManager.getGlobalFilters(); + data.query.filterManager.setFilters([...globalFilters, ...savedQueryFilters]); + setState(s => ({ + ...s, + savedQuery: { ...savedQuery }, // Shallow query for reference issues + dateRange: savedQuery.attributes.timefilter + ? { + fromDate: savedQuery.attributes.timefilter.from, + toDate: savedQuery.attributes.timefilter.to, + } + : s.dateRange, + })); + }} + onClearSavedQuery={() => { + data.query.filterManager.setFilters(data.query.filterManager.getGlobalFilters()); + setState(s => ({ + ...s, + savedQuery: undefined, + filters: data.query.filterManager.getGlobalFilters(), + query: { + query: '', + language: + storage.get('kibana.userQueryLanguage') || + core.uiSettings.get('search:queryLanguage'), + }, + })); + }} + query={state.query} + dateRangeFrom={state.dateRange.fromDate} + dateRangeTo={state.dateRange.toDate} + /> + </div> + + {(!state.isLoading || state.persistedDoc) && ( + <NativeRenderer + className="lnsApp__frame" + render={editorFrame.mount} + nativeProps={{ + dateRange: state.dateRange, + query: state.query, + filters: state.filters, + savedQuery: state.savedQuery, + doc: state.persistedDoc, + onError, + onChange: ({ filterableIndexPatterns, doc }) => { + if (!_.isEqual(state.persistedDoc, doc)) { + setState(s => ({ ...s, lastKnownDoc: doc })); + } + + // Update the cached index patterns if the user made a change to any of them + if ( + state.indexPatternsForTopNav.length !== filterableIndexPatterns.length || + filterableIndexPatterns.find( + ({ id }) => + !state.indexPatternsForTopNav.find(indexPattern => indexPattern.id === id) + ) + ) { + getAllIndexPatterns( + filterableIndexPatterns, + data.indexPatterns, + core.notifications + ).then(indexPatterns => { + if (indexPatterns) { + setState(s => ({ ...s, indexPatternsForTopNav: indexPatterns })); + } + }); + } + }, + }} + /> + )} + </div> + {lastKnownDoc && state.isSaveModalVisible && ( + <SavedObjectSaveModal + onSave={props => { + const [pinnedFilters, appFilters] = _.partition( + lastKnownDoc.state?.filters, + esFilters.isFilterPinned + ); + const lastDocWithoutPinned = pinnedFilters?.length + ? { + ...lastKnownDoc, + state: { + ...lastKnownDoc.state, + filters: appFilters, + }, + } + : lastKnownDoc; + + const doc = { + ...lastDocWithoutPinned, + id: props.newCopyOnSave ? undefined : lastKnownDoc.id, + title: props.newTitle, + }; + + docStorage + .save(doc) + .then(({ id }) => { + // Prevents unnecessary network request and disables save button + const newDoc = { ...doc, id }; + setState(s => ({ + ...s, + isSaveModalVisible: false, + persistedDoc: newDoc, + lastKnownDoc: newDoc, + })); + if (docId !== id) { + redirectTo(id); + } + }) + .catch(e => { + // eslint-disable-next-line no-console + console.dir(e); + trackUiEvent('save_failed'); + core.notifications.toasts.addDanger( + i18n.translate('xpack.lens.app.docSavingError', { + defaultMessage: 'Error saving document', + }) + ); + setState(s => ({ ...s, isSaveModalVisible: false })); + }); + }} + onClose={() => setState(s => ({ ...s, isSaveModalVisible: false }))} + title={lastKnownDoc.title || ''} + showCopyOnSave={!!lastKnownDoc.id && !addToDashboardMode} + objectType={i18n.translate('xpack.lens.app.saveModalType', { + defaultMessage: 'Lens visualization', + })} + showDescription={false} + confirmButtonLabel={confirmButton} + /> + )} + </KibanaContextProvider> + </I18nProvider> + ); +} + +export async function getAllIndexPatterns( + ids: Array<{ id: string }>, + indexPatternsService: IndexPatternsContract, + notifications: NotificationsStart +): Promise<IndexPatternInstance[]> { + try { + return await Promise.all(ids.map(({ id }) => indexPatternsService.get(id))); + } catch (e) { + notifications.toasts.addDanger( + i18n.translate('xpack.lens.app.indexPatternLoadingError', { + defaultMessage: 'Error loading index patterns', + }) + ); + + throw new Error(e); + } +} diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/index.ts b/x-pack/plugins/lens/public/app_plugin/index.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/app_plugin/index.ts rename to x-pack/plugins/lens/public/app_plugin/index.ts diff --git a/x-pack/legacy/plugins/lens/public/assets/chart_area.svg b/x-pack/plugins/lens/public/assets/chart_area.svg similarity index 100% rename from x-pack/legacy/plugins/lens/public/assets/chart_area.svg rename to x-pack/plugins/lens/public/assets/chart_area.svg diff --git a/x-pack/legacy/plugins/lens/public/assets/chart_area_stacked.svg b/x-pack/plugins/lens/public/assets/chart_area_stacked.svg similarity index 100% rename from x-pack/legacy/plugins/lens/public/assets/chart_area_stacked.svg rename to x-pack/plugins/lens/public/assets/chart_area_stacked.svg diff --git a/x-pack/legacy/plugins/lens/public/assets/chart_bar.svg b/x-pack/plugins/lens/public/assets/chart_bar.svg similarity index 100% rename from x-pack/legacy/plugins/lens/public/assets/chart_bar.svg rename to x-pack/plugins/lens/public/assets/chart_bar.svg diff --git a/x-pack/legacy/plugins/lens/public/assets/chart_bar_horizontal.svg b/x-pack/plugins/lens/public/assets/chart_bar_horizontal.svg similarity index 100% rename from x-pack/legacy/plugins/lens/public/assets/chart_bar_horizontal.svg rename to x-pack/plugins/lens/public/assets/chart_bar_horizontal.svg diff --git a/x-pack/legacy/plugins/lens/public/assets/chart_bar_horizontal_stacked.svg b/x-pack/plugins/lens/public/assets/chart_bar_horizontal_stacked.svg similarity index 100% rename from x-pack/legacy/plugins/lens/public/assets/chart_bar_horizontal_stacked.svg rename to x-pack/plugins/lens/public/assets/chart_bar_horizontal_stacked.svg diff --git a/x-pack/legacy/plugins/lens/public/assets/chart_bar_stacked.svg b/x-pack/plugins/lens/public/assets/chart_bar_stacked.svg similarity index 100% rename from x-pack/legacy/plugins/lens/public/assets/chart_bar_stacked.svg rename to x-pack/plugins/lens/public/assets/chart_bar_stacked.svg diff --git a/x-pack/legacy/plugins/lens/public/assets/chart_datatable.svg b/x-pack/plugins/lens/public/assets/chart_datatable.svg similarity index 100% rename from x-pack/legacy/plugins/lens/public/assets/chart_datatable.svg rename to x-pack/plugins/lens/public/assets/chart_datatable.svg diff --git a/x-pack/legacy/plugins/lens/public/assets/chart_line.svg b/x-pack/plugins/lens/public/assets/chart_line.svg similarity index 100% rename from x-pack/legacy/plugins/lens/public/assets/chart_line.svg rename to x-pack/plugins/lens/public/assets/chart_line.svg diff --git a/x-pack/legacy/plugins/lens/public/assets/chart_metric.svg b/x-pack/plugins/lens/public/assets/chart_metric.svg similarity index 100% rename from x-pack/legacy/plugins/lens/public/assets/chart_metric.svg rename to x-pack/plugins/lens/public/assets/chart_metric.svg diff --git a/x-pack/legacy/plugins/lens/public/assets/chart_mixed_xy.svg b/x-pack/plugins/lens/public/assets/chart_mixed_xy.svg similarity index 100% rename from x-pack/legacy/plugins/lens/public/assets/chart_mixed_xy.svg rename to x-pack/plugins/lens/public/assets/chart_mixed_xy.svg diff --git a/x-pack/legacy/plugins/lens/public/assets/lens_app_graphic_dark_2x.png b/x-pack/plugins/lens/public/assets/lens_app_graphic_dark_2x.png similarity index 100% rename from x-pack/legacy/plugins/lens/public/assets/lens_app_graphic_dark_2x.png rename to x-pack/plugins/lens/public/assets/lens_app_graphic_dark_2x.png diff --git a/x-pack/legacy/plugins/lens/public/assets/lens_app_graphic_light_2x.png b/x-pack/plugins/lens/public/assets/lens_app_graphic_light_2x.png similarity index 100% rename from x-pack/legacy/plugins/lens/public/assets/lens_app_graphic_light_2x.png rename to x-pack/plugins/lens/public/assets/lens_app_graphic_light_2x.png diff --git a/x-pack/plugins/lens/public/datatable_visualization/_index.scss b/x-pack/plugins/lens/public/datatable_visualization/_index.scss new file mode 100644 index 0000000000000..532e8106b023f --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/_index.scss @@ -0,0 +1 @@ +@import 'visualization'; diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization/_visualization.scss b/x-pack/plugins/lens/public/datatable_visualization/_visualization.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/datatable_visualization/_visualization.scss rename to x-pack/plugins/lens/public/datatable_visualization/_visualization.scss diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx similarity index 98% rename from x-pack/legacy/plugins/lens/public/datatable_visualization/expression.tsx rename to x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 050f294d0b964..cbdc0617e8568 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -13,7 +13,7 @@ import { ExpressionFunctionDefinition, ExpressionRenderDefinition, IInterpreterRenderHandlers, -} from '../../../../../../src/plugins/expressions/public'; +} from '../../../../../src/plugins/expressions/public'; import { VisualizationContainer } from '../visualization_container'; export interface DatatableColumns { diff --git a/x-pack/plugins/lens/public/datatable_visualization/index.ts b/x-pack/plugins/lens/public/datatable_visualization/index.ts new file mode 100644 index 0000000000000..ff036aadfd4cf --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/index.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 { CoreSetup } from 'kibana/public'; +import { datatableVisualization } from './visualization'; +import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; +import { datatable, datatableColumns, getDatatableRenderer } from './expression'; +import { EditorFrameSetup, FormatFactory } from '../types'; + +export interface DatatableVisualizationPluginSetupPlugins { + expressions: ExpressionsSetup; + formatFactory: Promise<FormatFactory>; + editorFrame: EditorFrameSetup; +} + +export class DatatableVisualization { + constructor() {} + + setup( + _core: CoreSetup | null, + { expressions, formatFactory, editorFrame }: DatatableVisualizationPluginSetupPlugins + ) { + expressions.registerFunction(() => datatableColumns); + expressions.registerFunction(() => datatable); + expressions.registerRenderer(() => getDatatableRenderer(formatFactory)); + editorFrame.registerVisualization(datatableVisualization); + } +} diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/datatable_visualization/visualization.test.tsx rename to x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/datatable_visualization/visualization.tsx rename to x-pack/plugins/lens/public/datatable_visualization/visualization.tsx diff --git a/x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.test.tsx b/x-pack/plugins/lens/public/debounced_component/debounced_component.test.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.test.tsx rename to x-pack/plugins/lens/public/debounced_component/debounced_component.test.tsx diff --git a/x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.tsx b/x-pack/plugins/lens/public/debounced_component/debounced_component.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.tsx rename to x-pack/plugins/lens/public/debounced_component/debounced_component.tsx diff --git a/x-pack/legacy/plugins/lens/public/debounced_component/index.ts b/x-pack/plugins/lens/public/debounced_component/index.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/debounced_component/index.ts rename to x-pack/plugins/lens/public/debounced_component/index.ts diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap rename to x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/_drag_drop.scss b/x-pack/plugins/lens/public/drag_drop/_drag_drop.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/drag_drop/_drag_drop.scss rename to x-pack/plugins/lens/public/drag_drop/_drag_drop.scss diff --git a/x-pack/plugins/lens/public/drag_drop/_index.scss b/x-pack/plugins/lens/public/drag_drop/_index.scss new file mode 100644 index 0000000000000..ddf9b9aa3e429 --- /dev/null +++ b/x-pack/plugins/lens/public/drag_drop/_index.scss @@ -0,0 +1 @@ +@import 'drag_drop'; diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.test.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.test.tsx rename to x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.tsx rename to x-pack/plugins/lens/public/drag_drop/drag_drop.tsx diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/index.ts b/x-pack/plugins/lens/public/drag_drop/index.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/drag_drop/index.ts rename to x-pack/plugins/lens/public/drag_drop/index.ts diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/providers.test.tsx b/x-pack/plugins/lens/public/drag_drop/providers.test.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/drag_drop/providers.test.tsx rename to x-pack/plugins/lens/public/drag_drop/providers.test.tsx diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/providers.tsx b/x-pack/plugins/lens/public/drag_drop/providers.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/drag_drop/providers.tsx rename to x-pack/plugins/lens/public/drag_drop/providers.tsx diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/readme.md b/x-pack/plugins/lens/public/drag_drop/readme.md similarity index 100% rename from x-pack/legacy/plugins/lens/public/drag_drop/readme.md rename to x-pack/plugins/lens/public/drag_drop/readme.md diff --git a/x-pack/plugins/lens/public/editor_frame_service/_index.scss b/x-pack/plugins/lens/public/editor_frame_service/_index.scss new file mode 100644 index 0000000000000..199cbe35e25fa --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/_index.scss @@ -0,0 +1 @@ +@import 'editor_frame/index'; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/__mocks__/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/__mocks__/suggestion_helpers.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/__mocks__/suggestion_helpers.ts rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/__mocks__/suggestion_helpers.ts diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/_chart_switch.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_chart_switch.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/_chart_switch.scss rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/_chart_switch.scss diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/_config_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_config_panel_wrapper.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/_config_panel_wrapper.scss rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/_config_panel_wrapper.scss diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/_data_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_data_panel_wrapper.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/_data_panel_wrapper.scss rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/_data_panel_wrapper.scss diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/_expression_renderer.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_expression_renderer.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/_expression_renderer.scss rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/_expression_renderer.scss diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/_frame_layout.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_frame_layout.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/_frame_layout.scss rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/_frame_layout.scss diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/_suggestion_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_suggestion_panel.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/_suggestion_panel.scss rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/_suggestion_panel.scss diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/_workspace_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_workspace_panel_wrapper.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/_workspace_panel_wrapper.scss rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/_workspace_panel_wrapper.scss diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/chart_switch.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/chart_switch.test.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/chart_switch.test.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/chart_switch.test.tsx diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/chart_switch.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/chart_switch.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/chart_switch.tsx diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/config_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel_wrapper.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/config_panel_wrapper.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel_wrapper.tsx diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx similarity index 94% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx index 2a9499077f3c1..cd0aee732793e 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx @@ -7,12 +7,11 @@ import React, { useMemo, memo, useContext, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiPopover, EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; -import { DatasourceDataPanelProps, Datasource } from '../../../public'; import { NativeRenderer } from '../../native_renderer'; import { Action } from './state_management'; import { DragContext } from '../../drag_drop'; -import { StateSetter, FramePublicAPI } from '../../types'; -import { Query, Filter } from '../../../../../../../src/plugins/data/public'; +import { StateSetter, FramePublicAPI, DatasourceDataPanelProps, Datasource } from '../../types'; +import { Query, Filter } from '../../../../../../src/plugins/data/public'; interface DataPanelWrapperProps { datasourceState: unknown; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx similarity index 97% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index 082519d9a8feb..b72d9081bbc91 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -5,8 +5,8 @@ */ import React, { useEffect, useReducer } from 'react'; -import { CoreSetup, CoreStart } from 'src/core/public'; -import { ReactExpressionRendererType } from '../../../../../../../src/plugins/expressions/public'; +import { CoreSetup, CoreStart } from 'kibana/public'; +import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; import { Datasource, DatasourcePublicAPI, @@ -25,7 +25,7 @@ import { RootDragDropProvider } from '../../drag_drop'; import { getSavedObjectFormat } from './save'; import { WorkspacePanelWrapper } from './workspace_panel_wrapper'; import { generateId } from '../../id_generator'; -import { Filter, Query, SavedQuery } from '../../../../../../../src/plugins/data/public'; +import { Filter, Query, SavedQuery } from '../../../../../../src/plugins/data/public'; export interface EditorFrameProps { doc?: Document; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts similarity index 97% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts index d264e6d0da3ad..9d4f8587577a3 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts @@ -6,7 +6,7 @@ import { Ast, fromExpression, ExpressionFunctionAST } from '@kbn/interpreter/common'; import { Visualization, Datasource, FramePublicAPI } from '../../types'; -import { Filter, TimeRange, Query } from '../../../../../../../src/plugins/data/public'; +import { Filter, TimeRange, Query } from '../../../../../../src/plugins/data/public'; export function prependDatasourceExpression( visualizationExpression: Ast | string | null, diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.tsx diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.scss new file mode 100644 index 0000000000000..d4b27c6c98b3c --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.scss @@ -0,0 +1,8 @@ +@import 'chart_switch'; +@import 'config_panel_wrapper'; +@import 'data_panel_wrapper'; +@import 'expression_renderer'; +@import 'frame_layout'; +@import 'suggestion_panel'; +@import 'workspace_panel_wrapper'; + diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/index.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/index.ts rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.ts diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/layer_actions.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/layer_actions.test.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/layer_actions.test.ts rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/layer_actions.test.ts diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/layer_actions.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/layer_actions.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/layer_actions.ts rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/layer_actions.ts diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts similarity index 98% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts index 60bfbc493f61c..9c7ed265f3539 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts @@ -6,7 +6,7 @@ import { getSavedObjectFormat, Props } from './save'; import { createMockDatasource, createMockVisualization } from '../mocks'; -import { esFilters, IIndexPattern, IFieldType } from '../../../../../../../src/plugins/data/public'; +import { esFilters, IIndexPattern, IFieldType } from '../../../../../../src/plugins/data/public'; describe('save editor frame state', () => { const mockVisualization = createMockVisualization(); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/save.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/save.ts rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts similarity index 99% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts index 4aaf2a3ee9e81..1f62929783b63 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts @@ -5,7 +5,7 @@ */ import { getInitialState, reducer } from './state_management'; -import { EditorFrameProps } from '.'; +import { EditorFrameProps } from './index'; import { Datasource, Visualization } from '../../types'; import { createExpressionRendererMock } from '../mocks'; import { coreMock } from 'src/core/public/mocks'; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts similarity index 99% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts index 7d763bcac2cc9..bb6daf5641a64 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EditorFrameProps } from '../editor_frame'; +import { EditorFrameProps } from './index'; import { Document } from '../../persistence/saved_object_store'; export interface PreviewState { diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx similarity index 92% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx index b146f2467c46c..240bdff40b51c 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx @@ -15,7 +15,8 @@ import { createMockFramePublicAPI, } from '../mocks'; import { act } from 'react-dom/test-utils'; -import { ReactExpressionRendererType } from '../../../../../../../src/plugins/expressions/public'; +import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; +import { esFilters, IFieldType, IIndexPattern } from '../../../../../../src/plugins/data/public'; import { SuggestionPanel, SuggestionPanelProps } from './suggestion_panel'; import { getSuggestions, Suggestion } from './suggestion_helpers'; import { EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui'; @@ -243,14 +244,25 @@ describe('suggestion_panel', () => { (mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce('test | expression'); mockDatasource.toExpression.mockReturnValue('datasource_expression'); - mount(<SuggestionPanel {...defaultProps} />); + const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; + const field = ({ name: 'myfield' } as unknown) as IFieldType; + + mount( + <SuggestionPanel + {...defaultProps} + frame={{ + ...createMockFramePublicAPI(), + filters: [esFilters.buildExistsFilter(field, indexPattern)], + }} + /> + ); expect(expressionRendererMock).toHaveBeenCalledTimes(1); const passedExpression = (expressionRendererMock as jest.Mock).mock.calls[0][0].expression; expect(passedExpression).toMatchInlineSnapshot(` "kibana - | kibana_context timeRange=\\"{\\\\\\"from\\\\\\":\\\\\\"now-7d\\\\\\",\\\\\\"to\\\\\\":\\\\\\"now\\\\\\"}\\" query=\\"{\\\\\\"query\\\\\\":\\\\\\"\\\\\\",\\\\\\"language\\\\\\":\\\\\\"lucene\\\\\\"}\\" filters=\\"[]\\" + | kibana_context timeRange=\\"{\\\\\\"from\\\\\\":\\\\\\"now-7d\\\\\\",\\\\\\"to\\\\\\":\\\\\\"now\\\\\\"}\\" query=\\"{\\\\\\"query\\\\\\":\\\\\\"\\\\\\",\\\\\\"language\\\\\\":\\\\\\"lucene\\\\\\"}\\" filters=\\"[{\\\\\\"meta\\\\\\":{\\\\\\"index\\\\\\":\\\\\\"index1\\\\\\"},\\\\\\"exists\\\\\\":{\\\\\\"field\\\\\\":\\\\\\"myfield\\\\\\"}}]\\" | lens_merge_tables layerIds=\\"first\\" tables={datasource_expression} | test | expression" diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx similarity index 99% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index 93f6ea6ea67ac..867214d15578a 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -24,7 +24,7 @@ import classNames from 'classnames'; import { Action, PreviewState } from './state_management'; import { Datasource, Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../types'; import { getSuggestions, switchToSuggestion } from './suggestion_helpers'; -import { ReactExpressionRendererType } from '../../../../../../../src/plugins/expressions/public'; +import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; import { prependDatasourceExpression, prependKibanaContext } from './expression_helpers'; import { debouncedComponent } from '../../debounced_component'; import { trackUiEvent, trackSuggestionEvent } from '../../lens_ui_telemetry'; @@ -235,6 +235,7 @@ export function SuggestionPanel({ const expressionContext = { query: frame.query, + filters: frame.filters, timeRange: { from: frame.dateRange.fromDate, to: frame.dateRange.toDate, diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx similarity index 99% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx index 748e5b876da95..33ecee53fa3bc 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { ReactExpressionRendererProps } from '../../../../../../../src/plugins/expressions/public'; +import { ReactExpressionRendererProps } from '../../../../../../src/plugins/expressions/public'; import { FramePublicAPI, TableSuggestion, Visualization } from '../../types'; import { createMockVisualization, @@ -21,7 +21,7 @@ import { ReactWrapper } from 'enzyme'; import { DragDrop, ChildDragDropProvider } from '../../drag_drop'; import { Ast } from '@kbn/interpreter/common'; import { coreMock } from 'src/core/public/mocks'; -import { esFilters, IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/public'; +import { esFilters, IFieldType, IIndexPattern } from '../../../../../../src/plugins/data/public'; describe('workspace_panel', () => { let mockVisualization: jest.Mocked<Visualization>; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx similarity index 98% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx index c2a5c16e405a2..1f741ca37934f 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx @@ -16,8 +16,8 @@ import { EuiBetaBadge, EuiButtonEmpty, } from '@elastic/eui'; -import { CoreStart, CoreSetup } from 'src/core/public'; -import { ReactExpressionRendererType } from '../../../../../../../src/plugins/expressions/public'; +import { CoreStart, CoreSetup } from 'kibana/public'; +import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; import { Action } from './state_management'; import { Datasource, Visualization, FramePublicAPI } from '../../types'; import { DragDrop, DragContext } from '../../drag_drop'; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel_wrapper.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel_wrapper.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel_wrapper.tsx diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx similarity index 96% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx rename to x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx index 55363ebe4d8f3..aeae64514b0fd 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx @@ -9,9 +9,9 @@ import { Embeddable } from './embeddable'; import { ReactExpressionRendererProps } from 'src/plugins/expressions/public'; import { Query, TimeRange, Filter, TimefilterContract } from 'src/plugins/data/public'; import { Document } from '../../persistence'; -import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; -jest.mock('../../../../../../../src/plugins/inspector/public/', () => ({ +jest.mock('../../../../../../src/plugins/inspector/public/', () => ({ isAvailable: false, open: false, })); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx similarity index 94% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx rename to x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index c2ab1c72af545..0ef5f6d1a5470 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -16,15 +16,15 @@ import { } from 'src/plugins/data/public'; import { Subscription } from 'rxjs'; -import { ReactExpressionRendererType } from '../../../../../../../src/plugins/expressions/public'; -import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/plugins/visualizations/public'; +import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../../src/plugins/visualizations/public'; import { Embeddable as AbstractEmbeddable, EmbeddableOutput, IContainer, EmbeddableInput, -} from '../../../../../../../src/plugins/embeddable/public'; +} from '../../../../../../src/plugins/embeddable/public'; import { Document, DOC_TYPE } from '../../persistence'; import { ExpressionWrapper } from './expression_wrapper'; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts similarity index 91% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts rename to x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts index 99a59c756e228..68dbff263f60d 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts @@ -15,17 +15,17 @@ import { IndexPatternsContract, IndexPattern, TimefilterContract, -} from '../../../../../../../src/plugins/data/public'; -import { ReactExpressionRendererType } from '../../../../../../../src/plugins/expressions/public'; +} from '../../../../../../src/plugins/data/public'; +import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; import { EmbeddableFactoryDefinition, ErrorEmbeddable, EmbeddableInput, IContainer, -} from '../../../../../../../src/plugins/embeddable/public'; +} from '../../../../../../src/plugins/embeddable/public'; import { Embeddable } from './embeddable'; import { SavedObjectIndexStore, DOC_TYPE } from '../../persistence'; -import { getEditPath } from '../../../../../../plugins/lens/common'; +import { getEditPath } from '../../../common'; interface StartServices { timefilter: TimefilterContract; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx rename to x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/format_column.ts b/x-pack/plugins/lens/public/editor_frame_service/format_column.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/format_column.ts rename to x-pack/plugins/lens/public/editor_frame_service/format_column.ts diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/index.ts b/x-pack/plugins/lens/public/editor_frame_service/index.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/index.ts rename to x-pack/plugins/lens/public/editor_frame_service/index.ts diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/merge_tables.test.ts b/x-pack/plugins/lens/public/editor_frame_service/merge_tables.test.ts similarity index 98% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/merge_tables.test.ts rename to x-pack/plugins/lens/public/editor_frame_service/merge_tables.test.ts index 9368674de31c5..243441f2c8ab3 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/merge_tables.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/merge_tables.test.ts @@ -8,8 +8,6 @@ import moment from 'moment'; import { mergeTables } from './merge_tables'; import { KibanaDatatable } from 'src/plugins/expressions'; -jest.mock('ui/new_platform'); - describe('lens_merge_tables', () => { it('should produce a row with the nested table as defined', () => { const sampleTable1: KibanaDatatable = { diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/merge_tables.ts b/x-pack/plugins/lens/public/editor_frame_service/merge_tables.ts similarity index 96% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/merge_tables.ts rename to x-pack/plugins/lens/public/editor_frame_service/merge_tables.ts index c06640fb25de6..7c10ee4a57fad 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/merge_tables.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/merge_tables.ts @@ -10,7 +10,7 @@ import { ExpressionValueSearchContext, KibanaDatatable, } from 'src/plugins/expressions/public'; -import { search } from '../../../../../../src/plugins/data/public'; +import { search } from '../../../../../src/plugins/data/public'; const { toAbsoluteDates } = search.aggs; import { LensMultiTable } from '../types'; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx similarity index 92% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/mocks.tsx rename to x-pack/plugins/lens/public/editor_frame_service/mocks.tsx index 5d2f68a5567eb..50cd1ad8bd53a 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/mocks.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx @@ -9,12 +9,12 @@ import { ReactExpressionRendererProps, ExpressionsSetup, ExpressionsStart, -} from '../../../../../../src/plugins/expressions/public'; -import { embeddablePluginMock } from '../../../../../../src/plugins/embeddable/public/mocks'; -import { expressionsPluginMock } from '../../../../../../src/plugins/expressions/public/mocks'; +} from '../../../../../src/plugins/expressions/public'; +import { embeddablePluginMock } from '../../../../../src/plugins/embeddable/public/mocks'; +import { expressionsPluginMock } from '../../../../../src/plugins/expressions/public/mocks'; import { DatasourcePublicAPI, FramePublicAPI, Datasource, Visualization } from '../types'; import { EditorFrameSetupPlugins, EditorFrameStartPlugins } from './service'; -import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; export function createMockVisualization(): jest.Mocked<Visualization> { return { diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/service.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx similarity index 98% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/service.test.tsx rename to x-pack/plugins/lens/public/editor_frame_service/service.test.tsx index 42a1fcc055a1e..fbd65c5044d51 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/service.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx @@ -14,8 +14,6 @@ import { } from './mocks'; import { CoreSetup } from 'kibana/public'; -jest.mock('ui/new_platform'); - // mock away actual dependencies to prevent all of it being loaded jest.mock('./embeddable/embeddable_factory', () => ({ EmbeddableFactory: class Mock {}, diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.tsx similarity index 91% rename from x-pack/legacy/plugins/lens/public/editor_frame_service/service.tsx rename to x-pack/plugins/lens/public/editor_frame_service/service.tsx index 1375c60060ca8..15fe449d6563b 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.tsx @@ -7,16 +7,13 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; -import { CoreSetup, CoreStart } from 'src/core/public'; -import { - ExpressionsSetup, - ExpressionsStart, -} from '../../../../../../src/plugins/expressions/public'; -import { EmbeddableSetup, EmbeddableStart } from '../../../../../../src/plugins/embeddable/public'; +import { CoreSetup, CoreStart } from 'kibana/public'; +import { ExpressionsSetup, ExpressionsStart } from '../../../../../src/plugins/expressions/public'; +import { EmbeddableSetup, EmbeddableStart } from '../../../../../src/plugins/embeddable/public'; import { DataPublicPluginSetup, DataPublicPluginStart, -} from '../../../../../../src/plugins/data/public'; +} from '../../../../../src/plugins/data/public'; import { Datasource, Visualization, @@ -32,13 +29,13 @@ import { getActiveDatasourceIdFromDoc } from './editor_frame/state_management'; export interface EditorFrameSetupPlugins { data: DataPublicPluginSetup; - embeddable: EmbeddableSetup; + embeddable?: EmbeddableSetup; expressions: ExpressionsSetup; } export interface EditorFrameStartPlugins { data: DataPublicPluginStart; - embeddable: EmbeddableStart; + embeddable?: EmbeddableStart; expressions: ExpressionsStart; } @@ -79,7 +76,9 @@ export class EditorFrameService { }; }; - plugins.embeddable.registerEmbeddableFactory('lens', new EmbeddableFactory(getStartServices)); + if (plugins.embeddable) { + plugins.embeddable.registerEmbeddableFactory('lens', new EmbeddableFactory(getStartServices)); + } return { registerDatasource: datasource => { diff --git a/x-pack/plugins/lens/public/help_menu_util.tsx b/x-pack/plugins/lens/public/help_menu_util.tsx new file mode 100644 index 0000000000000..333a90df4731b --- /dev/null +++ b/x-pack/plugins/lens/public/help_menu_util.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 { ChromeStart, DocLinksStart } from 'kibana/public'; + +export function addHelpMenuToAppChrome(chrome: ChromeStart, docLinks: DocLinksStart) { + chrome.setHelpExtension({ + appName: 'Lens', + links: [ + { + linkType: 'documentation', + href: `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/lens.html`, + }, + { + linkType: 'github', + title: '[Lens]', + labels: ['Feature:Lens'], + }, + ], + }); +} diff --git a/x-pack/legacy/plugins/lens/public/helpers/index.ts b/x-pack/plugins/lens/public/helpers/index.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/helpers/index.ts rename to x-pack/plugins/lens/public/helpers/index.ts diff --git a/x-pack/plugins/lens/public/helpers/url_helper.test.ts b/x-pack/plugins/lens/public/helpers/url_helper.test.ts new file mode 100644 index 0000000000000..37e35ca17e0b3 --- /dev/null +++ b/x-pack/plugins/lens/public/helpers/url_helper.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('../../../../../src/plugins/dashboard/public', () => ({ + DashboardConstants: { + ADD_EMBEDDABLE_ID: 'addEmbeddableId', + ADD_EMBEDDABLE_TYPE: 'addEmbeddableType', + }, +})); + +import { addEmbeddableToDashboardUrl, getUrlVars } from './url_helper'; + +describe('Dashboard URL Helper', () => { + it('addEmbeddableToDashboardUrl', () => { + const id = '123eb456cd'; + const urlVars = { + x: '1', + y: '2', + z: '3', + }; + const url = + "/pep/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!())"; + expect(addEmbeddableToDashboardUrl(url, id, urlVars)).toEqual( + `/pep/app/kibana#/dashboard?_a=%28description%3A%27%27%2Cfilters%3A%21%28%29%29&_g=%28refreshInterval%3A%28pause%3A%21t%2Cvalue%3A0%29%2Ctime%3A%28from%3Anow-15m%2Cto%3Anow%29%29&addEmbeddableId=${id}&addEmbeddableType=lens&x=1&y=2&z=3` + ); + }); + + it('getUrlVars', () => { + let url = + "http://localhost:5601/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!()"; + expect(getUrlVars(url)).toEqual({ + _g: '(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))', + _a: "(description:'',filters:!()", + }); + url = 'http://mybusiness.mydomain.com/app/kibana#/dashboard?x=y&y=z'; + expect(getUrlVars(url)).toEqual({ + x: 'y', + y: 'z', + }); + url = 'http://localhost:5601/app/kibana#/dashboard/777182'; + expect(getUrlVars(url)).toEqual({}); + url = + 'http://localhost:5601/app/kibana#/dashboard/777182?title=Some%20Dashboard%20With%20Spaces'; + expect(getUrlVars(url)).toEqual({ title: 'Some Dashboard With Spaces' }); + }); +}); diff --git a/x-pack/plugins/lens/public/helpers/url_helper.ts b/x-pack/plugins/lens/public/helpers/url_helper.ts new file mode 100644 index 0000000000000..0a97ba4b2edf7 --- /dev/null +++ b/x-pack/plugins/lens/public/helpers/url_helper.ts @@ -0,0 +1,45 @@ +/* + * 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 { parseUrl, stringify } from 'query-string'; +import { DashboardConstants } from '../../../../../src/plugins/dashboard/public'; + +type UrlVars = Record<string, string>; + +/** + * Return query params from URL + * @param url given url + */ +export function getUrlVars(url: string): Record<string, string> { + const vars: UrlVars = {}; + for (const [, key, value] of url.matchAll(/[?&]+([^=&]+)=([^&]*)/gi)) { + vars[key] = decodeURIComponent(value); + } + return vars; +} + +/** * + * Returns dashboard URL with added embeddableType and embeddableId query params + * eg. + * input: url: /lol/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)), embeddableId: 12345 + * output: /lol/app/kibana#/dashboard?addEmbeddableType=lens&addEmbeddableId=12345&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)) + * @param url dasbhoard absolute url + * @param embeddableId id of the saved visualization + * @param urlVars url query params + */ +export function addEmbeddableToDashboardUrl(url: string, embeddableId: string, urlVars: UrlVars) { + const dashboardParsedUrl = parseUrl(url); + const keys = Object.keys(urlVars).sort(); + + keys.forEach(key => { + dashboardParsedUrl.query[key] = urlVars[key]; + }); + dashboardParsedUrl.query[DashboardConstants.ADD_EMBEDDABLE_TYPE] = 'lens'; + dashboardParsedUrl.query[DashboardConstants.ADD_EMBEDDABLE_ID] = embeddableId; + const query = stringify(dashboardParsedUrl.query); + + return `${dashboardParsedUrl.url}?${query}`; +} diff --git a/x-pack/legacy/plugins/lens/public/id_generator/id_generator.test.ts b/x-pack/plugins/lens/public/id_generator/id_generator.test.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/id_generator/id_generator.test.ts rename to x-pack/plugins/lens/public/id_generator/id_generator.test.ts diff --git a/x-pack/legacy/plugins/lens/public/id_generator/id_generator.ts b/x-pack/plugins/lens/public/id_generator/id_generator.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/id_generator/id_generator.ts rename to x-pack/plugins/lens/public/id_generator/id_generator.ts diff --git a/x-pack/legacy/plugins/lens/public/id_generator/index.ts b/x-pack/plugins/lens/public/id_generator/index.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/id_generator/index.ts rename to x-pack/plugins/lens/public/id_generator/index.ts diff --git a/x-pack/plugins/lens/public/index.scss b/x-pack/plugins/lens/public/index.scss new file mode 100644 index 0000000000000..67bbac12be8c3 --- /dev/null +++ b/x-pack/plugins/lens/public/index.scss @@ -0,0 +1,14 @@ +// Import the EUI global scope so we can use EUI constants +@import '@elastic/eui/src/global_styling/variables/index'; +@import '@elastic/eui/src/global_styling/mixins/index'; + +@import 'variables'; +@import 'mixins'; + +@import 'app_plugin/index'; +@import 'datatable_visualization/index'; +@import 'drag_drop/index'; +@import 'editor_frame_service/index'; +@import 'indexpattern_datasource/index'; +@import 'xy_visualization/index'; +@import 'metric_visualization/index'; diff --git a/x-pack/legacy/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/index.ts rename to x-pack/plugins/lens/public/index.ts diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/__mocks__/state_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/state_helpers.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/__mocks__/state_helpers.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/state_helpers.ts diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/__snapshots__/lens_field_icon.test.tsx.snap b/x-pack/plugins/lens/public/indexpattern_datasource/__snapshots__/lens_field_icon.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/__snapshots__/lens_field_icon.test.tsx.snap rename to x-pack/plugins/lens/public/indexpattern_datasource/__snapshots__/lens_field_icon.test.tsx.snap diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/_datapanel.scss b/x-pack/plugins/lens/public/indexpattern_datasource/_datapanel.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/_datapanel.scss rename to x-pack/plugins/lens/public/indexpattern_datasource/_datapanel.scss diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/_field_item.scss b/x-pack/plugins/lens/public/indexpattern_datasource/_field_item.scss similarity index 98% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/_field_item.scss rename to x-pack/plugins/lens/public/indexpattern_datasource/_field_item.scss index 89f6bbf908419..41919b900c71f 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/_field_item.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/_field_item.scss @@ -14,7 +14,7 @@ } .lnsFieldItem--missing { - background: lightOrDarkTheme(transparentize($euiColorMediumShade, 0.9), $euiColorEmptyShade); + background: lightOrDarkTheme(transparentize($euiColorMediumShade, .9), $euiColorEmptyShade); color: $euiColorDarkShade; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/_index.scss b/x-pack/plugins/lens/public/indexpattern_datasource/_index.scss new file mode 100644 index 0000000000000..e5d8b408e33e5 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/_index.scss @@ -0,0 +1,4 @@ +@import 'datapanel'; +@import 'field_item'; + +@import 'dimension_panel/index'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/auto_date.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/auto_date.test.ts similarity index 96% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/auto_date.test.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/auto_date.test.ts index cc1a74a1854ce..5f35ef650a08c 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/auto_date.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/auto_date.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { getAutoDate } from './auto_date'; describe('auto_date', () => { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/auto_date.ts b/x-pack/plugins/lens/public/indexpattern_datasource/auto_date.ts similarity index 92% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/auto_date.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/auto_date.ts index 063cbb4d217a7..97a46f4a3e176 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/auto_date.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/auto_date.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DataPublicPluginSetup } from '../../../../../../src/plugins/data/public'; +import { DataPublicPluginSetup } from '../../../../../src/plugins/data/public'; import { ExpressionFunctionDefinition, KibanaContext, -} from '../../../../../../src/plugins/expressions/public'; +} from '../../../../../src/plugins/expressions/public'; interface LensAutoDateProps { aggConfigs: string; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx similarity index 99% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index 3066ac0e11325..c396f0efee42e 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -6,6 +6,7 @@ import React, { ChangeEvent } from 'react'; import { createMockedDragDropContext } from './mocks'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { InnerIndexPatternDataPanel, IndexPatternDataPanel, MemoizedDataPanel } from './datapanel'; import { FieldItem } from './field_item'; import { act } from 'react-dom/test-utils'; @@ -16,8 +17,6 @@ import { ChangeIndexPattern } from './change_indexpattern'; import { EuiProgress } from '@elastic/eui'; import { documentField } from './document_field'; -jest.mock('ui/new_platform'); - const initialState: IndexPatternPrivateState = { indexPatternRefs: [], existingFields: {}, @@ -218,6 +217,7 @@ describe('IndexPattern Data Panel', () => { defaultProps = { indexPatternRefs: [], existingFields: {}, + data: dataPluginMock.createStartContract(), dragDropContext: createMockedDragDropContext(), currentIndexPatternId: '1', indexPatterns: initialState.indexPatterns, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx similarity index 98% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/datapanel.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 7a3c04b67fbc4..79dcdafd916b4 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -27,6 +27,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; import { DatasourceDataPanelProps, DataType, StateSetter } from '../types'; import { ChildDragDropProvider, DragContextState } from '../drag_drop'; import { FieldItem } from './field_item'; @@ -40,9 +41,10 @@ import { trackUiEvent } from '../lens_ui_telemetry'; import { syncExistingFields } from './loader'; import { fieldExists } from './pure_helpers'; import { Loader } from '../loader'; -import { esQuery, IIndexPattern } from '../../../../../../src/plugins/data/public'; +import { esQuery, IIndexPattern } from '../../../../../src/plugins/data/public'; export type Props = DatasourceDataPanelProps<IndexPatternPrivateState> & { + data: DataPublicPluginStart; changeIndexPattern: ( id: string, state: IndexPatternPrivateState, @@ -78,6 +80,7 @@ export function IndexPatternDataPanel({ state, dragDropContext, core, + data, query, filters, dateRange, @@ -152,6 +155,7 @@ export function IndexPatternDataPanel({ showEmptyFields={state.showEmptyFields} onToggleEmptyFields={onToggleEmptyFields} core={core} + data={data} onChangeIndexPattern={onChangeIndexPattern} existingFields={state.existingFields} /> @@ -177,8 +181,10 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ showEmptyFields, onToggleEmptyFields, core, + data, existingFields, }: Pick<DatasourceDataPanelProps, Exclude<keyof DatasourceDataPanelProps, 'state' | 'setState'>> & { + data: DataPublicPluginStart; currentIndexPatternId: string; indexPatternRefs: IndexPatternRef[]; indexPatterns: Record<string, IndexPattern>; @@ -441,6 +447,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ {specialFields.map(field => ( <FieldItem core={core} + data={data} key={field.name} indexPattern={currentIndexPattern} field={field} @@ -468,6 +475,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ return ( <FieldItem core={core} + data={data} indexPattern={currentIndexPattern} key={field.name} field={field} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/_field_select.scss b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/_field_select.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/_field_select.scss rename to x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/_field_select.scss diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/_index.scss b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/_index.scss new file mode 100644 index 0000000000000..085a00a2c33c5 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/_index.scss @@ -0,0 +1,2 @@ +@import 'field_select'; +@import 'popover'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/_popover.scss b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/_popover.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/_popover.scss rename to x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/_popover.scss diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.test.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.test.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.test.tsx diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx similarity index 99% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index f4485774bc942..074c40759f8d8 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -8,7 +8,7 @@ import { ReactWrapper, ShallowWrapper } from 'enzyme'; import React from 'react'; import { act } from 'react-dom/test-utils'; import { EuiComboBox, EuiSideNav, EuiSideNavItemType, EuiFieldNumber } from '@elastic/eui'; -import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; +import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; import { changeColumn } from '../state_helpers'; import { IndexPatternDimensionEditorComponent, @@ -19,18 +19,12 @@ import { import { DragContextState } from '../../drag_drop'; import { createMockedDragDropContext } from '../mocks'; import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; -import { - IUiSettingsClient, - SavedObjectsClientContract, - HttpSetup, - CoreSetup, -} from 'src/core/public'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreSetup } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { IndexPatternPrivateState } from '../types'; import { documentField } from '../document_field'; import { OperationMetadata } from '../../types'; -jest.mock('ui/new_platform'); jest.mock('../loader'); jest.mock('../state_helpers'); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx similarity index 96% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx index 5d87137db3d39..b3bd08d3bbfbe 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx @@ -8,7 +8,7 @@ import _ from 'lodash'; import React, { memo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiLink } from '@elastic/eui'; -import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'src/core/public'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { DatasourceDimensionTriggerProps, @@ -16,7 +16,7 @@ import { DatasourceDimensionDropProps, DatasourceDimensionDropHandlerProps, } from '../../types'; -import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; +import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; import { IndexPatternColumn, OperationType } from '../indexpattern'; import { getAvailableOperationsByMetadata, buildColumn, changeField } from '../operations'; import { PopoverEditor } from './popover_editor'; @@ -24,7 +24,7 @@ import { changeColumn } from '../state_helpers'; import { isDraggedField, hasField } from '../utils'; import { IndexPatternPrivateState, IndexPatternField } from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; -import { DateRange } from '../../../../../../plugins/lens/common'; +import { DateRange } from '../../../common'; export type IndexPatternDimensionTriggerProps = DatasourceDimensionTriggerProps< IndexPatternPrivateState @@ -140,12 +140,16 @@ export function onDrop( operationsForNewField && operationsForNewField.includes(selectedColumn.operationType); + if (!operationsForNewField || operationsForNewField.length === 0) { + return false; + } + // If only the field has changed use the onFieldChange method on the operation to get the // new column, otherwise use the regular buildColumn to get a new column. const newColumn = hasFieldChanged ? changeField(selectedColumn, currentIndexPattern, droppedItem.field) : buildColumn({ - op: operationsForNewField ? operationsForNewField[0] : undefined, + op: operationsForNewField[0], columns: props.state.layers[props.layerId].columns, indexPattern: currentIndexPattern, layerId, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/index.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/index.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/index.ts diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/document_field.ts b/x-pack/plugins/lens/public/indexpattern_datasource/document_field.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/document_field.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/document_field.ts diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx similarity index 95% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.test.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx index 77f7f8cdab358..6a4a2bd2ba77b 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx @@ -10,16 +10,15 @@ import { EuiLoadingSpinner, EuiPopover } from '@elastic/eui'; import { FieldItem, FieldItemProps } from './field_item'; import { coreMock } from 'src/core/public/mocks'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { npStart } from 'ui/new_platform'; -import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; +import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { IndexPattern } from './types'; -jest.mock('ui/new_platform'); - describe('IndexPattern Field Item', () => { let defaultProps: FieldItemProps; let indexPattern: IndexPattern; let core: ReturnType<typeof coreMock['createSetup']>; + let data: DataPublicPluginStart; beforeEach(() => { indexPattern = { @@ -61,9 +60,11 @@ describe('IndexPattern Field Item', () => { } as IndexPattern; core = coreMock.createSetup(); + data = dataPluginMock.createStartContract(); core.http.post.mockClear(); defaultProps = { indexPattern, + data, core, highlight: '', dateRange: { @@ -81,7 +82,7 @@ describe('IndexPattern Field Item', () => { exists: true, }; - npStart.plugins.data.fieldFormats = ({ + data.fieldFormats = ({ getDefaultInstance: jest.fn(() => ({ convert: jest.fn((s: unknown) => JSON.stringify(s)), })), diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx similarity index 97% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx index b98f589bc5b98..c4d2a6f8780c6 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx @@ -20,7 +20,6 @@ import { EuiText, EuiToolTip, } from '@elastic/eui'; -import { npStart } from 'ui/new_platform'; import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme'; import { Axis, @@ -33,6 +32,7 @@ import { TooltipType, } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; import { Query, KBN_FIELD_TYPES, @@ -40,17 +40,18 @@ import { Filter, esQuery, IIndexPattern, -} from '../../../../../../src/plugins/data/public'; +} from '../../../../../src/plugins/data/public'; import { DraggedField } from './indexpattern'; import { DragDrop } from '../drag_drop'; import { DatasourceDataPanelProps, DataType } from '../types'; -import { BucketedAggregation, FieldStatsResponse } from '../../../../../plugins/lens/common'; +import { BucketedAggregation, FieldStatsResponse } from '../../common'; import { IndexPattern, IndexPatternField } from './types'; import { LensFieldIcon } from './lens_field_icon'; import { trackUiEvent } from '../lens_ui_telemetry'; export interface FieldItemProps { core: DatasourceDataPanelProps['core']; + data: DataPublicPluginStart; field: IndexPatternField; indexPattern: IndexPattern; highlight?: string; @@ -237,8 +238,16 @@ export function FieldItem(props: FieldItemProps) { } function FieldItemPopoverContents(props: State & FieldItemProps) { - const fieldFormats = npStart.plugins.data.fieldFormats; - const { histogram, topValues, indexPattern, field, dateRange, core, sampledValues } = props; + const { + histogram, + topValues, + indexPattern, + field, + dateRange, + core, + sampledValues, + data: { fieldFormats }, + } = props; const IS_DARK_THEME = core.uiSettings.get('theme:darkMode'); const chartTheme = IS_DARK_THEME ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/index.ts new file mode 100644 index 0000000000000..fe14f472341af --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/index.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'kibana/public'; +import { Storage } from '../../../../../src/plugins/kibana_utils/public'; +import { getIndexPatternDatasource } from './indexpattern'; +import { renameColumns } from './rename_columns'; +import { getAutoDate } from './auto_date'; +import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; +import { + DataPublicPluginSetup, + DataPublicPluginStart, +} from '../../../../../src/plugins/data/public'; +import { Datasource, EditorFrameSetup } from '../types'; + +export interface IndexPatternDatasourceSetupPlugins { + expressions: ExpressionsSetup; + data: DataPublicPluginSetup; + editorFrame: EditorFrameSetup; +} + +export interface IndexPatternDatasourceStartPlugins { + data: DataPublicPluginStart; +} + +export class IndexPatternDatasource { + constructor() {} + + setup( + core: CoreSetup<IndexPatternDatasourceStartPlugins>, + { data: dataSetup, expressions, editorFrame }: IndexPatternDatasourceSetupPlugins + ) { + expressions.registerFunction(renameColumns); + expressions.registerFunction(getAutoDate({ data: dataSetup })); + + editorFrame.registerDatasource( + core.getStartServices().then(([coreStart, { data }]) => + getIndexPatternDatasource({ + core: coreStart, + storage: new Storage(localStorage), + data, + }) + ) as Promise<Datasource> + ); + } +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts similarity index 84% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index 76e59a170a9e9..e4f3677d0fe88 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -8,13 +8,11 @@ import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { getIndexPatternDatasource, IndexPatternColumn, uniqueLabels } from './indexpattern'; import { DatasourcePublicAPI, Operation, Datasource } from '../types'; import { coreMock } from 'src/core/public/mocks'; -import { pluginsMock } from 'ui/new_platform/__mocks__/helpers'; import { IndexPatternPersistedState, IndexPatternPrivateState } from './types'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; jest.mock('./loader'); jest.mock('../id_generator'); -// Contains old and new platform data plugins, used for interpreter and filter ratio -jest.mock('ui/new_platform'); const expectedIndexPatterns = { 1: { @@ -140,7 +138,7 @@ describe('IndexPattern Data Source', () => { indexPatternDatasource = getIndexPatternDatasource({ storage: {} as IStorageWrapper, core: coreMock.createStart(), - data: pluginsMock.createStart().data, + data: dataPluginMock.createStartContract(), }); persistedState = { @@ -259,12 +257,54 @@ describe('IndexPattern Data Source', () => { const state = stateFromPersistedState(queryPersistedState); expect(indexPatternDatasource.toExpression(state, 'first')).toMatchInlineSnapshot(` - "esaggs - index=\\"1\\" - metricsAtAllLevels=false - partialRows=false - includeFormatHints=true - aggConfigs={lens_auto_date aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}}]'} | lens_rename_columns idMap='{\\"col-0-col1\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"isBucketed\\":false,\\"sourceField\\":\\"Records\\",\\"operationType\\":\\"count\\",\\"id\\":\\"col1\\"},\\"col-1-col2\\":{\\"label\\":\\"Date\\",\\"dataType\\":\\"date\\",\\"isBucketed\\":true,\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"timestamp\\",\\"params\\":{\\"interval\\":\\"1d\\"},\\"id\\":\\"col2\\"}}' " + Object { + "chain": Array [ + Object { + "arguments": Object { + "aggConfigs": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "aggConfigs": Array [ + "[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}}]", + ], + }, + "function": "lens_auto_date", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "includeFormatHints": Array [ + true, + ], + "index": Array [ + "1", + ], + "metricsAtAllLevels": Array [ + false, + ], + "partialRows": Array [ + false, + ], + }, + "function": "esaggs", + "type": "function", + }, + Object { + "arguments": Object { + "idMap": Array [ + "{\\"col-0-col1\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"isBucketed\\":false,\\"sourceField\\":\\"Records\\",\\"operationType\\":\\"count\\",\\"id\\":\\"col1\\"},\\"col-1-col2\\":{\\"label\\":\\"Date\\",\\"dataType\\":\\"date\\",\\"isBucketed\\":true,\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"timestamp\\",\\"params\\":{\\"interval\\":\\"1d\\"},\\"id\\":\\"col2\\"}}", + ], + }, + "function": "lens_rename_columns", + "type": "function", + }, + ], + "type": "expression", + } `); }); }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx similarity index 96% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 9c2a9c9bf4a09..b8f0460f2a9ab 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -8,7 +8,7 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; -import { CoreStart } from 'src/core/public'; +import { CoreStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { @@ -42,10 +42,10 @@ import { IndexPatternPrivateState, IndexPatternPersistedState, } from './types'; -import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; -import { Plugin as DataPlugin } from '../../../../../../src/plugins/data/public'; +import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; +import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; import { deleteColumn } from './state_helpers'; -import { Datasource, StateSetter } from '..'; +import { Datasource, StateSetter } from '../index'; export { OperationType, IndexPatternColumn } from './operations'; @@ -105,7 +105,7 @@ export function getIndexPatternDatasource({ }: { core: CoreStart; storage: IStorageWrapper; - data: ReturnType<DataPlugin['start']>; + data: DataPublicPluginStart; }) { const savedObjectsClient = core.savedObjects.client; const uiSettings = core.uiSettings; @@ -209,6 +209,7 @@ export function getIndexPatternDatasource({ onError: onIndexPatternLoadError, }); }} + data={data} {...props} /> </I18nProvider>, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx similarity index 99% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index e36622f876acd..2008b326a539c 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -12,7 +12,6 @@ import { getDatasourceSuggestionsFromCurrentState, } from './indexpattern_suggestions'; -jest.mock('ui/new_platform'); jest.mock('./loader'); jest.mock('../id_generator'); @@ -824,10 +823,10 @@ describe('IndexPattern Data Source suggestions', () => { state: expect.objectContaining({ layers: expect.objectContaining({ currentLayer: expect.objectContaining({ - columnOrder: ['cola', 'id1'], + columnOrder: ['cola', 'colb'], columns: { cola: initialState.layers.currentLayer.columns.cola, - id1: expect.objectContaining({ + colb: expect.objectContaining({ operationType: 'avg', sourceField: 'memory', }), diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts similarity index 91% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts index 96127caa67bb4..2b3e976a77ea7 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -14,9 +14,10 @@ import { getOperationTypesForField, operationDefinitionMap, IndexPatternColumn, + OperationType, } from './operations'; -import { hasField } from './utils'; import { operationDefinitions } from './operations/definitions'; +import { hasField } from './utils'; import { IndexPattern, IndexPatternPrivateState, @@ -141,7 +142,13 @@ function getExistingLayerSuggestionsForField( suggestions.push( buildSuggestion({ state, - updatedLayer: addFieldAsBucketOperation(layer, layerId, indexPattern, field), + updatedLayer: addFieldAsBucketOperation( + layer, + layerId, + indexPattern, + field, + usableAsBucketOperation + ), layerId, changeType: 'extended', }) @@ -176,27 +183,8 @@ function addFieldAsMetricOperation( indexPattern: IndexPattern, field: IndexPatternField ): IndexPatternLayer | undefined { - const operations = getOperationTypesForField(field); - const operationsAlreadyAppliedToThisField = Object.values(layer.columns) - .filter(column => hasField(column) && column.sourceField === field.name) - .map(column => column.operationType); - const operationCandidate = operations.find( - operation => !operationsAlreadyAppliedToThisField.includes(operation) - ); - - if (!operationCandidate) { - return; - } - - const newColumn = buildColumn({ - op: operationCandidate, - columns: layer.columns, - layerId, - indexPattern, - suggestedPriority: undefined, - field, - }); - const newColumnId = generateId(); + const newColumn = getMetricColumn(indexPattern, layerId, field); + const addedColumnId = generateId(); const [, metrics] = separateBucketColumns(layer); @@ -206,20 +194,19 @@ function addFieldAsMetricOperation( indexPatternId: indexPattern.id, columns: { ...layer.columns, - [newColumnId]: newColumn, + [addedColumnId]: newColumn, }, - columnOrder: [...layer.columnOrder, newColumnId], + columnOrder: [...layer.columnOrder, addedColumnId], }; } - // If only one metric, replace instead of add - const newColumns = { ...layer.columns, [newColumnId]: newColumn }; - delete newColumns[metrics[0]]; + // Replacing old column with new column, keeping the old ID + const newColumns = { ...layer.columns, [metrics[0]]: newColumn }; return { indexPatternId: indexPattern.id, columns: newColumns, - columnOrder: [...layer.columnOrder.filter(c => c !== metrics[0]), newColumnId], + columnOrder: layer.columnOrder, // Order is kept by replacing }; } @@ -227,11 +214,11 @@ function addFieldAsBucketOperation( layer: IndexPatternLayer, layerId: string, indexPattern: IndexPattern, - field: IndexPatternField + field: IndexPatternField, + operation: OperationType ): IndexPatternLayer { - const applicableBucketOperation = getBucketOperation(field); const newColumn = buildColumn({ - op: applicableBucketOperation, + op: operation, columns: layer.columns, layerId, indexPattern, @@ -253,7 +240,7 @@ function addFieldAsBucketOperation( let updatedColumnOrder: string[] = []; if (oldDateHistogramId) { - if (applicableBucketOperation === 'terms') { + if (operation === 'terms') { // Insert the new terms bucket above the first date histogram updatedColumnOrder = [ ...buckets.slice(0, oldDateHistogramIndex), @@ -261,7 +248,7 @@ function addFieldAsBucketOperation( ...buckets.slice(oldDateHistogramIndex, buckets.length), ...metrics, ]; - } else if (applicableBucketOperation === 'date_histogram') { + } else if (operation === 'date_histogram') { // Replace date histogram with new date histogram delete updatedColumns[oldDateHistogramId]; updatedColumnOrder = layer.columnOrder.map(columnId => @@ -288,8 +275,9 @@ function getEmptyLayerSuggestionsForField( ): IndexPatternSugestion[] { const indexPattern = state.indexPatterns[indexPatternId]; let newLayer: IndexPatternLayer | undefined; - if (getBucketOperation(field)) { - newLayer = createNewLayerWithBucketAggregation(layerId, indexPattern, field); + const bucketOperation = getBucketOperation(field); + if (bucketOperation) { + newLayer = createNewLayerWithBucketAggregation(layerId, indexPattern, field, bucketOperation); } else if (indexPattern.timeFieldName && getOperationTypesForField(field).length > 0) { newLayer = createNewLayerWithMetricAggregation(layerId, indexPattern, field); } @@ -313,7 +301,8 @@ function getEmptyLayerSuggestionsForField( function createNewLayerWithBucketAggregation( layerId: string, indexPattern: IndexPattern, - field: IndexPatternField + field: IndexPatternField, + operation: OperationType ): IndexPatternLayer { const countColumn = buildColumn({ op: 'count', @@ -330,7 +319,7 @@ function createNewLayerWithBucketAggregation( // let column know about count column const column = buildColumn({ layerId, - op: getBucketOperation(field), + op: operation, indexPattern, columns: { [col2]: countColumn, @@ -356,15 +345,7 @@ function createNewLayerWithMetricAggregation( ): IndexPatternLayer { const dateField = indexPattern.fields.find(f => f.name === indexPattern.timeFieldName)!; - const operations = getOperationTypesForField(field); - const column = buildColumn({ - op: operations[0], - columns: {}, - suggestedPriority: undefined, - field, - indexPattern, - layerId, - }); + const column = getMetricColumn(indexPattern, layerId, field); const dateColumn = buildColumn({ op: 'date_histogram', @@ -501,12 +482,7 @@ function createChangedNestingSuggestion(state: IndexPatternPrivateState, layerId }); } -function createMetricSuggestion( - indexPattern: IndexPattern, - layerId: string, - state: IndexPatternPrivateState, - field: IndexPatternField -) { +function getMetricColumn(indexPattern: IndexPattern, layerId: string, field: IndexPatternField) { const operationDefinitionsMap = _.indexBy(operationDefinitions, 'type'); const [column] = getOperationTypesForField(field) .map(type => @@ -519,6 +495,16 @@ function createMetricSuggestion( }) ) .filter(op => (op.dataType === 'number' || op.dataType === 'document') && !op.isBucketed); + return column; +} + +function createMetricSuggestion( + indexPattern: IndexPattern, + layerId: string, + state: IndexPatternPrivateState, + field: IndexPatternField +) { + const column = getMetricColumn(indexPattern, layerId, field); if (!column) { return; @@ -573,21 +559,26 @@ function createAlternativeMetricSuggestions( return; } const field = indexPattern.fields.find(({ name }) => column.sourceField === name)!; - const alternativeMetricOperations = getOperationTypesForField(field).filter( - operationType => operationType !== column.operationType - ); + const alternativeMetricOperations = getOperationTypesForField(field) + .map(op => + buildColumn({ + op, + columns: layer.columns, + indexPattern, + layerId, + field, + suggestedPriority: undefined, + }) + ) + .filter( + fullOperation => + fullOperation.operationType !== column.operationType && !fullOperation.isBucketed + ); if (alternativeMetricOperations.length === 0) { return; } const newId = generateId(); - const newColumn = buildColumn({ - op: alternativeMetricOperations[0], - columns: layer.columns, - indexPattern, - layerId, - field, - suggestedPriority: undefined, - }); + const newColumn = alternativeMetricOperations[0]; const updatedLayer = { indexPatternId: indexPattern.id, columns: { [newId]: newColumn }, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx similarity index 99% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx index 219a6d935e436..4dd29d7925916 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx @@ -12,7 +12,6 @@ import { ShallowWrapper } from 'enzyme'; import { EuiSelectable, EuiSelectableList } from '@elastic/eui'; import { ChangeIndexPattern } from './change_indexpattern'; -jest.mock('ui/new_platform'); jest.mock('./state_helpers'); const initialState: IndexPatternPrivateState = { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/layerpanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/layerpanel.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/lens_field_icon.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/lens_field_icon.test.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/lens_field_icon.test.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/lens_field_icon.test.tsx diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/lens_field_icon.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/lens_field_icon.tsx similarity index 86% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/lens_field_icon.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/lens_field_icon.tsx index 06eda73748cef..bcc83e799d889 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/lens_field_icon.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/lens_field_icon.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { FieldIcon, FieldIconProps } from '../../../../../../src/plugins/kibana_react/public'; +import { FieldIcon, FieldIconProps } from '../../../../../src/plugins/kibana_react/public'; import { DataType } from '../types'; import { normalizeOperationDataType } from './utils'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts similarity index 99% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/loader.test.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index ea9c8213ba909..cacf729ba0caf 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -16,8 +16,6 @@ import { import { IndexPatternPersistedState, IndexPatternPrivateState } from './types'; import { documentField } from './document_field'; -// TODO: This should not be necessary -jest.mock('ui/new_platform'); jest.mock('./operations'); const sampleIndexPatterns = { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts similarity index 96% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/loader.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index f4d5857f4826d..23faab768eba6 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -5,8 +5,8 @@ */ import _ from 'lodash'; -import { SavedObjectsClientContract, SavedObjectAttributes, HttpSetup } from 'src/core/public'; -import { SimpleSavedObject } from 'src/core/public'; +import { SavedObjectsClientContract, SavedObjectAttributes, HttpSetup } from 'kibana/public'; +import { SimpleSavedObject } from 'kibana/public'; import { StateSetter } from '../types'; import { IndexPattern, @@ -16,14 +16,14 @@ import { IndexPatternField, } from './types'; import { updateLayerIndexPattern } from './state_helpers'; -import { DateRange, ExistingFields } from '../../../../../plugins/lens/common/types'; -import { BASE_API_URL } from '../../../../../plugins/lens/common'; +import { DateRange, ExistingFields } from '../../common/types'; +import { BASE_API_URL } from '../../common'; import { documentField } from './document_field'; import { indexPatterns as indexPatternsUtils, IFieldType, IndexPatternTypeMeta, -} from '../../../../../../src/plugins/data/public'; +} from '../../../../../src/plugins/data/public'; interface SavedIndexPatternAttributes extends SavedObjectAttributes { title: string; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/mocks.ts b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/mocks.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx similarity index 98% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx index 33325016deaeb..9491ca9ea3787 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx @@ -5,7 +5,7 @@ */ import { i18n } from '@kbn/i18n'; -import { OperationDefinition } from '.'; +import { OperationDefinition } from './index'; import { FormattedIndexPatternColumn } from './column_types'; const supportedTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx similarity index 97% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx index 1592b1049f666..1dcaf78b58a6c 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx @@ -5,7 +5,7 @@ */ import { i18n } from '@kbn/i18n'; -import { OperationDefinition } from '.'; +import { OperationDefinition } from './index'; import { FormattedIndexPatternColumn } from './column_types'; import { IndexPatternField } from '../../types'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx similarity index 99% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx index dc279fca82d4b..e3b6061248f3b 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx @@ -6,21 +6,19 @@ import React from 'react'; import { DateHistogramIndexPatternColumn } from './date_histogram'; -import { dateHistogramOperation } from '.'; +import { dateHistogramOperation } from './index'; import { shallow } from 'enzyme'; import { EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; -import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'src/core/public'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; import { coreMock } from 'src/core/public/mocks'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { dataPluginMock, getCalculateAutoTimeExpression, -} from '../../../../../../../../src/plugins/data/public/mocks'; +} from '../../../../../../../src/plugins/data/public/mocks'; import { createMockedIndexPattern } from '../../mocks'; import { IndexPatternPrivateState } from '../../types'; -jest.mock('ui/new_platform'); - const dataStart = dataPluginMock.createStartContract(); dataStart.search.aggs.calculateAutoTimeExpression = getCalculateAutoTimeExpression({ ...coreMock.createStart().uiSettings, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx similarity index 98% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx index 452d5c9140868..6161df1167afe 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx @@ -21,12 +21,9 @@ import { EuiSpacer, } from '@elastic/eui'; import { updateColumnParam } from '../../state_helpers'; -import { OperationDefinition } from '.'; +import { OperationDefinition } from './index'; import { FieldBasedIndexPatternColumn } from './column_types'; -import { - IndexPatternAggRestrictions, - search, -} from '../../../../../../../../src/plugins/data/public'; +import { IndexPatternAggRestrictions, search } from '../../../../../../../src/plugins/data/public'; const { isValidInterval } = search.aggs; const autoInterval = 'auto'; @@ -45,6 +42,7 @@ export const dateHistogramOperation: OperationDefinition<DateHistogramIndexPatte displayName: i18n.translate('xpack.lens.indexPattern.dateHistogram', { defaultMessage: 'Date histogram', }), + priority: 3, // Higher than any metric getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => { if ( type === 'date' && diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts new file mode 100644 index 0000000000000..ef12fca690f0c --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -0,0 +1,198 @@ +/* + * 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 { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { termsOperation } from './terms'; +import { cardinalityOperation } from './cardinality'; +import { minOperation, averageOperation, sumOperation, maxOperation } from './metrics'; +import { dateHistogramOperation } from './date_histogram'; +import { countOperation } from './count'; +import { DimensionPriority, StateSetter, OperationMetadata } from '../../../types'; +import { BaseIndexPatternColumn } from './column_types'; +import { IndexPatternPrivateState, IndexPattern, IndexPatternField } from '../../types'; +import { DateRange } from '../../../../common'; +import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; + +// List of all operation definitions registered to this data source. +// If you want to implement a new operation, add it to this array and +// its type will get propagated to everything else +const internalOperationDefinitions = [ + termsOperation, + dateHistogramOperation, + minOperation, + maxOperation, + averageOperation, + cardinalityOperation, + sumOperation, + countOperation, +]; + +export { termsOperation } from './terms'; +export { dateHistogramOperation } from './date_histogram'; +export { minOperation, averageOperation, sumOperation, maxOperation } from './metrics'; +export { countOperation } from './count'; + +/** + * Properties passed to the operation-specific part of the popover editor + */ +export interface ParamEditorProps<C extends BaseIndexPatternColumn> { + currentColumn: C; + state: IndexPatternPrivateState; + setState: StateSetter<IndexPatternPrivateState>; + columnId: string; + layerId: string; + uiSettings: IUiSettingsClient; + storage: IStorageWrapper; + savedObjectsClient: SavedObjectsClientContract; + http: HttpSetup; + dateRange: DateRange; + data: DataPublicPluginStart; +} + +interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn> { + type: C['operationType']; + /** + * The priority of the operation. If multiple operations are possible in + * a given scenario (e.g. the user dragged a field into the workspace), + * the operation with the highest priority is picked. + */ + priority?: number; + /** + * The name of the operation shown to the user (e.g. in the popover editor). + * Should be i18n-ified. + */ + displayName: string; + /** + * This function is called if another column in the same layer changed or got removed. + * Can be used to update references to other columns (e.g. for sorting). + * Based on the current column and the other updated columns, this function has to + * return an updated column. If not implemented, the `id` function is used instead. + */ + onOtherColumnChanged?: ( + currentColumn: C, + columns: Partial<Record<string, IndexPatternColumn>> + ) => C; + /** + * React component for operation specific settings shown in the popover editor + */ + paramEditor?: React.ComponentType<ParamEditorProps<C>>; + /** + * Function turning a column into an agg config passed to the `esaggs` function + * together with the agg configs returned from other columns. + */ + toEsAggsConfig: (column: C, columnId: string) => unknown; + /** + * Returns true if the `column` can also be used on `newIndexPattern`. + * If this function returns false, the column is removed when switching index pattern + * for a layer + */ + isTransferable: (column: C, newIndexPattern: IndexPattern) => boolean; + /** + * Transfering a column to another index pattern. This can be used to + * adjust operation specific settings such as reacting to aggregation restrictions + * present on the new index pattern. + */ + transfer?: (column: C, newIndexPattern: IndexPattern) => C; +} + +interface BaseBuildColumnArgs { + suggestedPriority: DimensionPriority | undefined; + layerId: string; + columns: Partial<Record<string, IndexPatternColumn>>; + indexPattern: IndexPattern; +} + +interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn> + extends BaseOperationDefinitionProps<C> { + /** + * Returns the meta data of the operation if applied to the given field. Undefined + * if the field is not applicable to the operation. + */ + getPossibleOperationForField: (field: IndexPatternField) => OperationMetadata | undefined; + /** + * Builds the column object for the given parameters. Should include default p + */ + buildColumn: ( + arg: BaseBuildColumnArgs & { + field: IndexPatternField; + previousColumn?: C; + } + ) => C; + /** + * This method will be called if the user changes the field of an operation. + * You must implement it and return the new column after the field change. + * The most simple implementation will just change the field on the column, and keep + * the rest the same. Some implementations might want to change labels, or their parameters + * when changing the field. + * + * This will only be called for switching the field, not for initially selecting a field. + * + * See {@link OperationDefinition#transfer} for controlling column building when switching an + * index pattern not just a field. + * + * @param oldColumn The column before the user changed the field. + * @param indexPattern The index pattern that field is on. + * @param field The field that the user changed to. + */ + onFieldChange: (oldColumn: C, indexPattern: IndexPattern, field: IndexPatternField) => C; +} + +/** + * Shape of an operation definition. If the type parameter of the definition + * indicates a field based column, `getPossibleOperationForField` has to be + * specified, otherwise `getPossibleOperationForDocument` has to be defined. + */ +export type OperationDefinition<C extends BaseIndexPatternColumn> = FieldBasedOperationDefinition< + C +>; + +// Helper to to infer the column type out of the operation definition. +// This is done to avoid it to have to list out the column types along with +// the operation definition types +type ColumnFromOperationDefinition<D> = D extends OperationDefinition<infer C> ? C : never; + +/** + * A union type of all available column types. If a column is of an unknown type somewhere + * withing the indexpattern data source it should be typed as `IndexPatternColumn` to make + * typeguards possible that consider all available column types. + */ +export type IndexPatternColumn = ColumnFromOperationDefinition< + typeof internalOperationDefinitions[number] +>; + +/** + * A union type of all available operation types. The operation type is a unique id of an operation. + * Each column is assigned to exactly one operation type. + */ +export type OperationType = typeof internalOperationDefinitions[number]['type']; + +/** + * This is an operation definition of an unspecified column out of all possible + * column types. + */ +export type GenericOperationDefinition = FieldBasedOperationDefinition<IndexPatternColumn>; + +/** + * List of all available operation definitions + */ +export const operationDefinitions = internalOperationDefinitions as GenericOperationDefinition[]; + +/** + * Map of all operation visible to consumers (e.g. the dimension panel). + * This simplifies the type of the map and makes it a simple list of unspecified + * operations definitions, because typescript can't infer the type correctly in most + * situations. + * + * If you need a specifically typed version of an operation (e.g. explicitly working with terms), + * you should import the definition directly from this file + * (e.g. `import { termsOperation } from './operations/definitions'`). This map is + * intended to be used in situations where the operation type is not known during compile time. + */ +export const operationDefinitionMap = internalOperationDefinitions.reduce( + (definitionMap, definition) => ({ ...definitionMap, [definition.type]: definition }), + {} +) as Record<OperationType, GenericOperationDefinition>; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx similarity index 98% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx index c2d9478c6ea15..3da635dc13d10 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx @@ -5,7 +5,7 @@ */ import { i18n } from '@kbn/i18n'; -import { OperationDefinition } from '.'; +import { OperationDefinition } from './index'; import { FormattedIndexPatternColumn } from './column_types'; type MetricColumn<T> = FormattedIndexPatternColumn & { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx similarity index 98% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx index 226246714f18d..8f6130e74b5b8 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx @@ -7,16 +7,14 @@ import React from 'react'; import { shallow } from 'enzyme'; import { EuiRange, EuiSelect } from '@elastic/eui'; -import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'src/core/public'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; -import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; import { createMockedIndexPattern } from '../../mocks'; import { TermsIndexPatternColumn } from './terms'; -import { termsOperation } from '.'; +import { termsOperation } from './index'; import { IndexPatternPrivateState } from '../../types'; -jest.mock('ui/new_platform'); - const defaultProps = { storage: {} as IStorageWrapper, uiSettings: {} as IUiSettingsClient, @@ -274,7 +272,7 @@ describe('terms', () => { expect(updatedColumn).toBe(initialColumn); }); - it('should switch to alphabetical ordering if the order column is removed', () => { + it('should switch to alphabetical ordering if there are no columns to order by', () => { const termsColumn = termsOperation.onOtherColumnChanged!( { label: 'Top value of category', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.tsx similarity index 98% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.tsx index cd0dcc0b7e9ce..7eb10456b2a6e 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.tsx @@ -10,7 +10,7 @@ import { EuiForm, EuiFormRow, EuiRange, EuiSelect } from '@elastic/eui'; import { IndexPatternColumn } from '../../indexpattern'; import { updateColumnParam } from '../../state_helpers'; import { DataType } from '../../../types'; -import { OperationDefinition } from '.'; +import { OperationDefinition } from './index'; import { FieldBasedIndexPatternColumn } from './column_types'; type PropType<C> = C extends React.ComponentType<infer P> ? P : unknown; @@ -53,6 +53,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn> = { displayName: i18n.translate('xpack.lens.indexPattern.terms', { defaultMessage: 'Top values', }), + priority: 3, // Higher than any metric getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => { if ( supportedTypes.has(type) && @@ -135,7 +136,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn> = { } return currentColumn; }, - paramEditor: ({ state, setState, currentColumn, columnId: currentColumnId, layerId }) => { + paramEditor: ({ state, setState, currentColumn, layerId }) => { const SEPARATOR = '$$$'; function toValue(orderBy: TermsIndexPatternColumn['params']['orderBy']) { if (orderBy.type === 'alphabetical') { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/index.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts similarity index 88% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index 3602491c6eb2c..e5d20839aae3d 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -4,13 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getOperationTypesForField, getAvailableOperationsByMetadata, buildColumn } from '.'; -import { AvgIndexPatternColumn, MinIndexPatternColumn } from './definitions/metrics'; -import { CountIndexPatternColumn } from './definitions/count'; +import { getOperationTypesForField, getAvailableOperationsByMetadata, buildColumn } from './index'; +import { AvgIndexPatternColumn } from './definitions/metrics'; import { IndexPatternPrivateState } from '../types'; import { documentField } from '../document_field'; -jest.mock('ui/new_platform'); jest.mock('../loader'); const expectedIndexPatterns = { @@ -198,33 +196,21 @@ describe('getOperationTypesForField', () => { expect(column.operationType).toEqual('avg'); expect(column.sourceField).toEqual(field.name); }); - - it('should pick a suitable field operation if none is passed in', () => { - const field = expectedIndexPatterns[1].fields[1]; - const column = buildColumn({ - layerId: 'first', - indexPattern: expectedIndexPatterns[1], - columns: state.layers.first.columns, - suggestedPriority: 0, - field, - }) as MinIndexPatternColumn; - expect(column.operationType).toEqual('avg'); - expect(column.sourceField).toEqual(field.name); - }); - - it('should pick a suitable document operation if none is passed in', () => { - const column = buildColumn({ - layerId: 'first', - indexPattern: expectedIndexPatterns[1], - columns: state.layers.first.columns, - suggestedPriority: 0, - field: documentField, - }) as CountIndexPatternColumn; - expect(column.operationType).toEqual('count'); - }); }); describe('getAvailableOperationsByMetaData', () => { + it('should put the average operation first', () => { + const numberOperation = getAvailableOperationsByMetadata(expectedIndexPatterns[1]).find( + ({ operationMetaData }) => + !operationMetaData.isBucketed && operationMetaData.dataType === 'number' + )!; + expect(numberOperation.operations[0]).toEqual( + expect.objectContaining({ + operationType: 'avg', + }) + ); + }); + it('should list out all field-operation tuples for different operation meta data', () => { expect(getAvailableOperationsByMetadata(expectedIndexPatterns[1])).toMatchInlineSnapshot(` Array [ @@ -279,17 +265,22 @@ describe('getOperationTypesForField', () => { "operations": Array [ Object { "field": "bytes", - "operationType": "min", + "operationType": "avg", "type": "field", }, Object { "field": "bytes", - "operationType": "max", + "operationType": "sum", "type": "field", }, Object { "field": "bytes", - "operationType": "avg", + "operationType": "min", + "type": "field", + }, + Object { + "field": "bytes", + "operationType": "max", "type": "field", }, Object { @@ -307,11 +298,6 @@ describe('getOperationTypesForField', () => { "operationType": "cardinality", "type": "field", }, - Object { - "field": "bytes", - "operationType": "sum", - "type": "field", - }, ], }, ] diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/operations.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts similarity index 89% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/operations.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts index ce8ea55c445dc..dbcd4eac7fd59 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/operations.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts @@ -52,20 +52,22 @@ export function getOperationDisplay() { return display; } +function getSortScoreByPriority(a: GenericOperationDefinition, b: GenericOperationDefinition) { + return (b.priority || Number.NEGATIVE_INFINITY) - (a.priority || Number.NEGATIVE_INFINITY); +} + /** * Returns all `OperationType`s that can build a column using `buildColumn` based on the * passed in field. */ -export function getOperationTypesForField(field: IndexPatternField) { +export function getOperationTypesForField(field: IndexPatternField): OperationType[] { return operationDefinitions .filter( operationDefinition => 'getPossibleOperationForField' in operationDefinition && operationDefinition.getPossibleOperationForField(field) ) - .sort( - (a, b) => (b.priority || Number.NEGATIVE_INFINITY) - (a.priority || Number.NEGATIVE_INFINITY) - ) + .sort(getSortScoreByPriority) .map(({ type }) => type); } @@ -131,7 +133,7 @@ export function getAvailableOperationsByMetadata(indexPattern: IndexPattern) { } }; - operationDefinitions.forEach(operationDefinition => { + operationDefinitions.sort(getSortScoreByPriority).forEach(operationDefinition => { indexPattern.fields.forEach(field => { addToMap( { @@ -156,13 +158,6 @@ function getPossibleOperationForField( : undefined; } -function getDefinition(findFunction: (definition: GenericOperationDefinition) => boolean) { - const candidates = operationDefinitions.filter(findFunction); - return candidates.reduce((a, b) => - (a.priority || Number.NEGATIVE_INFINITY) > (b.priority || Number.NEGATIVE_INFINITY) ? a : b - ); -} - /** * Changes the field of the passed in colum. To do so, this method uses the `onFieldChange` function of * the operation definition of the column. Returns a new column object with the field changed. @@ -204,7 +199,7 @@ export function buildColumn({ suggestedPriority, previousColumn, }: { - op?: OperationType; + op: OperationType; columns: Partial<Record<string, IndexPatternColumn>>; suggestedPriority: DimensionPriority | undefined; layerId: string; @@ -212,15 +207,7 @@ export function buildColumn({ field: IndexPatternField; previousColumn?: IndexPatternColumn; }): IndexPatternColumn { - let operationDefinition: GenericOperationDefinition | undefined; - - if (op) { - operationDefinition = operationDefinitionMap[op]; - } else if (field) { - operationDefinition = getDefinition(definition => - Boolean(getPossibleOperationForField(definition, field)) - ); - } + const operationDefinition = operationDefinitionMap[op]; if (!operationDefinition) { throw new Error('No suitable operation found for given parameters'); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/pure_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/pure_helpers.test.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/pure_helpers.test.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/pure_helpers.test.ts diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/pure_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/pure_helpers.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/pure_helpers.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/pure_helpers.ts diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/rename_columns.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/rename_columns.test.ts similarity index 96% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/rename_columns.test.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/rename_columns.test.ts index 9da7591305a6c..4bfd6a4f93c75 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/rename_columns.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/rename_columns.test.ts @@ -5,8 +5,8 @@ */ import { renameColumns } from './rename_columns'; -import { KibanaDatatable } from '../../../../../../src/plugins/expressions/public'; -import { createMockExecutionContext } from '../../../../../../src/plugins/expressions/common/mocks'; +import { KibanaDatatable } from '../../../../../src/plugins/expressions/public'; +import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; describe('rename_columns', () => { it('should rename columns of a given datatable', () => { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/rename_columns.ts b/x-pack/plugins/lens/public/indexpattern_datasource/rename_columns.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/rename_columns.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/rename_columns.ts diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts similarity index 99% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts index 0a58853f1ef4f..1e3251a8dedd8 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts @@ -17,7 +17,6 @@ import { DateHistogramIndexPatternColumn } from './operations/definitions/date_h import { AvgIndexPatternColumn } from './operations/definitions/metrics'; import { IndexPattern, IndexPatternPrivateState, IndexPatternLayer } from './types'; -jest.mock('ui/new_platform'); jest.mock('./operations'); describe('state_helpers', () => { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/state_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/state_helpers.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.ts diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts new file mode 100644 index 0000000000000..3ab51b5fa3f2b --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { Ast, ExpressionFunctionAST } from '@kbn/interpreter/common'; +import { IndexPatternColumn } from './indexpattern'; +import { operationDefinitionMap } from './operations'; +import { IndexPattern, IndexPatternPrivateState } from './types'; +import { OriginalColumn } from './rename_columns'; + +function getExpressionForLayer( + indexPattern: IndexPattern, + columns: Record<string, IndexPatternColumn>, + columnOrder: string[] +): Ast | null { + if (columnOrder.length === 0) { + return null; + } + + function getEsAggsConfig<C extends IndexPatternColumn>(column: C, columnId: string) { + return operationDefinitionMap[column.operationType].toEsAggsConfig(column, columnId); + } + + const columnEntries = columnOrder.map(colId => [colId, columns[colId]] as const); + + if (columnEntries.length) { + const aggs = columnEntries.map(([colId, col]) => { + return getEsAggsConfig(col, colId); + }); + + const idMap = columnEntries.reduce((currentIdMap, [colId], index) => { + return { + ...currentIdMap, + [`col-${index}-${colId}`]: { + ...columns[colId], + id: colId, + }, + }; + }, {} as Record<string, OriginalColumn>); + + type FormattedColumn = Required<Extract<IndexPatternColumn, { params?: { format: unknown } }>>; + + const columnsWithFormatters = columnEntries.filter( + ([, col]) => col.params && 'format' in col.params && col.params.format + ) as Array<[string, FormattedColumn]>; + const formatterOverrides: ExpressionFunctionAST[] = columnsWithFormatters.map(([id, col]) => { + const format = (col as FormattedColumn).params!.format; + const base: ExpressionFunctionAST = { + type: 'function', + function: 'lens_format_column', + arguments: { + format: [format.id], + columnId: [id], + }, + }; + if (typeof format.params?.decimals === 'number') { + return { + ...base, + arguments: { + ...base.arguments, + decimals: [format.params.decimals], + }, + }; + } + return base; + }); + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'esaggs', + arguments: { + index: [indexPattern.id], + metricsAtAllLevels: [false], + partialRows: [false], + includeFormatHints: [true], + aggConfigs: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_auto_date', + arguments: { + aggConfigs: [JSON.stringify(aggs)], + }, + }, + ], + }, + ], + }, + }, + { + type: 'function', + function: 'lens_rename_columns', + arguments: { + idMap: [JSON.stringify(idMap)], + }, + }, + ...formatterOverrides, + ], + }; + } + + return null; +} + +export function toExpression(state: IndexPatternPrivateState, layerId: string) { + if (state.layers[layerId]) { + return getExpressionForLayer( + state.indexPatterns[state.layers[layerId].indexPatternId], + state.layers[layerId].columns, + state.layers[layerId].columnOrder + ); + } + + return null; +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts new file mode 100644 index 0000000000000..563af40ed2720 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts @@ -0,0 +1,60 @@ +/* + * 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 { IndexPatternColumn } from './operations'; +import { IndexPatternAggRestrictions } from '../../../../../src/plugins/data/public'; + +export interface IndexPattern { + id: string; + fields: IndexPatternField[]; + title: string; + timeFieldName?: string | null; + fieldFormatMap?: Record< + string, + { + id: string; + params: unknown; + } + >; +} + +export interface IndexPatternField { + name: string; + type: string; + esTypes?: string[]; + aggregatable: boolean; + scripted?: boolean; + searchable: boolean; + aggregationRestrictions?: Partial<IndexPatternAggRestrictions>; +} + +export interface IndexPatternLayer { + columnOrder: string[]; + columns: Record<string, IndexPatternColumn>; + // Each layer is tied to the index pattern that created it + indexPatternId: string; +} + +export interface IndexPatternPersistedState { + currentIndexPatternId: string; + layers: Record<string, IndexPatternLayer>; +} + +export type IndexPatternPrivateState = IndexPatternPersistedState & { + indexPatternRefs: IndexPatternRef[]; + indexPatterns: Record<string, IndexPattern>; + + /** + * indexPatternId -> fieldName -> boolean + */ + existingFields: Record<string, Record<string, boolean>>; + showEmptyFields: boolean; +}; + +export interface IndexPatternRef { + id: string; + title: string; +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/indexpattern_datasource/utils.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/utils.ts diff --git a/x-pack/legacy/plugins/lens/public/lens_ui_telemetry/factory.test.ts b/x-pack/plugins/lens/public/lens_ui_telemetry/factory.test.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/lens_ui_telemetry/factory.test.ts rename to x-pack/plugins/lens/public/lens_ui_telemetry/factory.test.ts diff --git a/x-pack/legacy/plugins/lens/public/lens_ui_telemetry/factory.ts b/x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts similarity index 96% rename from x-pack/legacy/plugins/lens/public/lens_ui_telemetry/factory.ts rename to x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts index 73750a65c50b8..10b052c66efed 100644 --- a/x-pack/legacy/plugins/lens/public/lens_ui_telemetry/factory.ts +++ b/x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts @@ -5,10 +5,10 @@ */ import moment from 'moment'; -import { HttpSetup } from 'src/core/public'; +import { HttpSetup } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; -import { BASE_API_URL } from '../../../../../plugins/lens/common'; +import { BASE_API_URL } from '../../common'; const STORAGE_KEY = 'lens-ui-telemetry'; diff --git a/x-pack/legacy/plugins/lens/public/lens_ui_telemetry/index.ts b/x-pack/plugins/lens/public/lens_ui_telemetry/index.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/lens_ui_telemetry/index.ts rename to x-pack/plugins/lens/public/lens_ui_telemetry/index.ts diff --git a/x-pack/legacy/plugins/lens/public/loader.test.tsx b/x-pack/plugins/lens/public/loader.test.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/loader.test.tsx rename to x-pack/plugins/lens/public/loader.test.tsx diff --git a/x-pack/legacy/plugins/lens/public/loader.tsx b/x-pack/plugins/lens/public/loader.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/loader.tsx rename to x-pack/plugins/lens/public/loader.tsx diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/auto_scale.test.tsx b/x-pack/plugins/lens/public/metric_visualization/auto_scale.test.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/metric_visualization/auto_scale.test.tsx rename to x-pack/plugins/lens/public/metric_visualization/auto_scale.test.tsx diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/auto_scale.tsx b/x-pack/plugins/lens/public/metric_visualization/auto_scale.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/metric_visualization/auto_scale.tsx rename to x-pack/plugins/lens/public/metric_visualization/auto_scale.tsx diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/index.scss b/x-pack/plugins/lens/public/metric_visualization/index.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/metric_visualization/index.scss rename to x-pack/plugins/lens/public/metric_visualization/index.scss diff --git a/x-pack/plugins/lens/public/metric_visualization/index.ts b/x-pack/plugins/lens/public/metric_visualization/index.ts new file mode 100644 index 0000000000000..2960da52191e4 --- /dev/null +++ b/x-pack/plugins/lens/public/metric_visualization/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'kibana/public'; +import { metricVisualization } from './metric_visualization'; +import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; +import { metricChart, getMetricChartRenderer } from './metric_expression'; +import { EditorFrameSetup, FormatFactory } from '../types'; + +export interface MetricVisualizationPluginSetupPlugins { + expressions: ExpressionsSetup; + formatFactory: Promise<FormatFactory>; + editorFrame: EditorFrameSetup; +} + +export class MetricVisualization { + constructor() {} + + setup( + _core: CoreSetup | null, + { expressions, formatFactory, editorFrame }: MetricVisualizationPluginSetupPlugins + ) { + expressions.registerFunction(() => metricChart); + + expressions.registerRenderer(() => getMetricChartRenderer(formatFactory)); + + editorFrame.registerVisualization(metricVisualization); + } +} diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_expression.test.tsx b/x-pack/plugins/lens/public/metric_visualization/metric_expression.test.tsx similarity index 94% rename from x-pack/legacy/plugins/lens/public/metric_visualization/metric_expression.test.tsx rename to x-pack/plugins/lens/public/metric_visualization/metric_expression.test.tsx index 3da38d486aecd..2406e7cd42ebc 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_expression.test.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/metric_expression.test.tsx @@ -9,8 +9,8 @@ import { LensMultiTable } from '../types'; import React from 'react'; import { shallow } from 'enzyme'; import { MetricConfig } from './types'; -import { createMockExecutionContext } from '../../../../../../src/plugins/expressions/common/mocks'; -import { IFieldFormat } from '../../../../../../src/plugins/data/public'; +import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; +import { IFieldFormat } from '../../../../../src/plugins/data/public'; function sampleArgs() { const data: LensMultiTable = { diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_expression.tsx b/x-pack/plugins/lens/public/metric_visualization/metric_expression.tsx similarity index 98% rename from x-pack/legacy/plugins/lens/public/metric_visualization/metric_expression.tsx rename to x-pack/plugins/lens/public/metric_visualization/metric_expression.tsx index a80552e57a9e0..3484837f65b43 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_expression.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/metric_expression.tsx @@ -10,7 +10,7 @@ import { ExpressionFunctionDefinition, ExpressionRenderDefinition, IInterpreterRenderHandlers, -} from '../../../../../../src/plugins/expressions/public'; +} from '../../../../../src/plugins/expressions/public'; import { MetricConfig } from './types'; import { FormatFactory, LensMultiTable } from '../types'; import { AutoScale } from './auto_scale'; diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_suggestions.test.ts b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts similarity index 98% rename from x-pack/legacy/plugins/lens/public/metric_visualization/metric_suggestions.test.ts rename to x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts index c9bfadbefaf5f..ef93f0b5bf064 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_suggestions.test.ts +++ b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts @@ -5,7 +5,7 @@ */ import { getSuggestions } from './metric_suggestions'; -import { TableSuggestionColumn, TableSuggestion } from '..'; +import { TableSuggestionColumn, TableSuggestion } from '../index'; describe('metric_suggestions', () => { function numCol(columnId: string): TableSuggestionColumn { diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_suggestions.ts b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/metric_visualization/metric_suggestions.ts rename to x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts b/x-pack/plugins/lens/public/metric_visualization/metric_visualization.test.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts rename to x-pack/plugins/lens/public/metric_visualization/metric_visualization.test.ts diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/metric_visualization.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx rename to x-pack/plugins/lens/public/metric_visualization/metric_visualization.tsx diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/types.ts b/x-pack/plugins/lens/public/metric_visualization/types.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/metric_visualization/types.ts rename to x-pack/plugins/lens/public/metric_visualization/types.ts diff --git a/x-pack/legacy/plugins/lens/public/native_renderer/index.ts b/x-pack/plugins/lens/public/native_renderer/index.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/native_renderer/index.ts rename to x-pack/plugins/lens/public/native_renderer/index.ts diff --git a/x-pack/legacy/plugins/lens/public/native_renderer/native_renderer.test.tsx b/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/native_renderer/native_renderer.test.tsx rename to x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx diff --git a/x-pack/legacy/plugins/lens/public/native_renderer/native_renderer.tsx b/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/native_renderer/native_renderer.tsx rename to x-pack/plugins/lens/public/native_renderer/native_renderer.tsx diff --git a/x-pack/legacy/plugins/lens/public/persistence/index.ts b/x-pack/plugins/lens/public/persistence/index.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/persistence/index.ts rename to x-pack/plugins/lens/public/persistence/index.ts diff --git a/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.test.ts b/x-pack/plugins/lens/public/persistence/saved_object_store.test.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/persistence/saved_object_store.test.ts rename to x-pack/plugins/lens/public/persistence/saved_object_store.test.ts diff --git a/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts b/x-pack/plugins/lens/public/persistence/saved_object_store.ts similarity index 94% rename from x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts rename to x-pack/plugins/lens/public/persistence/saved_object_store.ts index ac0b3322b400e..015f4b9b825f4 100644 --- a/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts +++ b/x-pack/plugins/lens/public/persistence/saved_object_store.ts @@ -5,8 +5,8 @@ */ // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { SavedObjectAttributes } from 'src/core/server'; -import { Query, Filter } from '../../../../../../src/plugins/data/public'; +import { SavedObjectAttributes } from 'kibana/server'; +import { Query, Filter } from '../../../../../src/plugins/data/public'; export interface Document { id?: string; diff --git a/x-pack/plugins/lens/public/plugin.tsx b/x-pack/plugins/lens/public/plugin.tsx new file mode 100644 index 0000000000000..8d760eb0df501 --- /dev/null +++ b/x-pack/plugins/lens/public/plugin.tsx @@ -0,0 +1,208 @@ +/* + * 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 { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { HashRouter, Route, RouteComponentProps, Switch } from 'react-router-dom'; +import { render, unmountComponentAtNode } from 'react-dom'; +import rison, { RisonObject, RisonValue } from 'rison-node'; +import { isObject } from 'lodash'; + +import { AppMountParameters, CoreSetup, CoreStart } from 'kibana/public'; +import { DataPublicPluginSetup, DataPublicPluginStart } from 'src/plugins/data/public'; +import { EmbeddableSetup, EmbeddableStart } from 'src/plugins/embeddable/public'; +import { ExpressionsSetup, ExpressionsStart } from 'src/plugins/expressions/public'; +import { VisualizationsSetup } from 'src/plugins/visualizations/public'; +import { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; +import { KibanaLegacySetup } from 'src/plugins/kibana_legacy/public'; +import { DashboardConstants } from '../../../../src/plugins/dashboard/public'; +import { Storage } from '../../../../src/plugins/kibana_utils/public'; +import { EditorFrameService } from './editor_frame_service'; +import { IndexPatternDatasource } from './indexpattern_datasource'; +import { addHelpMenuToAppChrome } from './help_menu_util'; +import { SavedObjectIndexStore } from './persistence'; +import { XyVisualization } from './xy_visualization'; +import { MetricVisualization } from './metric_visualization'; +import { DatatableVisualization } from './datatable_visualization'; +import { App } from './app_plugin'; +import { + LensReportManager, + setReportManager, + stopReportManager, + trackUiEvent, +} from './lens_ui_telemetry'; + +import { UiActionsStart } from '../../../../src/plugins/ui_actions/public'; +import { NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../common'; +import { addEmbeddableToDashboardUrl, getUrlVars } from './helpers'; +import { EditorFrameStart } from './types'; +import { getLensAliasConfig } from './vis_type_alias'; + +import './index.scss'; + +export interface LensPluginSetupDependencies { + kibanaLegacy: KibanaLegacySetup; + expressions: ExpressionsSetup; + data: DataPublicPluginSetup; + embeddable?: EmbeddableSetup; + visualizations: VisualizationsSetup; +} + +export interface LensPluginStartDependencies { + data: DataPublicPluginStart; + embeddable: EmbeddableStart; + expressions: ExpressionsStart; + navigation: NavigationPublicPluginStart; + uiActions: UiActionsStart; +} + +export const isRisonObject = (value: RisonValue): value is RisonObject => { + return isObject(value); +}; +export class LensPlugin { + private datatableVisualization: DatatableVisualization; + private editorFrameService: EditorFrameService; + private createEditorFrame: EditorFrameStart['createInstance'] | null = null; + private indexpatternDatasource: IndexPatternDatasource; + private xyVisualization: XyVisualization; + private metricVisualization: MetricVisualization; + + constructor() { + this.datatableVisualization = new DatatableVisualization(); + this.editorFrameService = new EditorFrameService(); + this.indexpatternDatasource = new IndexPatternDatasource(); + this.xyVisualization = new XyVisualization(); + this.metricVisualization = new MetricVisualization(); + } + + setup( + core: CoreSetup<LensPluginStartDependencies, void>, + { kibanaLegacy, expressions, data, embeddable, visualizations }: LensPluginSetupDependencies + ) { + const editorFrameSetupInterface = this.editorFrameService.setup(core, { + data, + embeddable, + expressions, + }); + const dependencies = { + expressions, + data, + editorFrame: editorFrameSetupInterface, + formatFactory: core + .getStartServices() + .then(([_, { data: dataStart }]) => dataStart.fieldFormats.deserialize), + }; + this.indexpatternDatasource.setup(core, dependencies); + this.xyVisualization.setup(core, dependencies); + this.datatableVisualization.setup(core, dependencies); + this.metricVisualization.setup(core, dependencies); + + visualizations.registerAlias(getLensAliasConfig()); + + kibanaLegacy.registerLegacyApp({ + id: 'lens', + title: NOT_INTERNATIONALIZED_PRODUCT_NAME, + mount: async (params: AppMountParameters) => { + const [coreStart, startDependencies] = await core.getStartServices(); + const { data: dataStart, navigation } = startDependencies; + const savedObjectsClient = coreStart.savedObjects.client; + addHelpMenuToAppChrome(coreStart.chrome, coreStart.docLinks); + + const instance = await this.createEditorFrame!(); + + setReportManager( + new LensReportManager({ + storage: new Storage(localStorage), + http: core.http, + }) + ); + const updateUrlTime = (urlVars: Record<string, string>): void => { + const decoded = rison.decode(urlVars._g); + if (!isRisonObject(decoded)) { + return; + } + // @ts-ignore + decoded.time = dataStart.query.timefilter.timefilter.getTime(); + urlVars._g = rison.encode(decoded); + }; + const redirectTo = ( + routeProps: RouteComponentProps<{ id?: string }>, + addToDashboardMode: boolean, + id?: string + ) => { + if (!id) { + routeProps.history.push('/lens'); + } else if (!addToDashboardMode) { + routeProps.history.push(`/lens/edit/${id}`); + } else if (addToDashboardMode && id) { + routeProps.history.push(`/lens/edit/${id}`); + const lastDashboardLink = coreStart.chrome.navLinks.get('kibana:dashboard'); + if (!lastDashboardLink || !lastDashboardLink.url) { + throw new Error('Cannot get last dashboard url'); + } + const urlVars = getUrlVars(lastDashboardLink.url); + updateUrlTime(urlVars); // we need to pass in timerange in query params directly + const dashboardUrl = addEmbeddableToDashboardUrl(lastDashboardLink.url, id, urlVars); + window.history.pushState({}, '', dashboardUrl); + } + }; + + const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => { + trackUiEvent('loaded'); + const addToDashboardMode = + !!routeProps.location.search && + routeProps.location.search.includes( + DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM + ); + return ( + <App + core={coreStart} + data={dataStart} + navigation={navigation} + editorFrame={instance} + storage={new Storage(localStorage)} + docId={routeProps.match.params.id} + docStorage={new SavedObjectIndexStore(savedObjectsClient)} + redirectTo={id => redirectTo(routeProps, addToDashboardMode, id)} + addToDashboardMode={addToDashboardMode} + /> + ); + }; + + function NotFound() { + trackUiEvent('loaded_404'); + return <FormattedMessage id="xpack.lens.app404" defaultMessage="404 Not Found" />; + } + + render( + <I18nProvider> + <HashRouter> + <Switch> + <Route exact path="/lens/edit/:id" render={renderEditor} /> + <Route exact path="/lens" render={renderEditor} /> + <Route path="/lens" component={NotFound} /> + </Switch> + </HashRouter> + </I18nProvider>, + params.element + ); + return () => { + instance.unmount(); + unmountComponentAtNode(params.element); + }; + }, + }); + } + + start(core: CoreStart, startDependencies: LensPluginStartDependencies) { + this.createEditorFrame = this.editorFrameService.start(core, startDependencies).createInstance; + this.xyVisualization.start(core, startDependencies); + } + + stop() { + stopReportManager(); + } +} diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts new file mode 100644 index 0000000000000..0b432c0c70727 --- /dev/null +++ b/x-pack/plugins/lens/public/types.ts @@ -0,0 +1,422 @@ +/* + * 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 { Ast } from '@kbn/interpreter/common'; +import { IconType } from '@elastic/eui/src/components/icon/icon'; +import { CoreSetup } from 'kibana/public'; +import { KibanaDatatable, SerializedFieldFormat } from '../../../../src/plugins/expressions/public'; +import { DragContextState } from './drag_drop'; +import { Document } from './persistence'; +import { DateRange } from '../common'; +import { Query, Filter, SavedQuery, IFieldFormat } from '../../../../src/plugins/data/public'; + +export type ErrorCallback = (e: { message: string }) => void; + +export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat; + +export interface PublicAPIProps<T> { + state: T; + layerId: string; + dateRange: DateRange; +} + +export interface EditorFrameProps { + onError: ErrorCallback; + doc?: Document; + dateRange: DateRange; + query: Query; + filters: Filter[]; + savedQuery?: SavedQuery; + + // Frame loader (app or embeddable) is expected to call this when it loads and updates + // This should be replaced with a top-down state + onChange: (newState: { + filterableIndexPatterns: DatasourceMetaData['filterableIndexPatterns']; + doc: Document; + }) => void; +} +export interface EditorFrameInstance { + mount: (element: Element, props: EditorFrameProps) => void; + unmount: () => void; +} + +export interface EditorFrameSetup { + // generic type on the API functions to pull the "unknown vs. specific type" error into the implementation + registerDatasource: <T, P>(datasource: Datasource<T, P> | Promise<Datasource<T, P>>) => void; + registerVisualization: <T, P>( + visualization: Visualization<T, P> | Promise<Visualization<T, P>> + ) => void; +} + +export interface EditorFrameStart { + createInstance: () => Promise<EditorFrameInstance>; +} + +// Hints the default nesting to the data source. 0 is the highest priority +export type DimensionPriority = 0 | 1 | 2; + +export interface TableSuggestionColumn { + columnId: string; + operation: Operation; +} + +/** + * A possible table a datasource can create. This object is passed to the visualization + * which tries to build a meaningful visualization given the shape of the table. If this + * is possible, the visualization returns a `VisualizationSuggestion` object + */ +export interface TableSuggestion { + /** + * Flag indicating whether the table will include more than one column. + * This is not the case for example for a single metric aggregation + * */ + isMultiRow: boolean; + /** + * The columns of the table. Each column has to be mapped to a dimension in a chart. If a visualization + * can't use all columns of a suggestion, it should not return a `VisualizationSuggestion` based on it + * because there would be unreferenced columns + */ + columns: TableSuggestionColumn[]; + /** + * The layer this table will replace. This is only relevant if the visualization this suggestion is passed + * is currently active and has multiple layers configured. If this suggestion is applied, the table of this + * layer will be replaced by the columns specified in this suggestion + */ + layerId: string; + /** + * A label describing the table. This can be used to provide a title for the `VisualizationSuggestion`, + * but the visualization can also decide to overwrite it. + */ + label?: string; + /** + * The change type indicates what was changed in this table compared to the currently active table of this layer. + */ + changeType: TableChangeType; +} + +/** + * Indicates what was changed in this table compared to the currently active table of this layer. + * * `initial` means the layer associated with this table does not exist in the current configuration + * * `unchanged` means the table is the same in the currently active configuration + * * `reduced` means the table is a reduced version of the currently active table (some columns dropped, but not all of them) + * * `extended` means the table is an extended version of the currently active table (added one or multiple additional columns) + * * `layers` means the change is a change to the layer structure, not to the table + */ +export type TableChangeType = 'initial' | 'unchanged' | 'reduced' | 'extended' | 'layers'; + +export interface DatasourceSuggestion<T = unknown> { + state: T; + table: TableSuggestion; + keptLayerIds: string[]; +} + +export interface DatasourceMetaData { + filterableIndexPatterns: Array<{ id: string; title: string }>; +} + +export type StateSetter<T> = (newState: T | ((prevState: T) => T)) => void; + +/** + * Interface for the datasource registry + */ +export interface Datasource<T = unknown, P = unknown> { + id: string; + + // For initializing, either from an empty state or from persisted state + // Because this will be called at runtime, state might have a type of `any` and + // datasources should validate their arguments + initialize: (state?: P) => Promise<T>; + + // Given the current state, which parts should be saved? + getPersistableState: (state: T) => P; + + insertLayer: (state: T, newLayerId: string) => T; + removeLayer: (state: T, layerId: string) => T; + clearLayer: (state: T, layerId: string) => T; + getLayers: (state: T) => string[]; + removeColumn: (props: { prevState: T; layerId: string; columnId: string }) => T; + + renderDataPanel: (domElement: Element, props: DatasourceDataPanelProps<T>) => void; + renderDimensionTrigger: (domElement: Element, props: DatasourceDimensionTriggerProps<T>) => void; + renderDimensionEditor: (domElement: Element, props: DatasourceDimensionEditorProps<T>) => void; + renderLayerPanel: (domElement: Element, props: DatasourceLayerPanelProps<T>) => void; + canHandleDrop: (props: DatasourceDimensionDropProps<T>) => boolean; + onDrop: (props: DatasourceDimensionDropHandlerProps<T>) => boolean; + + toExpression: (state: T, layerId: string) => Ast | string | null; + + getMetaData: (state: T) => DatasourceMetaData; + + getDatasourceSuggestionsForField: (state: T, field: unknown) => Array<DatasourceSuggestion<T>>; + getDatasourceSuggestionsFromCurrentState: (state: T) => Array<DatasourceSuggestion<T>>; + + getPublicAPI: (props: PublicAPIProps<T>) => DatasourcePublicAPI; +} + +/** + * This is an API provided to visualizations by the frame, which calls the publicAPI on the datasource + */ +export interface DatasourcePublicAPI { + datasourceId: string; + getTableSpec: () => Array<{ columnId: string }>; + getOperationForColumnId: (columnId: string) => Operation | null; +} + +export interface DatasourceDataPanelProps<T = unknown> { + state: T; + dragDropContext: DragContextState; + setState: StateSetter<T>; + core: Pick<CoreSetup, 'http' | 'notifications' | 'uiSettings'>; + query: Query; + dateRange: DateRange; + filters: Filter[]; +} + +interface SharedDimensionProps { + /** Visualizations can restrict operations based on their own rules. + * For example, limiting to only bucketed or only numeric operations. + */ + filterOperations: (operation: OperationMetadata) => boolean; + + /** Visualizations can hint at the role this dimension would play, which + * affects the default ordering of the query + */ + suggestedPriority?: DimensionPriority; + + /** Some dimension editors will allow users to change the operation grouping + * from the panel, and this lets the visualization hint that it doesn't want + * users to have that level of control + */ + hideGrouping?: boolean; +} + +export type DatasourceDimensionProps<T> = SharedDimensionProps & { + layerId: string; + columnId: string; + onRemove?: (accessor: string) => void; + state: T; +}; + +// The only way a visualization has to restrict the query building +export type DatasourceDimensionEditorProps<T = unknown> = DatasourceDimensionProps<T> & { + setState: StateSetter<T>; + core: Pick<CoreSetup, 'http' | 'notifications' | 'uiSettings'>; + dateRange: DateRange; +}; + +export type DatasourceDimensionTriggerProps<T> = DatasourceDimensionProps<T> & { + dragDropContext: DragContextState; + togglePopover: () => void; +}; + +export interface DatasourceLayerPanelProps<T> { + layerId: string; + state: T; + setState: StateSetter<T>; +} + +export type DatasourceDimensionDropProps<T> = SharedDimensionProps & { + layerId: string; + columnId: string; + state: T; + setState: StateSetter<T>; + dragDropContext: DragContextState; +}; + +export type DatasourceDimensionDropHandlerProps<T> = DatasourceDimensionDropProps<T> & { + droppedItem: unknown; +}; + +export type DataType = 'document' | 'string' | 'number' | 'date' | 'boolean' | 'ip'; + +// An operation represents a column in a table, not any information +// about how the column was created such as whether it is a sum or average. +// Visualizations are able to filter based on the output, not based on the +// underlying data +export interface Operation extends OperationMetadata { + // User-facing label for the operation + label: string; +} + +export interface OperationMetadata { + // The output of this operation will have this data type + dataType: DataType; + // A bucketed operation is grouped by duplicate values, otherwise each row is + // treated as unique + isBucketed: boolean; + scale?: 'ordinal' | 'interval' | 'ratio'; + // Extra meta-information like cardinality, color + // TODO currently it's not possible to differentiate between a field from a raw + // document and an aggregated metric which might be handy in some cases. Once we + // introduce a raw document datasource, this should be considered here. +} + +export interface LensMultiTable { + type: 'lens_multitable'; + tables: Record<string, KibanaDatatable>; + dateRange?: { + fromDate: Date; + toDate: Date; + }; +} + +export interface VisualizationConfigProps<T = unknown> { + layerId: string; + frame: FramePublicAPI; + state: T; +} + +export type VisualizationLayerWidgetProps<T = unknown> = VisualizationConfigProps<T> & { + setState: (newState: T) => void; +}; + +type VisualizationDimensionGroupConfig = SharedDimensionProps & { + groupLabel: string; + + /** ID is passed back to visualization. For example, `x` */ + groupId: string; + accessors: string[]; + supportsMoreColumns: boolean; + /** If required, a warning will appear if accessors are empty */ + required?: boolean; + dataTestSubj?: string; +}; + +interface VisualizationDimensionChangeProps<T> { + layerId: string; + columnId: string; + prevState: T; +} + +/** + * Object passed to `getSuggestions` of a visualization. + * It contains a possible table the current datasource could + * provide and the state of the visualization if it is currently active. + * + * If the current datasource suggests multiple tables, `getSuggestions` + * is called multiple times with separate `SuggestionRequest` objects. + */ +export interface SuggestionRequest<T = unknown> { + /** + * A table configuration the datasource could provide. + */ + table: TableSuggestion; + /** + * State is only passed if the visualization is active. + */ + state?: T; + /** + * The visualization needs to know which table is being suggested + */ + keptLayerIds: string[]; +} + +/** + * A possible configuration of a given visualization. It is based on a `TableSuggestion`. + * Suggestion might be shown in the UI to be chosen by the user directly, but they are + * also applied directly under some circumstances (dragging in the first field from the data + * panel or switching to another visualization in the chart switcher). + */ +export interface VisualizationSuggestion<T = unknown> { + /** + * The score of a suggestion should indicate how valuable the suggestion is. It is used + * to rank multiple suggestions of multiple visualizations. The number should be between 0 and 1 + */ + score: number; + /** + * Flag indicating whether this suggestion should not be advertised to the user. It is still + * considered in scenarios where the available suggestion with the highest suggestion is applied + * directly. + */ + hide?: boolean; + /** + * Descriptive title of the suggestion. Should be as short as possible. This title is shown if + * the suggestion is advertised to the user and will also show either the `previewExpression` or + * the `previewIcon` + */ + title: string; + /** + * The new state of the visualization if this suggestion is applied. + */ + state: T; + /** + * An EUI icon type shown instead of the preview expression. + */ + previewIcon: IconType; +} + +export interface FramePublicAPI { + datasourceLayers: Record<string, DatasourcePublicAPI>; + + dateRange: DateRange; + query: Query; + filters: Filter[]; + + // Adds a new layer. This has a side effect of updating the datasource state + addNewLayer: () => string; + removeLayers: (layerIds: string[]) => void; +} + +export interface VisualizationType { + id: string; + icon?: IconType; + largeIcon?: IconType; + label: string; +} + +export interface Visualization<T = unknown, P = unknown> { + id: string; + + visualizationTypes: VisualizationType[]; + + getLayerIds: (state: T) => string[]; + clearLayer: (state: T, layerId: string) => T; + removeLayer?: (state: T, layerId: string) => T; + appendLayer?: (state: T, layerId: string) => T; + + // Layer context menu is used by visualizations for styling the entire layer + // For example, the XY visualization uses this to have multiple chart types + getLayerContextMenuIcon?: (opts: { state: T; layerId: string }) => IconType | undefined; + renderLayerContextMenu?: (domElement: Element, props: VisualizationLayerWidgetProps<T>) => void; + + getConfiguration: ( + props: VisualizationConfigProps<T> + ) => { groups: VisualizationDimensionGroupConfig[] }; + + getDescription: ( + state: T + ) => { + icon?: IconType; + label: string; + }; + + switchVisualizationType?: (visualizationTypeId: string, state: T) => T; + + // For initializing from saved object + initialize: (frame: FramePublicAPI, state?: P) => T; + + getPersistableState: (state: T) => P; + + // Actions triggered by the frame which tell the datasource that a dimension is being changed + setDimension: ( + props: VisualizationDimensionChangeProps<T> & { + groupId: string; + } + ) => T; + removeDimension: (props: VisualizationDimensionChangeProps<T>) => T; + + toExpression: (state: T, frame: FramePublicAPI) => Ast | string | null; + + /** + * Epression to render a preview version of the chart in very constraint space. + * If there is no expression provided, the preview icon is used. + */ + toPreviewExpression?: (state: T, frame: FramePublicAPI) => Ast | string | null; + + // The frame will call this function on all visualizations when the table changes, or when + // rendering additional ways of using the data + getSuggestions: (context: SuggestionRequest<T>) => Array<VisualizationSuggestion<T>>; +} diff --git a/x-pack/legacy/plugins/lens/public/vis_type_alias.ts b/x-pack/plugins/lens/public/vis_type_alias.ts similarity index 95% rename from x-pack/legacy/plugins/lens/public/vis_type_alias.ts rename to x-pack/plugins/lens/public/vis_type_alias.ts index 123b994e6ccce..807504ee2b9c2 100644 --- a/x-pack/legacy/plugins/lens/public/vis_type_alias.ts +++ b/x-pack/plugins/lens/public/vis_type_alias.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { VisTypeAlias } from 'src/plugins/visualizations/public'; -import { getBasePath, getEditPath } from '../../../../plugins/lens/common'; +import { getBasePath, getEditPath } from '../common'; export const getLensAliasConfig = (): VisTypeAlias => ({ aliasUrl: getBasePath(), diff --git a/x-pack/legacy/plugins/lens/public/visualization_container.test.tsx b/x-pack/plugins/lens/public/visualization_container.test.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/visualization_container.test.tsx rename to x-pack/plugins/lens/public/visualization_container.test.tsx diff --git a/x-pack/legacy/plugins/lens/public/visualization_container.tsx b/x-pack/plugins/lens/public/visualization_container.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/visualization_container.tsx rename to x-pack/plugins/lens/public/visualization_container.tsx diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap rename to x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap rename to x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap diff --git a/x-pack/plugins/lens/public/xy_visualization/_index.scss b/x-pack/plugins/lens/public/xy_visualization/_index.scss new file mode 100644 index 0000000000000..110a9589a6fb4 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/_index.scss @@ -0,0 +1 @@ +@import 'xy_expression'; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/_xy_expression.scss b/x-pack/plugins/lens/public/xy_visualization/_xy_expression.scss similarity index 100% rename from x-pack/legacy/plugins/lens/public/xy_visualization/_xy_expression.scss rename to x-pack/plugins/lens/public/xy_visualization/_xy_expression.scss diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts new file mode 100644 index 0000000000000..5dfae097be834 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/index.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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 { CoreSetup, IUiSettingsClient, CoreStart } from 'kibana/public'; +import moment from 'moment-timezone'; +import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; +import { xyVisualization } from './xy_visualization'; +import { xyChart, getXyChartRenderer } from './xy_expression'; +import { legendConfig, xConfig, layerConfig } from './types'; +import { EditorFrameSetup, FormatFactory } from '../types'; +import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; +import { setExecuteTriggerActions } from './services'; + +export interface XyVisualizationPluginSetupPlugins { + expressions: ExpressionsSetup; + formatFactory: Promise<FormatFactory>; + editorFrame: EditorFrameSetup; +} + +interface XyVisualizationPluginStartPlugins { + uiActions: UiActionsStart; +} + +function getTimeZone(uiSettings: IUiSettingsClient) { + const configuredTimeZone = uiSettings.get('dateFormat:tz'); + if (configuredTimeZone === 'Browser') { + return moment.tz.guess(); + } + + return configuredTimeZone; +} + +export class XyVisualization { + constructor() {} + + setup( + core: CoreSetup, + { expressions, formatFactory, editorFrame }: XyVisualizationPluginSetupPlugins + ) { + expressions.registerFunction(() => legendConfig); + expressions.registerFunction(() => xConfig); + expressions.registerFunction(() => layerConfig); + expressions.registerFunction(() => xyChart); + + expressions.registerRenderer( + getXyChartRenderer({ + formatFactory, + chartTheme: core.uiSettings.get<boolean>('theme:darkMode') + ? EUI_CHARTS_THEME_DARK.theme + : EUI_CHARTS_THEME_LIGHT.theme, + timeZone: getTimeZone(core.uiSettings), + }) + ); + + editorFrame.registerVisualization(xyVisualization); + } + start(core: CoreStart, { uiActions }: XyVisualizationPluginStartPlugins) { + setExecuteTriggerActions(uiActions.executeTriggerActions); + } +} diff --git a/x-pack/plugins/lens/public/xy_visualization/services.ts b/x-pack/plugins/lens/public/xy_visualization/services.ts new file mode 100644 index 0000000000000..51289fe0c63e7 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/services.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createGetterSetter } from '../../../../../src/plugins/kibana_utils/public'; +import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; + +export const [getExecuteTriggerActions, setExecuteTriggerActions] = createGetterSetter< + UiActionsStart['executeTriggerActions'] +>('executeTriggerActions'); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/state_helpers.ts b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/xy_visualization/state_helpers.ts rename to x-pack/plugins/lens/public/xy_visualization/state_helpers.ts diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/xy_visualization/to_expression.test.ts rename to x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/xy_visualization/to_expression.ts rename to x-pack/plugins/lens/public/xy_visualization/to_expression.ts diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts new file mode 100644 index 0000000000000..7a5837d382c7b --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -0,0 +1,282 @@ +/* + * 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 { Position } from '@elastic/charts'; +import { i18n } from '@kbn/i18n'; +import { ArgumentType, ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import chartAreaSVG from '../assets/chart_area.svg'; +import chartAreaStackedSVG from '../assets/chart_area_stacked.svg'; +import chartBarSVG from '../assets/chart_bar.svg'; +import chartBarStackedSVG from '../assets/chart_bar_stacked.svg'; +import chartBarHorizontalSVG from '../assets/chart_bar_horizontal.svg'; +import chartBarHorizontalStackedSVG from '../assets/chart_bar_horizontal_stacked.svg'; +import chartLineSVG from '../assets/chart_line.svg'; + +import { VisualizationType } from '../index'; + +export interface LegendConfig { + isVisible: boolean; + position: Position; +} + +type LegendConfigResult = LegendConfig & { type: 'lens_xy_legendConfig' }; + +export const legendConfig: ExpressionFunctionDefinition< + 'lens_xy_legendConfig', + null, + LegendConfig, + LegendConfigResult +> = { + name: 'lens_xy_legendConfig', + aliases: [], + type: 'lens_xy_legendConfig', + help: `Configure the xy chart's legend`, + inputTypes: ['null'], + args: { + isVisible: { + types: ['boolean'], + help: i18n.translate('xpack.lens.xyChart.isVisible.help', { + defaultMessage: 'Specifies whether or not the legend is visible.', + }), + }, + position: { + types: ['string'], + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], + help: i18n.translate('xpack.lens.xyChart.position.help', { + defaultMessage: 'Specifies the legend position.', + }), + }, + }, + fn: function fn(input: unknown, args: LegendConfig) { + return { + type: 'lens_xy_legendConfig', + ...args, + }; + }, +}; + +interface AxisConfig { + title: string; + hide?: boolean; +} + +const axisConfig: { [key in keyof AxisConfig]: ArgumentType<AxisConfig[key]> } = { + title: { + types: ['string'], + help: i18n.translate('xpack.lens.xyChart.title.help', { + defaultMessage: 'The axis title', + }), + }, + hide: { + types: ['boolean'], + default: false, + help: 'Show / hide axis', + }, +}; + +export interface YState extends AxisConfig { + accessors: string[]; +} + +export interface XConfig extends AxisConfig { + accessor: string; +} + +type XConfigResult = XConfig & { type: 'lens_xy_xConfig' }; + +export const xConfig: ExpressionFunctionDefinition< + 'lens_xy_xConfig', + null, + XConfig, + XConfigResult +> = { + name: 'lens_xy_xConfig', + aliases: [], + type: 'lens_xy_xConfig', + help: `Configure the xy chart's x axis`, + inputTypes: ['null'], + args: { + ...axisConfig, + accessor: { + types: ['string'], + help: 'The column to display on the x axis.', + }, + }, + fn: function fn(input: unknown, args: XConfig) { + return { + type: 'lens_xy_xConfig', + ...args, + }; + }, +}; + +type LayerConfigResult = LayerArgs & { type: 'lens_xy_layer' }; + +export const layerConfig: ExpressionFunctionDefinition< + 'lens_xy_layer', + null, + LayerArgs, + LayerConfigResult +> = { + name: 'lens_xy_layer', + aliases: [], + type: 'lens_xy_layer', + help: `Configure a layer in the xy chart`, + inputTypes: ['null'], + args: { + ...axisConfig, + layerId: { + types: ['string'], + help: '', + }, + xAccessor: { + types: ['string'], + help: '', + }, + seriesType: { + types: ['string'], + options: ['bar', 'line', 'area', 'bar_stacked', 'area_stacked'], + help: 'The type of chart to display.', + }, + xScaleType: { + options: ['ordinal', 'linear', 'time'], + help: 'The scale type of the x axis', + default: 'ordinal', + }, + isHistogram: { + types: ['boolean'], + default: false, + help: 'Whether to layout the chart as a histogram', + }, + yScaleType: { + options: ['log', 'sqrt', 'linear', 'time'], + help: 'The scale type of the y axes', + default: 'linear', + }, + splitAccessor: { + types: ['string'], + help: 'The column to split by', + multi: false, + }, + accessors: { + types: ['string'], + help: 'The columns to display on the y axis.', + multi: true, + }, + columnToLabel: { + types: ['string'], + help: 'JSON key-value pairs of column ID to label', + }, + }, + fn: function fn(input: unknown, args: LayerArgs) { + return { + type: 'lens_xy_layer', + ...args, + }; + }, +}; + +export type SeriesType = + | 'bar' + | 'bar_horizontal' + | 'line' + | 'area' + | 'bar_stacked' + | 'bar_horizontal_stacked' + | 'area_stacked'; + +export interface LayerConfig { + hide?: boolean; + layerId: string; + xAccessor?: string; + accessors: string[]; + seriesType: SeriesType; + splitAccessor?: string; +} + +export type LayerArgs = LayerConfig & { + columnToLabel?: string; // Actually a JSON key-value pair + yScaleType: 'time' | 'linear' | 'log' | 'sqrt'; + xScaleType: 'time' | 'linear' | 'ordinal'; + isHistogram: boolean; +}; + +// Arguments to XY chart expression, with computed properties +export interface XYArgs { + xTitle: string; + yTitle: string; + legend: LegendConfig & { type: 'lens_xy_legendConfig' }; + layers: LayerArgs[]; +} + +// Persisted parts of the state +export interface XYState { + preferredSeriesType: SeriesType; + legend: LegendConfig; + layers: LayerConfig[]; +} + +export type State = XYState; +export type PersistableState = XYState; + +export const visualizationTypes: VisualizationType[] = [ + { + id: 'bar', + icon: 'visBarVertical', + largeIcon: chartBarSVG, + label: i18n.translate('xpack.lens.xyVisualization.barLabel', { + defaultMessage: 'Bar', + }), + }, + { + id: 'bar_horizontal', + icon: 'visBarHorizontal', + largeIcon: chartBarHorizontalSVG, + label: i18n.translate('xpack.lens.xyVisualization.barHorizontalLabel', { + defaultMessage: 'Horizontal bar', + }), + }, + { + id: 'bar_stacked', + icon: 'visBarVerticalStacked', + largeIcon: chartBarStackedSVG, + label: i18n.translate('xpack.lens.xyVisualization.stackedBarLabel', { + defaultMessage: 'Stacked bar', + }), + }, + { + id: 'bar_horizontal_stacked', + icon: 'visBarHorizontalStacked', + largeIcon: chartBarHorizontalStackedSVG, + label: i18n.translate('xpack.lens.xyVisualization.stackedBarHorizontalLabel', { + defaultMessage: 'Stacked horizontal bar', + }), + }, + { + id: 'line', + icon: 'visLine', + largeIcon: chartLineSVG, + label: i18n.translate('xpack.lens.xyVisualization.lineLabel', { + defaultMessage: 'Line', + }), + }, + { + id: 'area', + icon: 'visArea', + largeIcon: chartAreaSVG, + label: i18n.translate('xpack.lens.xyVisualization.areaLabel', { + defaultMessage: 'Area', + }), + }, + { + id: 'area_stacked', + icon: 'visAreaStacked', + largeIcon: chartAreaStackedSVG, + label: i18n.translate('xpack.lens.xyVisualization.stackedAreaLabel', { + defaultMessage: 'Stacked area', + }), + }, +]; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/xy_visualization/xy_config_panel.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx new file mode 100644 index 0000000000000..80d33d1b95b61 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx @@ -0,0 +1,984 @@ +/* + * 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 { + AreaSeries, + Axis, + BarSeries, + Position, + LineSeries, + Settings, + ScaleType, + GeometryValue, + XYChartSeriesIdentifier, + SeriesNameFn, +} from '@elastic/charts'; +import { xyChart, XYChart } from './xy_expression'; +import { LensMultiTable } from '../types'; +import { KibanaDatatable, KibanaDatatableRow } from '../../../../../src/plugins/expressions/public'; +import React from 'react'; +import { shallow } from 'enzyme'; +import { XYArgs, LegendConfig, legendConfig, layerConfig, LayerArgs } from './types'; +import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; + +const executeTriggerActions = jest.fn(); + +const createSampleDatatableWithRows = (rows: KibanaDatatableRow[]): KibanaDatatable => ({ + type: 'kibana_datatable', + columns: [ + { + id: 'a', + name: 'a', + formatHint: { id: 'number', params: { pattern: '0,0.000' } }, + }, + { id: 'b', name: 'b', formatHint: { id: 'number', params: { pattern: '000,0' } } }, + { + id: 'c', + name: 'c', + formatHint: { id: 'string' }, + meta: { type: 'date-histogram', aggConfigParams: { interval: '10s' } }, + }, + { id: 'd', name: 'ColD', formatHint: { id: 'string' } }, + ], + rows, +}); + +const sampleLayer: LayerArgs = { + layerId: 'first', + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + xScaleType: 'ordinal', + yScaleType: 'linear', + isHistogram: false, +}; + +const createArgsWithLayers = (layers: LayerArgs[] = [sampleLayer]): XYArgs => ({ + xTitle: '', + yTitle: '', + legend: { + type: 'lens_xy_legendConfig', + isVisible: false, + position: Position.Top, + }, + layers, +}); + +function sampleArgs() { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: createSampleDatatableWithRows([ + { a: 1, b: 2, c: 'I', d: 'Foo' }, + { a: 1, b: 5, c: 'J', d: 'Bar' }, + ]), + }, + }; + + const args: XYArgs = createArgsWithLayers(); + + return { data, args }; +} + +describe('xy_expression', () => { + describe('configs', () => { + test('legendConfig produces the correct arguments', () => { + const args: LegendConfig = { + isVisible: true, + position: Position.Left, + }; + + const result = legendConfig.fn(null, args, createMockExecutionContext()); + + expect(result).toEqual({ + type: 'lens_xy_legendConfig', + ...args, + }); + }); + + test('layerConfig produces the correct arguments', () => { + const args: LayerArgs = { + layerId: 'first', + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + xScaleType: 'linear', + yScaleType: 'linear', + isHistogram: false, + }; + + const result = layerConfig.fn(null, args, createMockExecutionContext()); + + expect(result).toEqual({ + type: 'lens_xy_layer', + ...args, + }); + }); + }); + + describe('xyChart', () => { + test('it renders with the specified data and args', () => { + const { data, args } = sampleArgs(); + const result = xyChart.fn(data, args, createMockExecutionContext()); + + expect(result).toEqual({ + type: 'render', + as: 'lens_xy_chart_renderer', + value: { data, args }, + }); + }); + }); + + describe('XYChart component', () => { + let getFormatSpy: jest.Mock; + let convertSpy: jest.Mock; + + beforeEach(() => { + convertSpy = jest.fn(x => x); + getFormatSpy = jest.fn(); + getFormatSpy.mockReturnValue({ convert: convertSpy }); + }); + + test('it renders line', () => { + const { data, args } = sampleArgs(); + + const component = shallow( + <XYChart + data={data} + args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'line' }] }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + expect(component).toMatchSnapshot(); + expect(component.find(LineSeries)).toHaveLength(1); + }); + + describe('date range', () => { + const timeSampleLayer: LayerArgs = { + layerId: 'first', + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + xScaleType: 'time', + yScaleType: 'linear', + isHistogram: false, + }; + const multiLayerArgs = createArgsWithLayers([ + timeSampleLayer, + { + ...timeSampleLayer, + layerId: 'second', + seriesType: 'bar', + xScaleType: 'time', + }, + ]); + test('it uses the full date range', () => { + const { data, args } = sampleArgs(); + + const component = shallow( + <XYChart + data={{ + ...data, + dateRange: { + fromDate: new Date('2019-01-02T05:00:00.000Z'), + toDate: new Date('2019-01-03T05:00:00.000Z'), + }, + }} + args={{ + ...args, + layers: [{ ...args.layers[0], seriesType: 'line', xScaleType: 'time' }], + }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(` + Object { + "max": 1546491600000, + "min": 1546405200000, + "minInterval": undefined, + } + `); + }); + + test('it generates correct xDomain for a layer with single value and a layer with no data (1-0) ', () => { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: createSampleDatatableWithRows([{ a: 1, b: 2, c: 'I', d: 'Foo' }]), + second: createSampleDatatableWithRows([]), + }, + }; + + const component = shallow( + <XYChart + data={{ + ...data, + dateRange: { + fromDate: new Date('2019-01-02T05:00:00.000Z'), + toDate: new Date('2019-01-03T05:00:00.000Z'), + }, + }} + args={multiLayerArgs} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + + expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(` + Object { + "max": 1546491600000, + "min": 1546405200000, + "minInterval": 10000, + } + `); + }); + + test('it generates correct xDomain for two layers with single value(1-1)', () => { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: createSampleDatatableWithRows([{ a: 1, b: 2, c: 'I', d: 'Foo' }]), + second: createSampleDatatableWithRows([{ a: 10, b: 5, c: 'J', d: 'Bar' }]), + }, + }; + const component = shallow( + <XYChart + data={{ + ...data, + dateRange: { + fromDate: new Date('2019-01-02T05:00:00.000Z'), + toDate: new Date('2019-01-03T05:00:00.000Z'), + }, + }} + args={multiLayerArgs} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + + expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(` + Object { + "max": 1546491600000, + "min": 1546405200000, + "minInterval": 10000, + } + `); + }); + test('it generates correct xDomain for a layer with single value and layer with multiple value data (1-n)', () => { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: createSampleDatatableWithRows([{ a: 1, b: 2, c: 'I', d: 'Foo' }]), + second: createSampleDatatableWithRows([ + { a: 10, b: 5, c: 'J', d: 'Bar' }, + { a: 8, b: 5, c: 'K', d: 'Buzz' }, + ]), + }, + }; + const component = shallow( + <XYChart + data={{ + ...data, + dateRange: { + fromDate: new Date('2019-01-02T05:00:00.000Z'), + toDate: new Date('2019-01-03T05:00:00.000Z'), + }, + }} + args={multiLayerArgs} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + + expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(` + Object { + "max": 1546491600000, + "min": 1546405200000, + "minInterval": undefined, + } + `); + }); + + test('it generates correct xDomain for 2 layers with multiple value data (n-n)', () => { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: createSampleDatatableWithRows([ + { a: 1, b: 2, c: 'I', d: 'Foo' }, + { a: 8, b: 5, c: 'K', d: 'Buzz' }, + { a: 9, b: 7, c: 'L', d: 'Bar' }, + { a: 10, b: 2, c: 'G', d: 'Bear' }, + ]), + second: createSampleDatatableWithRows([ + { a: 10, b: 5, c: 'J', d: 'Bar' }, + { a: 8, b: 4, c: 'K', d: 'Fi' }, + { a: 1, b: 8, c: 'O', d: 'Pi' }, + ]), + }, + }; + const component = shallow( + <XYChart + data={{ + ...data, + dateRange: { + fromDate: new Date('2019-01-02T05:00:00.000Z'), + toDate: new Date('2019-01-03T05:00:00.000Z'), + }, + }} + args={multiLayerArgs} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + + expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(` + Object { + "max": 1546491600000, + "min": 1546405200000, + "minInterval": undefined, + } + `); + }); + }); + + test('it does not use date range if the x is not a time scale', () => { + const { data, args } = sampleArgs(); + + const component = shallow( + <XYChart + data={{ + ...data, + dateRange: { + fromDate: new Date('2019-01-02T05:00:00.000Z'), + toDate: new Date('2019-01-03T05:00:00.000Z'), + }, + }} + args={{ + ...args, + layers: [{ ...args.layers[0], seriesType: 'line', xScaleType: 'linear' }], + }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + expect(component.find(Settings).prop('xDomain')).toBeUndefined(); + }); + + test('it renders bar', () => { + const { data, args } = sampleArgs(); + const component = shallow( + <XYChart + data={data} + args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'bar' }] }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + expect(component).toMatchSnapshot(); + expect(component.find(BarSeries)).toHaveLength(1); + }); + + test('it renders area', () => { + const { data, args } = sampleArgs(); + const component = shallow( + <XYChart + data={data} + args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'area' }] }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + expect(component).toMatchSnapshot(); + expect(component.find(AreaSeries)).toHaveLength(1); + }); + + test('it renders horizontal bar', () => { + const { data, args } = sampleArgs(); + const component = shallow( + <XYChart + data={data} + args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'bar_horizontal' }] }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + expect(component).toMatchSnapshot(); + expect(component.find(BarSeries)).toHaveLength(1); + expect(component.find(Settings).prop('rotation')).toEqual(90); + }); + + test('onElementClick returns correct context data', () => { + const geometry: GeometryValue = { x: 5, y: 1, accessor: 'y1' }; + const series = { + key: 'spec{d}yAccessor{d}splitAccessors{b-2}', + specId: 'd', + yAccessor: 'd', + splitAccessors: {}, + seriesKeys: [2, 'd'], + }; + + const { args, data } = sampleArgs(); + + const wrapper = mountWithIntl( + <XYChart + data={data} + args={{ + ...args, + layers: [ + { + layerId: 'first', + isHistogram: true, + seriesType: 'bar_stacked', + xAccessor: 'b', + yScaleType: 'linear', + xScaleType: 'time', + splitAccessor: 'b', + accessors: ['d'], + columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + }, + ], + }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + + wrapper + .find(Settings) + .first() + .prop('onElementClick')!([[geometry, series as XYChartSeriesIdentifier]]); + + expect(executeTriggerActions).toHaveBeenCalledWith('VALUE_CLICK_TRIGGER', { + data: { + data: [ + { + column: 1, + row: 1, + table: data.tables.first, + value: 5, + }, + { + column: 1, + row: 0, + table: data.tables.first, + value: 2, + }, + ], + }, + }); + }); + + test('it renders stacked bar', () => { + const { data, args } = sampleArgs(); + const component = shallow( + <XYChart + data={data} + args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'bar_stacked' }] }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + expect(component).toMatchSnapshot(); + expect(component.find(BarSeries)).toHaveLength(1); + expect(component.find(BarSeries).prop('stackAccessors')).toHaveLength(1); + }); + + test('it renders stacked area', () => { + const { data, args } = sampleArgs(); + const component = shallow( + <XYChart + data={data} + args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'area_stacked' }] }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + expect(component).toMatchSnapshot(); + expect(component.find(AreaSeries)).toHaveLength(1); + expect(component.find(AreaSeries).prop('stackAccessors')).toHaveLength(1); + }); + + test('it renders stacked horizontal bar', () => { + const { data, args } = sampleArgs(); + const component = shallow( + <XYChart + data={data} + args={{ + ...args, + layers: [{ ...args.layers[0], seriesType: 'bar_horizontal_stacked' }], + }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + expect(component).toMatchSnapshot(); + expect(component.find(BarSeries)).toHaveLength(1); + expect(component.find(BarSeries).prop('stackAccessors')).toHaveLength(1); + expect(component.find(Settings).prop('rotation')).toEqual(90); + }); + + test('it passes time zone to the series', () => { + const { data, args } = sampleArgs(); + const component = shallow( + <XYChart + data={data} + args={args} + formatFactory={getFormatSpy} + timeZone="CEST" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + expect(component.find(LineSeries).prop('timeZone')).toEqual('CEST'); + }); + + test('it applies histogram mode to the series for single series', () => { + const { data, args } = sampleArgs(); + const firstLayer: LayerArgs = { ...args.layers[0], seriesType: 'bar', isHistogram: true }; + delete firstLayer.splitAccessor; + const component = shallow( + <XYChart + data={data} + args={{ ...args, layers: [firstLayer] }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); + }); + + test('it applies histogram mode to the series for stacked series', () => { + const { data, args } = sampleArgs(); + const component = shallow( + <XYChart + data={data} + args={{ + ...args, + layers: [ + { + ...args.layers[0], + seriesType: 'bar_stacked', + isHistogram: true, + }, + ], + }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); + }); + + test('it does not apply histogram mode for splitted series', () => { + const { data, args } = sampleArgs(); + const component = shallow( + <XYChart + data={data} + args={{ + ...args, + layers: [{ ...args.layers[0], seriesType: 'bar', isHistogram: true }], + }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(false); + }); + + describe('provides correct series naming', () => { + const dataWithoutFormats: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + { id: 'c', name: 'c' }, + { id: 'd', name: 'd' }, + ], + rows: [ + { a: 1, b: 2, c: 'I', d: 'Row 1' }, + { a: 1, b: 5, c: 'J', d: 'Row 2' }, + ], + }, + }, + }; + const dataWithFormats: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + { id: 'c', name: 'c' }, + { id: 'd', name: 'd', formatHint: { id: 'custom' } }, + ], + rows: [ + { a: 1, b: 2, c: 'I', d: 'Row 1' }, + { a: 1, b: 5, c: 'J', d: 'Row 2' }, + ], + }, + }, + }; + + const nameFnArgs = { + seriesKeys: [], + key: '', + specId: 'a', + yAccessor: '', + splitAccessors: new Map(), + }; + + const getRenderedComponent = (data: LensMultiTable, args: XYArgs) => { + return shallow( + <XYChart + data={data} + args={args} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + }; + + test('simplest xy chart without human-readable name', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['a'], + splitAccessor: undefined, + columnToLabel: '', + }, + ], + }; + + const component = getRenderedComponent(dataWithoutFormats, newArgs); + const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + + // In this case, the ID is used as the name. This shouldn't happen in practice + expect(nameFn({ ...nameFnArgs, seriesKeys: ['a'] }, false)).toEqual(''); + expect(nameFn({ ...nameFnArgs, seriesKeys: ['nonsense'] }, false)).toEqual(''); + }); + + test('simplest xy chart with empty name', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['a'], + splitAccessor: undefined, + columnToLabel: '{"a":""}', + }, + ], + }; + + const component = getRenderedComponent(dataWithoutFormats, newArgs); + const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + + // In this case, the ID is used as the name. This shouldn't happen in practice + expect(nameFn({ ...nameFnArgs, seriesKeys: ['a'] }, false)).toEqual(''); + expect(nameFn({ ...nameFnArgs, seriesKeys: ['nonsense'] }, false)).toEqual(''); + }); + + test('simplest xy chart with human-readable name', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['a'], + splitAccessor: undefined, + columnToLabel: '{"a":"Column A"}', + }, + ], + }; + + const component = getRenderedComponent(dataWithoutFormats, newArgs); + const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + + expect(nameFn({ ...nameFnArgs, seriesKeys: ['a'] }, false)).toEqual('Column A'); + }); + + test('multiple y accessors', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['a', 'b'], + splitAccessor: undefined, + columnToLabel: '{"a": "Label A"}', + }, + ], + }; + + const component = getRenderedComponent(dataWithoutFormats, newArgs); + const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + + // This accessor has a human-readable name + expect(nameFn({ ...nameFnArgs, seriesKeys: ['a'] }, false)).toEqual('Label A'); + // This accessor does not + expect(nameFn({ ...nameFnArgs, seriesKeys: ['b'] }, false)).toEqual(''); + expect(nameFn({ ...nameFnArgs, seriesKeys: ['nonsense'] }, false)).toEqual(''); + }); + + test('split series without formatting and single y accessor', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['a'], + splitAccessor: 'd', + columnToLabel: '{"a": "Label A"}', + }, + ], + }; + + const component = getRenderedComponent(dataWithoutFormats, newArgs); + const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + + expect(nameFn({ ...nameFnArgs, seriesKeys: ['split1', 'a'] }, false)).toEqual('split1'); + }); + + test('split series with formatting and single y accessor', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['a'], + splitAccessor: 'd', + columnToLabel: '{"a": "Label A"}', + }, + ], + }; + + const component = getRenderedComponent(dataWithFormats, newArgs); + const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + + convertSpy.mockReturnValueOnce('formatted'); + expect(nameFn({ ...nameFnArgs, seriesKeys: ['split1', 'a'] }, false)).toEqual('formatted'); + expect(getFormatSpy).toHaveBeenCalledWith({ id: 'custom' }); + }); + + test('split series without formatting with multiple y accessors', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['a', 'b'], + splitAccessor: 'd', + columnToLabel: '{"a": "Label A","b": "Label B"}', + }, + ], + }; + + const component = getRenderedComponent(dataWithoutFormats, newArgs); + const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + + expect(nameFn({ ...nameFnArgs, seriesKeys: ['split1', 'b'] }, false)).toEqual( + 'split1 - Label B' + ); + }); + + test('split series with formatting with multiple y accessors', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['a', 'b'], + splitAccessor: 'd', + columnToLabel: '{"a": "Label A","b": "Label B"}', + }, + ], + }; + + const component = getRenderedComponent(dataWithFormats, newArgs); + const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + + convertSpy.mockReturnValueOnce('formatted1').mockReturnValueOnce('formatted2'); + expect(nameFn({ ...nameFnArgs, seriesKeys: ['split1', 'a'] }, false)).toEqual( + 'formatted1 - Label A' + ); + expect(nameFn({ ...nameFnArgs, seriesKeys: ['split1', 'b'] }, false)).toEqual( + 'formatted2 - Label B' + ); + }); + }); + + test('it set the scale of the x axis according to the args prop', () => { + const { data, args } = sampleArgs(); + + const component = shallow( + <XYChart + data={data} + args={{ ...args, layers: [{ ...args.layers[0], xScaleType: 'ordinal' }] }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + expect(component.find(LineSeries).prop('xScaleType')).toEqual(ScaleType.Ordinal); + }); + + test('it set the scale of the y axis according to the args prop', () => { + const { data, args } = sampleArgs(); + + const component = shallow( + <XYChart + data={data} + args={{ ...args, layers: [{ ...args.layers[0], yScaleType: 'sqrt' }] }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + expect(component.find(LineSeries).prop('yScaleType')).toEqual(ScaleType.Sqrt); + }); + + test('it gets the formatter for the x axis', () => { + const { data, args } = sampleArgs(); + + shallow( + <XYChart + data={{ ...data }} + args={{ ...args }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + + expect(getFormatSpy).toHaveBeenCalledWith({ id: 'string' }); + }); + + test('it gets a default formatter for y if there are multiple y accessors', () => { + const { data, args } = sampleArgs(); + + shallow( + <XYChart + data={{ ...data }} + args={{ ...args }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + + expect(getFormatSpy).toHaveBeenCalledWith({ id: 'number' }); + }); + + test('it gets the formatter for the y axis if there is only one accessor', () => { + const { data, args } = sampleArgs(); + + shallow( + <XYChart + data={{ ...data }} + args={{ ...args, layers: [{ ...args.layers[0], accessors: ['a'] }] }} + formatFactory={getFormatSpy} + chartTheme={{}} + timeZone="UTC" + executeTriggerActions={executeTriggerActions} + /> + ); + expect(getFormatSpy).toHaveBeenCalledWith({ + id: 'number', + params: { pattern: '0,0.000' }, + }); + }); + + test('it should pass the formatter function to the axis', () => { + const { data, args } = sampleArgs(); + + const instance = shallow( + <XYChart + data={{ ...data }} + args={{ ...args }} + formatFactory={getFormatSpy} + timeZone="UTC" + chartTheme={{}} + executeTriggerActions={executeTriggerActions} + /> + ); + + const tickFormatter = instance + .find(Axis) + .first() + .prop('tickFormat'); + + if (!tickFormatter) { + throw new Error('tickFormatter prop not found'); + } + + tickFormatter('I'); + + expect(convertSpy).toHaveBeenCalledWith('I'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx similarity index 88% rename from x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx index f5798688badc5..f12a0e5b907c7 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -28,14 +28,14 @@ import { import { EuiIcon, EuiText, IconType, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { EmbeddableVisTriggerContext } from '../../../../../../src/plugins/embeddable/public'; -import { VIS_EVENT_TO_TRIGGER } from '../../../../../../src/plugins/visualizations/public'; +import { EmbeddableVisTriggerContext } from '../../../../../src/plugins/embeddable/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public'; import { LensMultiTable, FormatFactory } from '../types'; import { XYArgs, SeriesType, visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart } from './state_helpers'; -import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public'; -import { parseInterval } from '../../../../../../src/plugins/data/common'; +import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; +import { parseInterval } from '../../../../../src/plugins/data/common'; import { getExecuteTriggerActions } from './services'; type InferPropType<T> = T extends React.FunctionComponent<infer P> ? P : T; @@ -361,12 +361,31 @@ export function XYChart({ enableHistogramMode: isHistogram && (seriesType.includes('stacked') || !splitAccessor), timeZone, name(d) { + const splitHint = table.columns.find(col => col.id === splitAccessor)?.formatHint; + + // For multiple y series, the name of the operation is used on each, either: + // * Key - Y name + // * Formatted value - Y name if (accessors.length > 1) { return d.seriesKeys - .map((key: string | number) => columnToLabelMap[key] || key) + .map((key: string | number, i) => { + if (i === 0 && splitHint) { + return formatFactory(splitHint).convert(key); + } + return splitAccessor && i === 0 ? key : columnToLabelMap[key] ?? ''; + }) .join(' - '); } - return columnToLabelMap[d.seriesKeys[0]] ?? d.seriesKeys[0]; + + // For formatted split series, format the key + // This handles splitting by dates, for example + if (splitHint) { + return formatFactory(splitHint).convert(d.seriesKeys[0]); + } + // This handles both split and single-y cases: + // * If split series without formatting, show the value literally + // * If single Y, the seriesKey will be the acccessor, so we show the human-readable name + return splitAccessor ? d.seriesKeys[0] : columnToLabelMap[d.seriesKeys[0]] ?? ''; }, }; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/xy_visualization/xy_suggestions.test.ts rename to x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/xy_visualization/xy_suggestions.ts rename to x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.test.ts similarity index 100% rename from x-pack/legacy/plugins/lens/public/xy_visualization/xy_visualization.test.ts rename to x-pack/plugins/lens/public/xy_visualization/xy_visualization.test.ts diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx similarity index 100% rename from x-pack/legacy/plugins/lens/public/xy_visualization/xy_visualization.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx diff --git a/x-pack/legacy/plugins/lens/readme.md b/x-pack/plugins/lens/readme.md similarity index 100% rename from x-pack/legacy/plugins/lens/readme.md rename to x-pack/plugins/lens/readme.md diff --git a/x-pack/plugins/lens/server/index.ts b/x-pack/plugins/lens/server/index.ts index 3b9e94986d247..8aeeeab4539b6 100644 --- a/x-pack/plugins/lens/server/index.ts +++ b/x-pack/plugins/lens/server/index.ts @@ -4,10 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext } from 'kibana/server'; +import { PluginInitializerContext, PluginConfigDescriptor } from 'kibana/server'; import { LensServerPlugin } from './plugin'; export * from './plugin'; +import { configSchema, ConfigSchema } from '../config'; + +export const config: PluginConfigDescriptor<ConfigSchema> = { + schema: configSchema, +}; + export const plugin = (initializerContext: PluginInitializerContext) => new LensServerPlugin(initializerContext); diff --git a/x-pack/plugins/lens/server/migrations.test.ts b/x-pack/plugins/lens/server/migrations.test.ts new file mode 100644 index 0000000000000..e80308cc9acdb --- /dev/null +++ b/x-pack/plugins/lens/server/migrations.test.ts @@ -0,0 +1,161 @@ +/* + * 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 { migrations } from './migrations'; +import { SavedObjectMigrationContext } from 'src/core/server'; + +describe('Lens migrations', () => { + describe('7.7.0 missing dimensions in XY', () => { + const context = {} as SavedObjectMigrationContext; + + const example = { + type: 'lens', + attributes: { + expression: + 'kibana\n| kibana_context query="{\\"language\\":\\"kuery\\",\\"query\\":\\"\\"}" \n| lens_merge_tables layerIds="c61a8afb-a185-4fae-a064-fb3846f6c451" \n tables={esaggs index="logstash-*" metricsAtAllLevels=false partialRows=false includeFormatHints=true aggConfigs="[{\\"id\\":\\"2cd09808-3915-49f4-b3b0-82767eba23f7\\",\\"enabled\\":true,\\"type\\":\\"max\\",\\"schema\\":\\"metric\\",\\"params\\":{\\"field\\":\\"bytes\\"}}]" | lens_rename_columns idMap="{\\"col-0-2cd09808-3915-49f4-b3b0-82767eba23f7\\":\\"2cd09808-3915-49f4-b3b0-82767eba23f7\\"}"}\n| lens_metric_chart title="Maximum of bytes" accessor="2cd09808-3915-49f4-b3b0-82767eba23f7"', + state: { + datasourceMetaData: { + filterableIndexPatterns: [ + { + id: 'logstash-*', + title: 'logstash-*', + }, + ], + }, + datasourceStates: { + indexpattern: { + currentIndexPatternId: 'logstash-*', + layers: { + 'c61a8afb-a185-4fae-a064-fb3846f6c451': { + columnOrder: ['2cd09808-3915-49f4-b3b0-82767eba23f7'], + columns: { + '2cd09808-3915-49f4-b3b0-82767eba23f7': { + dataType: 'number', + isBucketed: false, + label: 'Maximum of bytes', + operationType: 'max', + scale: 'ratio', + sourceField: 'bytes', + }, + 'd3e62a7a-c259-4fff-a2fc-eebf20b7008a': { + dataType: 'number', + isBucketed: false, + label: 'Minimum of bytes', + operationType: 'min', + scale: 'ratio', + sourceField: 'bytes', + }, + 'd6e40cea-6299-43b4-9c9d-b4ee305a2ce8': { + dataType: 'date', + isBucketed: true, + label: 'Date Histogram of @timestamp', + operationType: 'date_histogram', + params: { + interval: 'auto', + }, + scale: 'interval', + sourceField: '@timestamp', + }, + }, + indexPatternId: 'logstash-*', + }, + }, + }, + }, + filters: [], + query: { + language: 'kuery', + query: '', + }, + visualization: { + accessor: '2cd09808-3915-49f4-b3b0-82767eba23f7', + isHorizontal: false, + layerId: 'c61a8afb-a185-4fae-a064-fb3846f6c451', + layers: [ + { + accessors: [ + 'd3e62a7a-c259-4fff-a2fc-eebf20b7008a', + '26ef70a9-c837-444c-886e-6bd905ee7335', + ], + layerId: 'c61a8afb-a185-4fae-a064-fb3846f6c451', + seriesType: 'area', + splitAccessor: '54cd64ed-2a44-4591-af84-b2624504569a', + xAccessor: 'd6e40cea-6299-43b4-9c9d-b4ee305a2ce8', + }, + ], + legend: { + isVisible: true, + position: 'right', + }, + preferredSeriesType: 'area', + }, + }, + title: 'Artistpreviouslyknownaslens', + visualizationType: 'lnsXY', + }, + }; + + it('should not change anything by XY visualizations', () => { + const target = { + ...example, + attributes: { + ...example.attributes, + visualizationType: 'lnsMetric', + }, + }; + const result = migrations['7.7.0'](target, context); + expect(result).toEqual(target); + }); + + it('should handle missing layers', () => { + const result = migrations['7.7.0']( + { + ...example, + attributes: { + ...example.attributes, + state: { + ...example.attributes.state, + datasourceStates: { + indexpattern: { + layers: [], + }, + }, + }, + }, + }, + context + ); + + expect(result.attributes.state.visualization.layers).toEqual([ + { + layerId: 'c61a8afb-a185-4fae-a064-fb3846f6c451', + seriesType: 'area', + // Removed split accessor + splitAccessor: undefined, + xAccessor: undefined, + // Removed a yAcccessor + accessors: [], + }, + ]); + }); + + it('should remove only missing accessors', () => { + const result = migrations['7.7.0'](example, context); + + expect(result.attributes.state.visualization.layers).toEqual([ + { + layerId: 'c61a8afb-a185-4fae-a064-fb3846f6c451', + seriesType: 'area', + xAccessor: 'd6e40cea-6299-43b4-9c9d-b4ee305a2ce8', + // Removed split accessor + splitAccessor: undefined, + // Removed a yAcccessor + accessors: ['d3e62a7a-c259-4fff-a2fc-eebf20b7008a'], + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/lens/server/migrations.ts b/x-pack/plugins/lens/server/migrations.ts new file mode 100644 index 0000000000000..3d238723b7438 --- /dev/null +++ b/x-pack/plugins/lens/server/migrations.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 { cloneDeep } from 'lodash'; +import { SavedObjectMigrationFn } from 'src/core/server'; + +interface XYLayerPre77 { + layerId: string; + xAccessor: string; + splitAccessor: string; + accessors: string[]; +} + +export const migrations: Record<string, SavedObjectMigrationFn> = { + '7.7.0': doc => { + const newDoc = cloneDeep(doc); + if (newDoc.attributes?.visualizationType === 'lnsXY') { + const datasourceState = newDoc.attributes.state?.datasourceStates?.indexpattern; + const datasourceLayers = datasourceState?.layers ?? {}; + const xyState = newDoc.attributes.state?.visualization; + newDoc.attributes.state.visualization.layers = xyState.layers.map((layer: XYLayerPre77) => { + const layerId = layer.layerId; + const datasource = datasourceLayers[layerId]; + return { + ...layer, + xAccessor: datasource?.columns[layer.xAccessor] ? layer.xAccessor : undefined, + splitAccessor: datasource?.columns[layer.splitAccessor] ? layer.splitAccessor : undefined, + accessors: layer.accessors.filter(accessor => !!datasource?.columns[accessor]), + }; + }) as typeof xyState.layers; + } + return newDoc; + }, +}; diff --git a/x-pack/plugins/lens/server/saved_objects.ts b/x-pack/plugins/lens/server/saved_objects.ts index 42dc750878f45..ac80eb098e780 100644 --- a/x-pack/plugins/lens/server/saved_objects.ts +++ b/x-pack/plugins/lens/server/saved_objects.ts @@ -6,12 +6,13 @@ import { CoreSetup } from 'kibana/server'; import { getEditPath } from '../common'; +import { migrations } from './migrations'; export function setupSavedObjects(core: CoreSetup) { core.savedObjects.registerType({ name: 'lens', hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', management: { icon: 'lensApp', defaultSearchField: 'title', @@ -22,6 +23,7 @@ export function setupSavedObjects(core: CoreSetup) { uiCapabilitiesPath: 'visualize.show', }), }, + migrations, mappings: { properties: { title: { @@ -44,7 +46,7 @@ export function setupSavedObjects(core: CoreSetup) { core.savedObjects.registerType({ name: 'lens-ui-telemetry', hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', mappings: { properties: { name: { diff --git a/x-pack/plugins/lens/server/usage/task.ts b/x-pack/plugins/lens/server/usage/task.ts index e2462be149745..469457f973b62 100644 --- a/x-pack/plugins/lens/server/usage/task.ts +++ b/x-pack/plugins/lens/server/usage/task.ts @@ -184,7 +184,10 @@ export function telemetryTaskRunner( ) { return ({ taskInstance }: RunContext) => { const { state } = taskInstance; - const callCluster = core.elasticsearch.adminClient.callAsInternalUser; + const callCluster = async (...args: Parameters<APICaller>) => { + const [coreStart] = await core.getStartServices(); + return coreStart.elasticsearch.legacy.client.callAsInternalUser(...args); + }; return { async run() { diff --git a/x-pack/plugins/licensing/common/licensing.mock.ts b/x-pack/plugins/licensing/common/licensing.mock.ts index bf8b85e3e981b..4a6b27255587a 100644 --- a/x-pack/plugins/licensing/common/licensing.mock.ts +++ b/x-pack/plugins/licensing/common/licensing.mock.ts @@ -53,6 +53,7 @@ const createLicenseMock = () => { }; mock.check.mockReturnValue({ state: 'valid' }); mock.hasAtLeast.mockReturnValue(true); + mock.getFeature.mockReturnValue({ isAvailable: true, isEnabled: true }); return mock; }; export const licenseMock = { diff --git a/x-pack/plugins/licensing/server/index.ts b/x-pack/plugins/licensing/server/index.ts index 0e14ead7c6c57..76e65afc595c4 100644 --- a/x-pack/plugins/licensing/server/index.ts +++ b/x-pack/plugins/licensing/server/index.ts @@ -12,3 +12,4 @@ export const plugin = (context: PluginInitializerContext) => new LicensingPlugin export * from '../common/types'; export * from './types'; export { config } from './licensing_config'; +export { CheckLicense, wrapRouteWithLicenseCheck } from './wrap_route_with_license_check'; diff --git a/x-pack/plugins/licensing/server/wrap_route_with_license_check.test.ts b/x-pack/plugins/licensing/server/wrap_route_with_license_check.test.ts new file mode 100644 index 0000000000000..7abdd3f6190ce --- /dev/null +++ b/x-pack/plugins/licensing/server/wrap_route_with_license_check.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServerMock } from 'src/core/server/mocks'; + +import { wrapRouteWithLicenseCheck, CheckLicense } from './wrap_route_with_license_check'; + +const context = { + licensing: { + license: {}, + }, +} as any; +const request = httpServerMock.createKibanaRequest(); + +describe('wrapRouteWithLicenseCheck', () => { + it('calls route handler if checkLicense returns "valid": true', async () => { + const checkLicense: CheckLicense = () => ({ valid: true, message: null }); + const routeHandler = jest.fn(); + const wrapper = wrapRouteWithLicenseCheck(checkLicense, routeHandler); + const response = httpServerMock.createResponseFactory(); + + await wrapper(context, request, response); + + expect(routeHandler).toHaveBeenCalledTimes(1); + expect(routeHandler).toHaveBeenCalledWith(context, request, response); + }); + + it('does not call route handler if checkLicense returns "valid": false', async () => { + const checkLicense: CheckLicense = () => ({ valid: false, message: 'reason' }); + const routeHandler = jest.fn(); + const wrapper = wrapRouteWithLicenseCheck(checkLicense, routeHandler); + const response = httpServerMock.createResponseFactory(); + + await wrapper(context, request, response); + + expect(routeHandler).toHaveBeenCalledTimes(0); + expect(response.forbidden).toHaveBeenCalledTimes(1); + expect(response.forbidden).toHaveBeenCalledWith({ body: 'reason' }); + }); + + it('allows an exception to bubble up if handler throws', async () => { + const checkLicense: CheckLicense = () => ({ valid: true, message: null }); + const routeHandler = () => { + throw new Error('reason'); + }; + const wrapper = wrapRouteWithLicenseCheck(checkLicense, routeHandler); + const response = httpServerMock.createResponseFactory(); + + await expect(wrapper(context, request, response)).rejects.toThrowErrorMatchingInlineSnapshot( + `"reason"` + ); + }); + + it('allows an exception to bubble up if "checkLicense" throws', async () => { + const checkLicense: CheckLicense = () => { + throw new Error('reason'); + }; + const routeHandler = jest.fn(); + const wrapper = wrapRouteWithLicenseCheck(checkLicense, routeHandler); + const response = httpServerMock.createResponseFactory(); + + await expect(wrapper(context, request, response)).rejects.toThrowErrorMatchingInlineSnapshot( + `"reason"` + ); + + expect(routeHandler).toHaveBeenCalledTimes(0); + }); +}); diff --git a/x-pack/plugins/licensing/server/wrap_route_with_license_check.ts b/x-pack/plugins/licensing/server/wrap_route_with_license_check.ts new file mode 100644 index 0000000000000..e0cac8d9db208 --- /dev/null +++ b/x-pack/plugins/licensing/server/wrap_route_with_license_check.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + RequestHandler, + RequestHandlerContext, + KibanaRequest, + RouteMethod, + KibanaResponseFactory, +} from 'src/core/server'; + +import { ILicense } from '../common/types'; + +export type CheckLicense = ( + license: ILicense +) => { valid: false; message: string } | { valid: true; message: null }; + +export function wrapRouteWithLicenseCheck<P, Q, B>( + checkLicense: CheckLicense, + handler: RequestHandler<P, Q, B> +): RequestHandler<P, Q, B> { + return async ( + context: RequestHandlerContext, + request: KibanaRequest<P, Q, B, RouteMethod>, + response: KibanaResponseFactory + ) => { + const licenseCheckResult = checkLicense(context.licensing.license); + + if (licenseCheckResult.valid) { + return handler(context, request, response); + } else { + return response.forbidden({ + body: licenseCheckResult.message, + }); + } + }; +} diff --git a/x-pack/legacy/plugins/logstash/common/constants/es_scroll_settings.js b/x-pack/plugins/logstash/common/constants/es_scroll_settings.ts similarity index 100% rename from x-pack/legacy/plugins/logstash/common/constants/es_scroll_settings.js rename to x-pack/plugins/logstash/common/constants/es_scroll_settings.ts diff --git a/x-pack/legacy/plugins/logstash/common/constants/index.js b/x-pack/plugins/logstash/common/constants/index.ts similarity index 100% rename from x-pack/legacy/plugins/logstash/common/constants/index.js rename to x-pack/plugins/logstash/common/constants/index.ts diff --git a/x-pack/legacy/plugins/logstash/common/constants/index_names.js b/x-pack/plugins/logstash/common/constants/index_names.ts similarity index 100% rename from x-pack/legacy/plugins/logstash/common/constants/index_names.js rename to x-pack/plugins/logstash/common/constants/index_names.ts diff --git a/x-pack/legacy/plugins/logstash/common/constants/monitoring.js b/x-pack/plugins/logstash/common/constants/monitoring.ts similarity index 100% rename from x-pack/legacy/plugins/logstash/common/constants/monitoring.js rename to x-pack/plugins/logstash/common/constants/monitoring.ts diff --git a/x-pack/legacy/plugins/logstash/common/constants/pagination.js b/x-pack/plugins/logstash/common/constants/pagination.ts similarity index 100% rename from x-pack/legacy/plugins/logstash/common/constants/pagination.js rename to x-pack/plugins/logstash/common/constants/pagination.ts diff --git a/x-pack/legacy/plugins/logstash/common/constants/pipeline.js b/x-pack/plugins/logstash/common/constants/pipeline.ts similarity index 100% rename from x-pack/legacy/plugins/logstash/common/constants/pipeline.js rename to x-pack/plugins/logstash/common/constants/pipeline.ts diff --git a/x-pack/legacy/plugins/logstash/common/constants/plugin.js b/x-pack/plugins/logstash/common/constants/plugin.ts similarity index 100% rename from x-pack/legacy/plugins/logstash/common/constants/plugin.js rename to x-pack/plugins/logstash/common/constants/plugin.ts diff --git a/x-pack/legacy/plugins/logstash/common/constants/routes.js b/x-pack/plugins/logstash/common/constants/routes.ts similarity index 100% rename from x-pack/legacy/plugins/logstash/common/constants/routes.js rename to x-pack/plugins/logstash/common/constants/routes.ts diff --git a/x-pack/legacy/plugins/logstash/common/constants/tooltips.js b/x-pack/plugins/logstash/common/constants/tooltips.ts similarity index 100% rename from x-pack/legacy/plugins/logstash/common/constants/tooltips.js rename to x-pack/plugins/logstash/common/constants/tooltips.ts diff --git a/x-pack/plugins/logstash/kibana.json b/x-pack/plugins/logstash/kibana.json new file mode 100644 index 0000000000000..bcc926535d3c2 --- /dev/null +++ b/x-pack/plugins/logstash/kibana.json @@ -0,0 +1,12 @@ +{ + "id": "logstash", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["xpack", "logstash"], + "requiredPlugins": [ + "licensing" + ], + "optionalPlugins": ["security"], + "server": true, + "ui": false +} diff --git a/x-pack/plugins/logstash/server/index.ts b/x-pack/plugins/logstash/server/index.ts new file mode 100644 index 0000000000000..cc65184a1f3a0 --- /dev/null +++ b/x-pack/plugins/logstash/server/index.ts @@ -0,0 +1,16 @@ +/* + * 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'; +import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; +import { LogstashPlugin } from './plugin'; + +export const plugin = (context: PluginInitializerContext) => new LogstashPlugin(context); + +export const config: PluginConfigDescriptor = { + schema: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), +}; diff --git a/x-pack/plugins/logstash/server/lib/check_license/check_license.test.ts b/x-pack/plugins/logstash/server/lib/check_license/check_license.test.ts new file mode 100755 index 0000000000000..324e0a22ff378 --- /dev/null +++ b/x-pack/plugins/logstash/server/lib/check_license/check_license.test.ts @@ -0,0 +1,60 @@ +/* + * 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 { licensingMock } from '../../../../licensing/server/mocks'; +import { checkLicense } from './check_license'; + +describe('check_license', function() { + describe('returns "valid": false & message when', () => { + it('license information is not available', () => { + const license = licensingMock.createLicenseMock(); + license.isAvailable = false; + + const { valid, message } = checkLicense(license); + + expect(valid).toBe(false); + expect(message).toStrictEqual(expect.any(String)); + }); + + it('license level is not enough', () => { + const license = licensingMock.createLicenseMock(); + license.hasAtLeast.mockReturnValue(false); + + const { valid, message } = checkLicense(license); + + expect(valid).toBe(false); + expect(message).toStrictEqual(expect.any(String)); + }); + + it('license is expired', () => { + const license = licensingMock.createLicenseMock(); + license.isActive = false; + + const { valid, message } = checkLicense(license); + + expect(valid).toBe(false); + expect(message).toStrictEqual(expect.any(String)); + }); + + it('elasticsearch security is disabled', () => { + const license = licensingMock.createLicenseMock(); + license.getFeature.mockReturnValue({ isEnabled: false, isAvailable: false }); + + const { valid, message } = checkLicense(license); + + expect(valid).toBe(false); + expect(message).toStrictEqual(expect.any(String)); + }); + }); + + it('returns "valid": true without message otherwise', () => { + const license = licensingMock.createLicenseMock(); + + const { valid, message } = checkLicense(license); + + expect(valid).toBe(true); + expect(message).toBe(null); + }); +}); diff --git a/x-pack/plugins/logstash/server/lib/check_license/check_license.ts b/x-pack/plugins/logstash/server/lib/check_license/check_license.ts new file mode 100644 index 0000000000000..4eef2eb9b0681 --- /dev/null +++ b/x-pack/plugins/logstash/server/lib/check_license/check_license.ts @@ -0,0 +1,65 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +import { CheckLicense } from '../../../../licensing/server'; + +export const checkLicense: CheckLicense = license => { + if (!license.isAvailable) { + return { + valid: false, + message: i18n.translate( + 'xpack.logstash.managementSection.notPossibleToManagePipelinesMessage', + { + defaultMessage: + 'You cannot manage Logstash pipelines because license information is not available at this time.', + } + ), + }; + } + + if (!license.hasAtLeast('standard')) { + return { + valid: false, + message: i18n.translate('xpack.logstash.managementSection.licenseDoesNotSupportDescription', { + defaultMessage: + 'Your {licenseType} license does not support Logstash pipeline management features. Please upgrade your license.', + values: { licenseType: license.type }, + }), + }; + } + + if (!license.isActive) { + return { + valid: false, + message: i18n.translate( + 'xpack.logstash.managementSection.pipelineCrudOperationsNotAllowedDescription', + { + defaultMessage: + 'You cannot edit, create, or delete your Logstash pipelines because your {licenseType} license has expired.', + values: { licenseType: license.type }, + } + ), + }; + } + + if (!license.getFeature('security').isEnabled) { + return { + valid: false, + message: i18n.translate('xpack.logstash.managementSection.enableSecurityDescription', { + defaultMessage: + 'Security must be enabled in order to use Logstash pipeline management features.' + + ' Please set xpack.security.enabled: true in your elasticsearch.yml.', + }), + }; + } + + return { + valid: true, + message: null, + }; +}; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/check_license/index.ts b/x-pack/plugins/logstash/server/lib/check_license/index.ts similarity index 100% rename from x-pack/legacy/plugins/index_lifecycle_management/server/lib/check_license/index.ts rename to x-pack/plugins/logstash/server/lib/check_license/index.ts diff --git a/x-pack/plugins/logstash/server/lib/fetch_all_from_scroll/fetch_all_from_scroll.test.ts b/x-pack/plugins/logstash/server/lib/fetch_all_from_scroll/fetch_all_from_scroll.test.ts new file mode 100755 index 0000000000000..8cd6b70d47570 --- /dev/null +++ b/x-pack/plugins/logstash/server/lib/fetch_all_from_scroll/fetch_all_from_scroll.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { fetchAllFromScroll } from './fetch_all_from_scroll'; + +describe('fetch_all_from_scroll', () => { + let stubCallWithRequest: jest.Mock; + + beforeEach(() => { + stubCallWithRequest = jest.fn(); + stubCallWithRequest + .mockResolvedValueOnce({ + hits: { + hits: ['newhit'], + }, + _scroll_id: 'newScrollId', + }) + .mockResolvedValueOnce({ + hits: { + hits: [], + }, + }); + }); + + describe('#fetchAllFromScroll', () => { + describe('when the passed-in response has no hits', () => { + const mockResponse = { + hits: { + hits: [], + }, + }; + + it('should return an empty array of hits', async () => { + const hits = await fetchAllFromScroll(mockResponse as any, stubCallWithRequest); + expect(hits).toEqual([]); + }); + + it('should not call callWithRequest', async () => { + await fetchAllFromScroll(mockResponse as any, stubCallWithRequest); + expect(stubCallWithRequest).toHaveBeenCalledTimes(0); + }); + }); + + describe('when the passed-in response has some hits', () => { + const mockResponse = { + hits: { + hits: ['foo', 'bar'], + }, + _scroll_id: 'originalScrollId', + }; + + it('should return the hits from the response', async () => { + const hits = await fetchAllFromScroll(mockResponse as any, stubCallWithRequest); + expect(hits).toEqual(['foo', 'bar', 'newhit']); + }); + + it('should call callWithRequest', async () => { + await fetchAllFromScroll(mockResponse as any, stubCallWithRequest); + expect(stubCallWithRequest).toHaveBeenCalledTimes(2); + + const firstCallWithRequestCallArgs = stubCallWithRequest.mock.calls[0]; + expect(firstCallWithRequestCallArgs[1].body.scroll_id).toBe('originalScrollId'); + + const secondCallWithRequestCallArgs = stubCallWithRequest.mock.calls[1]; + expect(secondCallWithRequestCallArgs[1].body.scroll_id).toBe('newScrollId'); + }); + }); + }); +}); diff --git a/x-pack/plugins/logstash/server/lib/fetch_all_from_scroll/fetch_all_from_scroll.ts b/x-pack/plugins/logstash/server/lib/fetch_all_from_scroll/fetch_all_from_scroll.ts new file mode 100755 index 0000000000000..060cf188a4c60 --- /dev/null +++ b/x-pack/plugins/logstash/server/lib/fetch_all_from_scroll/fetch_all_from_scroll.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { APICaller } from 'src/core/server'; +import { SearchResponse } from 'elasticsearch'; + +import { ES_SCROLL_SETTINGS } from '../../../common/constants'; +import { Hits } from '../../types'; + +export async function fetchAllFromScroll( + response: SearchResponse<any>, + callWithRequest: APICaller, + hits: Hits = [] +): Promise<Hits> { + const newHits = response.hits?.hits || []; + const scrollId = response._scroll_id; + + if (newHits.length > 0) { + hits.push(...newHits); + + const innerResponse = await callWithRequest('scroll', { + body: { + scroll: ES_SCROLL_SETTINGS.KEEPALIVE, + scroll_id: scrollId, + }, + }); + + return await fetchAllFromScroll(innerResponse, callWithRequest, hits); + } + + return hits; +} diff --git a/x-pack/legacy/plugins/logstash/server/lib/fetch_all_from_scroll/index.js b/x-pack/plugins/logstash/server/lib/fetch_all_from_scroll/index.ts similarity index 100% rename from x-pack/legacy/plugins/logstash/server/lib/fetch_all_from_scroll/index.js rename to x-pack/plugins/logstash/server/lib/fetch_all_from_scroll/index.ts diff --git a/x-pack/plugins/logstash/server/models/cluster/cluster.test.ts b/x-pack/plugins/logstash/server/models/cluster/cluster.test.ts new file mode 100755 index 0000000000000..63f9f1e58f6ec --- /dev/null +++ b/x-pack/plugins/logstash/server/models/cluster/cluster.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Cluster } from './cluster'; + +describe('cluster', () => { + describe('Cluster', () => { + describe('fromUpstreamJSON factory method', () => { + const upstreamJSON = { + cluster_uuid: 'S-S4NNZDRV-g9c-JrIhx6A', + }; + + it('returns correct Cluster instance', () => { + const cluster = Cluster.fromUpstreamJSON(upstreamJSON); + expect(cluster.uuid).toEqual(upstreamJSON.cluster_uuid); + }); + }); + }); +}); diff --git a/x-pack/plugins/logstash/server/models/cluster/cluster.ts b/x-pack/plugins/logstash/server/models/cluster/cluster.ts new file mode 100755 index 0000000000000..54f03605e14d6 --- /dev/null +++ b/x-pack/plugins/logstash/server/models/cluster/cluster.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 { get } from 'lodash'; + +/** + * This model deals with a cluster object from ES and converts it to Kibana downstream + */ +export class Cluster { + public readonly uuid: string; + constructor({ uuid }: { uuid: string }) { + this.uuid = uuid; + } + + public get downstreamJSON() { + const json = { + uuid: this.uuid, + }; + + return json; + } + + // generate Pipeline object from elasticsearch response + static fromUpstreamJSON(upstreamCluster: Record<string, string>) { + const uuid = get<string>(upstreamCluster, 'cluster_uuid'); + return new Cluster({ uuid }); + } +} diff --git a/x-pack/legacy/plugins/logstash/server/models/cluster/index.js b/x-pack/plugins/logstash/server/models/cluster/index.ts similarity index 100% rename from x-pack/legacy/plugins/logstash/server/models/cluster/index.js rename to x-pack/plugins/logstash/server/models/cluster/index.ts diff --git a/x-pack/legacy/plugins/logstash/server/models/pipeline/index.js b/x-pack/plugins/logstash/server/models/pipeline/index.ts similarity index 100% rename from x-pack/legacy/plugins/logstash/server/models/pipeline/index.js rename to x-pack/plugins/logstash/server/models/pipeline/index.ts diff --git a/x-pack/plugins/logstash/server/models/pipeline/pipeline.test.ts b/x-pack/plugins/logstash/server/models/pipeline/pipeline.test.ts new file mode 100755 index 0000000000000..82ce0d72e2052 --- /dev/null +++ b/x-pack/plugins/logstash/server/models/pipeline/pipeline.test.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 { Pipeline } from './pipeline'; + +describe('pipeline', () => { + describe('Pipeline', () => { + describe('fromUpstreamJSON factory method', () => { + const upstreamJSON = { + _id: 'apache', + _source: { + description: 'this is an apache pipeline', + pipeline_metadata: { + version: 1, + type: 'logstash_pipeline', + }, + username: 'elastic', + pipeline: 'input {} filter { grok {} }\n output {}', + }, + }; + + it('returns correct Pipeline instance', () => { + const pipeline = Pipeline.fromUpstreamJSON(upstreamJSON); + expect(pipeline.id).toBe(upstreamJSON._id); + expect(pipeline.description).toBe(upstreamJSON._source.description); + expect(pipeline.username).toBe(upstreamJSON._source.username); + expect(pipeline.pipeline).toBe(upstreamJSON._source.pipeline); + }); + + it('throws if pipeline argument does not contain an id property', () => { + const badJSON = { + // no _id + _source: upstreamJSON._source, + }; + const testFromUpstreamJsonError = () => { + return Pipeline.fromUpstreamJSON(badJSON); + }; + expect(testFromUpstreamJsonError).toThrowError( + /upstreamPipeline argument must contain an id property/i + ); + }); + }); + + describe('upstreamJSON getter method', () => { + it('returns the upstream JSON', () => { + const downstreamJSON = { + id: 'apache', + description: 'this is an apache pipeline', + username: 'elastic', + pipeline: 'input {} filter { grok {} }\n output {}', + }; + const pipeline = new Pipeline(downstreamJSON); + const expectedUpstreamJSON = { + description: 'this is an apache pipeline', + pipeline_metadata: { + type: 'logstash_pipeline', + version: 1, + }, + username: 'elastic', + pipeline: 'input {} filter { grok {} }\n output {}', + }; + // can't do an object level comparison because modified field is always `now` + expect(pipeline.upstreamJSON.last_modified).toStrictEqual(expect.any(String)); + expect(pipeline.upstreamJSON.description).toBe(expectedUpstreamJSON.description); + expect(pipeline.upstreamJSON.pipeline_metadata).toEqual( + expectedUpstreamJSON.pipeline_metadata + ); + expect(pipeline.upstreamJSON.pipeline).toBe(expectedUpstreamJSON.pipeline); + }); + }); + }); +}); diff --git a/x-pack/plugins/logstash/server/models/pipeline/pipeline.ts b/x-pack/plugins/logstash/server/models/pipeline/pipeline.ts new file mode 100755 index 0000000000000..3f2debeebeb46 --- /dev/null +++ b/x-pack/plugins/logstash/server/models/pipeline/pipeline.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +import { badRequest } from 'boom'; +import { get } from 'lodash'; +import { i18n } from '@kbn/i18n'; + +interface PipelineOptions { + id: string; + description: string; + pipeline: string; + username?: string; + settings?: Record<string, any>; +} + +interface DownstreamPipeline { + description: string; + pipeline: string; + settings?: Record<string, any>; +} +/** + * This model deals with a pipeline object from ES and converts it to Kibana downstream + */ +export class Pipeline { + public readonly id: string; + public readonly description: string; + public readonly username?: string; + public readonly pipeline: string; + private readonly settings: Record<string, any>; + + constructor(options: PipelineOptions) { + this.id = options.id; + this.description = options.description; + this.username = options.username; + this.pipeline = options.pipeline; + this.settings = options.settings || {}; + } + + public get downstreamJSON() { + const json = { + id: this.id, + description: this.description, + username: this.username, + pipeline: this.pipeline, + settings: this.settings, + }; + + return json; + } + + /** + * Returns the JSON schema for the pipeline doc that Elasticsearch expects + * For now, we hard code pipeline_metadata since we don't use it yet + * pipeline_metadata.version is the version of the Logstash config stored in + * pipeline field. + * pipeline_metadata.type is the Logstash config type (future: LIR, json, etc) + * @return {[JSON]} [Elasticsearch JSON] + */ + public get upstreamJSON() { + return { + description: this.description, + last_modified: moment().toISOString(), + pipeline_metadata: { + version: 1, + type: 'logstash_pipeline', + }, + username: this.username, + pipeline: this.pipeline, + pipeline_settings: this.settings, + }; + } + + // generate Pipeline object from kibana response + static fromDownstreamJSON( + downstreamPipeline: DownstreamPipeline, + pipelineId: string, + username?: string + ) { + const opts = { + id: pipelineId, + description: downstreamPipeline.description, + username, + pipeline: downstreamPipeline.pipeline, + settings: downstreamPipeline.settings, + }; + + return new Pipeline(opts); + } + + // generate Pipeline object from elasticsearch response + static fromUpstreamJSON(upstreamPipeline: Record<string, any>) { + if (!upstreamPipeline._id) { + throw badRequest( + i18n.translate( + 'xpack.logstash.upstreamPipelineArgumentMustContainAnIdPropertyErrorMessage', + { + defaultMessage: 'upstreamPipeline argument must contain an id property', + } + ) + ); + } + const id = get<string>(upstreamPipeline, '_id'); + const description = get<string>(upstreamPipeline, '_source.description'); + const username = get<string>(upstreamPipeline, '_source.username'); + const pipeline = get<string>(upstreamPipeline, '_source.pipeline'); + const settings = get<Record<string, any>>(upstreamPipeline, '_source.pipeline_settings'); + + const opts: PipelineOptions = { id, description, username, pipeline, settings }; + + return new Pipeline(opts); + } +} diff --git a/x-pack/legacy/plugins/logstash/server/models/pipeline_list_item/index.js b/x-pack/plugins/logstash/server/models/pipeline_list_item/index.ts similarity index 100% rename from x-pack/legacy/plugins/logstash/server/models/pipeline_list_item/index.js rename to x-pack/plugins/logstash/server/models/pipeline_list_item/index.ts diff --git a/x-pack/plugins/logstash/server/models/pipeline_list_item/pipeline_list_item.test.ts b/x-pack/plugins/logstash/server/models/pipeline_list_item/pipeline_list_item.test.ts new file mode 100755 index 0000000000000..c557e84443b02 --- /dev/null +++ b/x-pack/plugins/logstash/server/models/pipeline_list_item/pipeline_list_item.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PipelineListItem } from './pipeline_list_item'; + +describe('pipeline_list_item', () => { + describe('PipelineListItem', () => { + const upstreamJSON = { + _id: 'apache', + _source: { + description: 'this is an apache pipeline', + last_modified: '2017-05-14T02:50:51.250Z', + pipeline_metadata: { + type: 'logstash_pipeline', + version: 1, + }, + username: 'elastic', + pipeline: 'input {} filter { grok {} }\n output {}', + }, + _index: 'index', + _type: 'type', + _score: 100, + }; + + describe('fromUpstreamJSON factory method', () => { + it('returns correct PipelineListItem instance', () => { + const pipelineListItem = PipelineListItem.fromUpstreamJSON(upstreamJSON); + expect(pipelineListItem.id).toBe(upstreamJSON._id); + expect(pipelineListItem.description).toBe(upstreamJSON._source.description); + expect(pipelineListItem.username).toBe(upstreamJSON._source.username); + expect(pipelineListItem.last_modified).toBe(upstreamJSON._source.last_modified); + }); + }); + + describe('downstreamJSON getter method', () => { + it('returns the downstreamJSON JSON', () => { + const pipelineListItem = PipelineListItem.fromUpstreamJSON(upstreamJSON); + const expectedDownstreamJSON = { + id: 'apache', + description: 'this is an apache pipeline', + username: 'elastic', + last_modified: '2017-05-14T02:50:51.250Z', + }; + expect(pipelineListItem.downstreamJSON).toEqual(expectedDownstreamJSON); + }); + }); + }); +}); diff --git a/x-pack/plugins/logstash/server/models/pipeline_list_item/pipeline_list_item.ts b/x-pack/plugins/logstash/server/models/pipeline_list_item/pipeline_list_item.ts new file mode 100755 index 0000000000000..98c91fca1fcca --- /dev/null +++ b/x-pack/plugins/logstash/server/models/pipeline_list_item/pipeline_list_item.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { Hit, PipelineListItemOptions } from '../../types'; + +export class PipelineListItem { + public readonly id: string; + public readonly description: string; + public readonly last_modified: string; + public readonly username: string; + constructor(options: PipelineListItemOptions) { + this.id = options.id; + this.description = options.description; + this.last_modified = options.last_modified; + this.username = options.username; + } + + public get downstreamJSON() { + const json = { + id: this.id, + description: this.description, + last_modified: this.last_modified, + username: this.username, + }; + + return json; + } + + /** + * Takes the json GET response from ES and constructs a pipeline model to be used + * in Kibana downstream + */ + static fromUpstreamJSON(pipeline: Hit) { + const opts = { + id: pipeline._id, + description: get<string>(pipeline, '_source.description'), + last_modified: get<string>(pipeline, '_source.last_modified'), + username: get<string>(pipeline, '_source.username'), + }; + + return new PipelineListItem(opts); + } +} diff --git a/x-pack/plugins/logstash/server/plugin.ts b/x-pack/plugins/logstash/server/plugin.ts new file mode 100644 index 0000000000000..c048dd13bfb0c --- /dev/null +++ b/x-pack/plugins/logstash/server/plugin.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + CoreSetup, + CoreStart, + ICustomClusterClient, + Logger, + Plugin, + PluginInitializerContext, +} from 'src/core/server'; +import { LicensingPluginSetup } from '../../licensing/server'; +import { SecurityPluginSetup } from '../../security/server'; + +import { registerRoutes } from './routes'; + +interface SetupDeps { + licensing: LicensingPluginSetup; + security?: SecurityPluginSetup; +} + +export class LogstashPlugin implements Plugin { + private readonly logger: Logger; + private esClient?: ICustomClusterClient; + private coreSetup?: CoreSetup; + constructor(context: PluginInitializerContext) { + this.logger = context.logger.get(); + } + + setup(core: CoreSetup, deps: SetupDeps) { + this.logger.debug('Setting up Logstash plugin'); + + this.coreSetup = core; + registerRoutes(core.http.createRouter(), deps.security); + } + + start(core: CoreStart) { + const esClient = core.elasticsearch.legacy.createClient('logstash'); + + this.coreSetup!.http.registerRouteHandlerContext('logstash', async (context, request) => { + return { esClient: esClient.asScoped(request) }; + }); + } + stop() { + if (this.esClient) { + this.esClient.close(); + } + } +} diff --git a/x-pack/plugins/logstash/server/routes/cluster/index.ts b/x-pack/plugins/logstash/server/routes/cluster/index.ts new file mode 100644 index 0000000000000..c43c409cf5a17 --- /dev/null +++ b/x-pack/plugins/logstash/server/routes/cluster/index.ts @@ -0,0 +1,6 @@ +/* + * 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 { registerClusterLoadRoute } from './load'; diff --git a/x-pack/plugins/logstash/server/routes/cluster/load.ts b/x-pack/plugins/logstash/server/routes/cluster/load.ts new file mode 100644 index 0000000000000..18fe21f3da675 --- /dev/null +++ b/x-pack/plugins/logstash/server/routes/cluster/load.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { IRouter } from 'src/core/server'; +import { wrapRouteWithLicenseCheck } from '../../../../licensing/server'; +import { Cluster } from '../../models/cluster'; +import { checkLicense } from '../../lib/check_license'; + +export function registerClusterLoadRoute(router: IRouter) { + router.get( + { + path: '/api/logstash/cluster', + validate: false, + }, + wrapRouteWithLicenseCheck(checkLicense, async (context, request, response) => { + try { + const client = context.logstash!.esClient; + const info = await client.callAsCurrentUser('info'); + return response.ok({ + body: { + cluster: Cluster.fromUpstreamJSON(info).downstreamJSON, + }, + }); + } catch (err) { + if (err.status === 403) { + return response.ok(); + } + return response.internalError(); + } + }) + ); +} diff --git a/x-pack/plugins/logstash/server/routes/index.ts b/x-pack/plugins/logstash/server/routes/index.ts new file mode 100644 index 0000000000000..0c7183b409055 --- /dev/null +++ b/x-pack/plugins/logstash/server/routes/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { IRouter } from 'src/core/server'; +import { SecurityPluginSetup } from '../../../security/server'; +import { registerClusterLoadRoute } from './cluster'; +import { + registerPipelineDeleteRoute, + registerPipelineLoadRoute, + registerPipelineSaveRoute, +} from './pipeline'; +import { registerPipelinesListRoute, registerPipelinesDeleteRoute } from './pipelines'; +import { registerUpgradeRoute } from './upgrade'; + +export function registerRoutes(router: IRouter, security?: SecurityPluginSetup) { + registerClusterLoadRoute(router); + + registerPipelineDeleteRoute(router); + registerPipelineLoadRoute(router); + registerPipelineSaveRoute(router, security); + + registerPipelinesListRoute(router); + registerPipelinesDeleteRoute(router); + + registerUpgradeRoute(router); +} diff --git a/x-pack/plugins/logstash/server/routes/pipeline/delete.ts b/x-pack/plugins/logstash/server/routes/pipeline/delete.ts new file mode 100644 index 0000000000000..4aeae3e0ae2d5 --- /dev/null +++ b/x-pack/plugins/logstash/server/routes/pipeline/delete.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { INDEX_NAMES } from '../../../common/constants'; +import { wrapRouteWithLicenseCheck } from '../../../../licensing/server'; + +import { checkLicense } from '../../lib/check_license'; + +export function registerPipelineDeleteRoute(router: IRouter) { + router.delete( + { + path: '/api/logstash/pipeline/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + wrapRouteWithLicenseCheck( + checkLicense, + router.handleLegacyErrors(async (context, request, response) => { + const client = context.logstash!.esClient; + + await client.callAsCurrentUser('delete', { + index: INDEX_NAMES.PIPELINES, + id: request.params.id, + refresh: 'wait_for', + }); + + return response.noContent(); + }) + ) + ); +} diff --git a/x-pack/plugins/logstash/server/routes/pipeline/index.ts b/x-pack/plugins/logstash/server/routes/pipeline/index.ts new file mode 100644 index 0000000000000..e7db6e18ddaf3 --- /dev/null +++ b/x-pack/plugins/logstash/server/routes/pipeline/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { registerPipelineDeleteRoute } from './delete'; +export { registerPipelineLoadRoute } from './load'; +export { registerPipelineSaveRoute } from './save'; diff --git a/x-pack/plugins/logstash/server/routes/pipeline/load.ts b/x-pack/plugins/logstash/server/routes/pipeline/load.ts new file mode 100644 index 0000000000000..fec9097114d26 --- /dev/null +++ b/x-pack/plugins/logstash/server/routes/pipeline/load.ts @@ -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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; + +import { INDEX_NAMES } from '../../../common/constants'; +import { Pipeline } from '../../models/pipeline'; +import { wrapRouteWithLicenseCheck } from '../../../../licensing/server'; +import { checkLicense } from '../../lib/check_license'; + +export function registerPipelineLoadRoute(router: IRouter) { + router.get( + { + path: '/api/logstash/pipeline/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + wrapRouteWithLicenseCheck( + checkLicense, + router.handleLegacyErrors(async (context, request, response) => { + const client = context.logstash!.esClient; + + const result = await client.callAsCurrentUser('get', { + index: INDEX_NAMES.PIPELINES, + id: request.params.id, + _source: ['description', 'username', 'pipeline', 'pipeline_settings'], + ignore: [404], + }); + + if (!result.found) { + return response.notFound(); + } + + return response.ok({ + body: Pipeline.fromUpstreamJSON(result).downstreamJSON, + }); + }) + ) + ); +} diff --git a/x-pack/plugins/logstash/server/routes/pipeline/save.ts b/x-pack/plugins/logstash/server/routes/pipeline/save.ts new file mode 100644 index 0000000000000..556c281944a85 --- /dev/null +++ b/x-pack/plugins/logstash/server/routes/pipeline/save.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 { schema } from '@kbn/config-schema'; +import { i18n } from '@kbn/i18n'; +import { IRouter } from 'src/core/server'; + +import { INDEX_NAMES } from '../../../common/constants'; +import { Pipeline } from '../../models/pipeline'; +import { wrapRouteWithLicenseCheck } from '../../../../licensing/server'; +import { SecurityPluginSetup } from '../../../../security/server'; +import { checkLicense } from '../../lib/check_license'; + +export function registerPipelineSaveRoute(router: IRouter, security?: SecurityPluginSetup) { + router.put( + { + path: '/api/logstash/pipeline/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + id: schema.string(), + description: schema.string(), + pipeline: schema.string(), + username: schema.string(), + settings: schema.maybe(schema.object({}, { unknowns: 'allow' })), + }), + }, + }, + wrapRouteWithLicenseCheck( + checkLicense, + router.handleLegacyErrors(async (context, request, response) => { + try { + let username: string | undefined; + if (security) { + const user = await security.authc.getCurrentUser(request); + username = user?.username; + } + + const client = context.logstash!.esClient; + const pipeline = Pipeline.fromDownstreamJSON(request.body, request.params.id, username); + + await client.callAsCurrentUser('index', { + index: INDEX_NAMES.PIPELINES, + id: pipeline.id, + body: pipeline.upstreamJSON, + refresh: 'wait_for', + }); + + return response.noContent(); + } catch (err) { + const statusCode = err.statusCode; + // handles the permissions issue of Elasticsearch + if (statusCode === 403) { + return response.forbidden({ + body: i18n.translate('xpack.logstash.insufficientUserPermissionsDescription', { + defaultMessage: 'Insufficient user permissions for managing Logstash pipelines', + }), + }); + } + throw err; + } + }) + ) + ); +} diff --git a/x-pack/plugins/logstash/server/routes/pipelines/delete.ts b/x-pack/plugins/logstash/server/routes/pipelines/delete.ts new file mode 100644 index 0000000000000..ac3097ac0424b --- /dev/null +++ b/x-pack/plugins/logstash/server/routes/pipelines/delete.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 { schema } from '@kbn/config-schema'; +import { APICaller, IRouter } from 'src/core/server'; +import { wrapRouteWithLicenseCheck } from '../../../../licensing/server'; + +import { INDEX_NAMES } from '../../../common/constants'; +import { checkLicense } from '../../lib/check_license'; + +async function deletePipelines(callWithRequest: APICaller, pipelineIds: string[]) { + const deletePromises = pipelineIds.map(pipelineId => { + return callWithRequest('delete', { + index: INDEX_NAMES.PIPELINES, + id: pipelineId, + refresh: 'wait_for', + }) + .then(success => ({ success })) + .catch(error => ({ error })); + }); + + const results = await Promise.all(deletePromises); + const successes = results.filter(result => Reflect.has(result, 'success')); + const errors = results.filter(result => Reflect.has(result, 'error')); + + return { + numSuccesses: successes.length, + numErrors: errors.length, + }; +} + +export function registerPipelinesDeleteRoute(router: IRouter) { + router.post( + { + path: '/api/logstash/pipelines/delete', + validate: { + body: schema.object({ + pipelineIds: schema.arrayOf(schema.string()), + }), + }, + }, + wrapRouteWithLicenseCheck( + checkLicense, + router.handleLegacyErrors(async (context, request, response) => { + const client = context.logstash!.esClient; + const results = await deletePipelines(client.callAsCurrentUser, request.body.pipelineIds); + + return response.ok({ body: { results } }); + }) + ) + ); +} diff --git a/x-pack/plugins/logstash/server/routes/pipelines/index.ts b/x-pack/plugins/logstash/server/routes/pipelines/index.ts new file mode 100644 index 0000000000000..36681502cfc70 --- /dev/null +++ b/x-pack/plugins/logstash/server/routes/pipelines/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 { registerPipelinesListRoute } from './list'; +export { registerPipelinesDeleteRoute } from './delete'; diff --git a/x-pack/plugins/logstash/server/routes/pipelines/list.ts b/x-pack/plugins/logstash/server/routes/pipelines/list.ts new file mode 100644 index 0000000000000..bc477a25a7988 --- /dev/null +++ b/x-pack/plugins/logstash/server/routes/pipelines/list.ts @@ -0,0 +1,63 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { SearchResponse } from 'elasticsearch'; +import { APICaller, IRouter } from 'src/core/server'; +import { wrapRouteWithLicenseCheck } from '../../../../licensing/server'; + +import { INDEX_NAMES, ES_SCROLL_SETTINGS } from '../../../common/constants'; +import { PipelineListItem } from '../../models/pipeline_list_item'; +import { fetchAllFromScroll } from '../../lib/fetch_all_from_scroll'; +import { checkLicense } from '../../lib/check_license'; + +async function fetchPipelines(callWithRequest: APICaller) { + const params = { + index: INDEX_NAMES.PIPELINES, + scroll: ES_SCROLL_SETTINGS.KEEPALIVE, + body: { + size: ES_SCROLL_SETTINGS.PAGE_SIZE, + }, + ignore: [404], + }; + + const response = await callWithRequest<SearchResponse<any>>('search', params); + return fetchAllFromScroll(response, callWithRequest); +} + +export function registerPipelinesListRoute(router: IRouter) { + router.get( + { + path: '/api/logstash/pipelines', + validate: false, + }, + wrapRouteWithLicenseCheck( + checkLicense, + router.handleLegacyErrors(async (context, request, response) => { + try { + const client = context.logstash!.esClient; + const pipelinesHits = await fetchPipelines(client.callAsCurrentUser); + + const pipelines = pipelinesHits.map(pipeline => { + return PipelineListItem.fromUpstreamJSON(pipeline).downstreamJSON; + }); + + return response.ok({ body: { pipelines } }); + } catch (err) { + const statusCode = err.statusCode; + // handles the permissions issue of Elasticsearch + if (statusCode === 403) { + return response.forbidden({ + body: i18n.translate('xpack.logstash.insufficientUserPermissionsDescription', { + defaultMessage: 'Insufficient user permissions for managing Logstash pipelines', + }), + }); + } + throw err; + } + }) + ) + ); +} diff --git a/x-pack/plugins/logstash/server/routes/upgrade/index.ts b/x-pack/plugins/logstash/server/routes/upgrade/index.ts new file mode 100644 index 0000000000000..3a5b0868b446b --- /dev/null +++ b/x-pack/plugins/logstash/server/routes/upgrade/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 { registerUpgradeRoute } from './upgrade'; diff --git a/x-pack/plugins/logstash/server/routes/upgrade/upgrade.ts b/x-pack/plugins/logstash/server/routes/upgrade/upgrade.ts new file mode 100644 index 0000000000000..2bd2c0f89e190 --- /dev/null +++ b/x-pack/plugins/logstash/server/routes/upgrade/upgrade.ts @@ -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 { IRouter } from 'src/core/server'; +import { wrapRouteWithLicenseCheck } from '../../../../licensing/server'; + +import { INDEX_NAMES } from '../../../common/constants'; +import { checkLicense } from '../../lib/check_license'; + +export function registerUpgradeRoute(router: IRouter) { + router.post( + { + path: '/api/logstash/upgrade', + validate: false, + }, + wrapRouteWithLicenseCheck( + checkLicense, + router.handleLegacyErrors(async (context, request, response) => { + const client = context.logstash!.esClient; + + const doesIndexExist = await client.callAsCurrentUser('indices.exists', { + index: INDEX_NAMES.PIPELINES, + }); + + // If index doesn't exist yet, there is no mapping to upgrade + if (doesIndexExist) { + await client.callAsCurrentUser('indices.putMapping', { + index: INDEX_NAMES.PIPELINES, + body: { + properties: { + pipeline_settings: { + dynamic: false, + type: 'object', + }, + }, + }, + }); + } + + return response.ok({ body: { is_upgraded: true } }); + }) + ) + ); +} diff --git a/x-pack/plugins/logstash/server/types.ts b/x-pack/plugins/logstash/server/types.ts new file mode 100644 index 0000000000000..2b266b2f27708 --- /dev/null +++ b/x-pack/plugins/logstash/server/types.ts @@ -0,0 +1,27 @@ +/* + * 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 } from 'elasticsearch'; +import { IScopedClusterClient } from 'src/core/server'; + +type UnwrapArray<T> = T extends Array<infer U> ? U : never; + +export type Hits = SearchResponse<any>['hits']['hits']; +export type Hit = UnwrapArray<Hits>; + +export interface PipelineListItemOptions { + id: string; + description: string; + last_modified: string; + username: string; +} + +declare module 'src/core/server' { + interface RequestHandlerContext { + logstash?: { + esClient: IScopedClusterClient; + }; + } +} diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index f3997f741a1bf..9808a62852a94 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -46,9 +46,10 @@ export function createMapPath(id: string) { export enum LAYER_TYPE { TILE = 'TILE', VECTOR = 'VECTOR', - VECTOR_TILE = 'VECTOR_TILE', + VECTOR_TILE = 'VECTOR_TILE', // for static display of mvt vector tiles with a mapbox stylesheet. Does not support any ad-hoc configurations. Used for consuming EMS vector tiles. HEATMAP = 'HEATMAP', BLENDED_VECTOR = 'BLENDED_VECTOR', + TILED_VECTOR = 'TILED_VECTOR', // similar to a regular vector-layer, but it consumes the data as .mvt tilea iso GeoJson. It supports similar ad-hoc configurations like a regular vector layer (E.g. using IVectorStyle), although there is some loss of functionality e.g. does not support term joining } export enum SORT_ORDER { @@ -56,15 +57,19 @@ export enum SORT_ORDER { DESC = 'desc', } -export const EMS_TMS = 'EMS_TMS'; -export const EMS_FILE = 'EMS_FILE'; -export const ES_GEO_GRID = 'ES_GEO_GRID'; -export const ES_SEARCH = 'ES_SEARCH'; -export const ES_PEW_PEW = 'ES_PEW_PEW'; -export const EMS_XYZ = 'EMS_XYZ'; // identifies a custom TMS source. Name is a little unfortunate. -export const WMS = 'WMS'; -export const KIBANA_TILEMAP = 'KIBANA_TILEMAP'; -export const REGIONMAP_FILE = 'REGIONMAP_FILE'; +export enum SOURCE_TYPES { + EMS_TMS = 'EMS_TMS', + EMS_FILE = 'EMS_FILE', + ES_GEO_GRID = 'ES_GEO_GRID', + ES_SEARCH = 'ES_SEARCH', + ES_PEW_PEW = 'ES_PEW_PEW', + EMS_XYZ = 'EMS_XYZ', // identifies a custom TMS source. Name is a little unfortunate. + WMS = 'WMS', + KIBANA_TILEMAP = 'KIBANA_TILEMAP', + REGIONMAP_FILE = 'REGIONMAP_FILE', + GEOJSON_FILE = 'GEOJSON_FILE', + MVT_SINGLE_LAYER = 'MVT_SINGLE_LAYER', +} export enum FIELD_ORIGIN { SOURCE = 'source', @@ -77,8 +82,6 @@ export const SOURCE_META_ID_ORIGIN = `${SOURCE_DATA_ID_ORIGIN}_${META_ID_ORIGIN_ export const FORMATTERS_ID_ORIGIN_SUFFIX = 'formatters'; export const SOURCE_FORMATTERS_ID_ORIGIN = `${SOURCE_DATA_ID_ORIGIN}_${FORMATTERS_ID_ORIGIN_SUFFIX}`; -export const GEOJSON_FILE = 'GEOJSON_FILE'; - export const MIN_ZOOM = 0; export const MAX_ZOOM = 24; diff --git a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.d.ts b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.d.ts index ceba2fe56db12..e94dc6694b38d 100644 --- a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.d.ts +++ b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.d.ts @@ -31,12 +31,12 @@ type ESGeoGridSourceSyncMeta = { requestType: RENDER_AS; }; -export type VectorSourceSyncMeta = ESSearchSourceSyncMeta | ESGeoGridSourceSyncMeta; +export type VectorSourceSyncMeta = ESSearchSourceSyncMeta | ESGeoGridSourceSyncMeta | null; export type VectorSourceRequestMeta = MapFilters & { applyGlobalQuery: boolean; fieldNames: string[]; - geogridPrecision: number; + geogridPrecision?: number; sourceQuery: MapQuery; sourceMeta: VectorSourceSyncMeta; }; @@ -65,7 +65,7 @@ export type DataMeta = Partial<VectorSourceRequestMeta> & export type DataRequestDescriptor = { dataId: string; - dataMetaAtStart?: DataMeta; + dataMetaAtStart?: DataMeta | null; dataRequestToken?: symbol; data?: object; dataMeta?: DataMeta; diff --git a/x-pack/plugins/maps/common/descriptor_types/descriptor_types.d.ts b/x-pack/plugins/maps/common/descriptor_types/descriptor_types.d.ts index fb49e1aaebe1c..f8175b0ed3f10 100644 --- a/x-pack/plugins/maps/common/descriptor_types/descriptor_types.d.ts +++ b/x-pack/plugins/maps/common/descriptor_types/descriptor_types.d.ts @@ -9,6 +9,11 @@ import { AGG_TYPE, GRID_RESOLUTION, RENDER_AS, SORT_ORDER, SCALING_TYPES } from import { VectorStyleDescriptor } from './style_property_descriptor_types'; import { DataRequestDescriptor } from './data_request_descriptor_types'; +export type AttributionDescriptor = { + attributionText?: string; + attributionUrl?: string; +}; + export type AbstractSourceDescriptor = { id?: string; type: string; @@ -84,10 +89,21 @@ export type WMSSourceDescriptor = { attributionUrl: string; }; -export type XYZTMSSourceDescriptor = { - id: string; - type: string; +export type XYZTMSSourceDescriptor = AbstractSourceDescriptor & + AttributionDescriptor & { + urlTemplate: string; + }; + +export type TiledSingleLayerVectorSourceDescriptor = AbstractSourceDescriptor & { urlTemplate: string; + layerName: string; + + // These are the min/max zoom levels of the availability of the a particular layerName in the tileset at urlTemplate. + // These are _not_ the visible zoom-range of the data on a map. + // Tiled data can be displayed at higher levels of zoom than that they are stored in the tileset. + // e.g. EMS basemap data from level 14 is at most detailed resolution and can be displayed at higher levels + minSourceZoom: number; + maxSourceZoom: number; }; export type JoinDescriptor = { @@ -95,6 +111,18 @@ export type JoinDescriptor = { right: ESTermSourceDescriptor; }; +export type SourceDescriptor = + | XYZTMSSourceDescriptor + | WMSSourceDescriptor + | KibanaTilemapSourceDescriptor + | KibanaRegionmapSourceDescriptor + | ESTermSourceDescriptor + | ESSearchSourceDescriptor + | ESGeoGridSourceDescriptor + | EMSFileSourceDescriptor + | ESPewPewSourceDescriptor + | TiledSingleLayerVectorSourceDescriptor; + export type LayerDescriptor = { __dataRequests?: DataRequestDescriptor[]; __isInErrorState?: boolean; @@ -104,7 +132,7 @@ export type LayerDescriptor = { label?: string; minZoom?: number; maxZoom?: number; - sourceDescriptor: AbstractSourceDescriptor; + sourceDescriptor: SourceDescriptor; type?: string; visible?: boolean; }; diff --git a/x-pack/plugins/maps/public/elasticsearch_geo_utils.js b/x-pack/plugins/maps/public/elasticsearch_geo_utils.js index 79467e26ec3fa..617cf537fd5c3 100644 --- a/x-pack/plugins/maps/public/elasticsearch_geo_utils.js +++ b/x-pack/plugins/maps/public/elasticsearch_geo_utils.js @@ -231,28 +231,16 @@ function createGeoBoundBoxFilter(geometry, geoFieldName, filterProps = {}) { }; } -function createGeoPolygonFilter(polygonCoordinates, geoFieldName, filterProps = {}) { - return { - geo_polygon: { - ignore_unmapped: true, - [geoFieldName]: { - points: polygonCoordinates[POLYGON_COORDINATES_EXTERIOR_INDEX].map(coordinatePair => { - return { - lon: coordinatePair[LON_INDEX], - lat: coordinatePair[LAT_INDEX], - }; - }), - }, - }, - ...filterProps, - }; -} - export function createExtentFilter(mapExtent, geoFieldName, geoFieldType) { ensureGeoField(geoFieldType); const safePolygon = convertMapExtentToPolygon(mapExtent); + // Extent filters are used to dynamically filter data for the current map view port. + // Continue to use geo_bounding_box queries for extent filters + // 1) geo_bounding_box queries are faster than polygon queries + // 2) geo_shape benefits of pre-indexed shapes and + // compatability across multi-indices with geo_point and geo_shape do not apply to this use case. if (geoFieldType === ES_GEO_FIELD_TYPE.GEO_POINT) { return createGeoBoundBoxFilter(safePolygon, geoFieldName); } @@ -267,15 +255,7 @@ export function createExtentFilter(mapExtent, geoFieldName, geoFieldType) { }; } -export function createSpatialFilterWithBoundingBox(options) { - return createGeometryFilterWithMeta({ ...options, isBoundingBox: true }); -} - -export function createSpatialFilterWithGeometry(options) { - return createGeometryFilterWithMeta(options); -} - -function createGeometryFilterWithMeta({ +export function createSpatialFilterWithGeometry({ preIndexedShape, geometry, geometryLabel, @@ -283,16 +263,16 @@ function createGeometryFilterWithMeta({ geoFieldName, geoFieldType, relation = ES_SPATIAL_RELATIONS.INTERSECTS, - isBoundingBox = false, }) { ensureGeoField(geoFieldType); - const relationLabel = - geoFieldType === ES_GEO_FIELD_TYPE.GEO_POINT - ? i18n.translate('xpack.maps.es_geo_utils.shapeFilter.geoPointRelationLabel', { - defaultMessage: 'in', - }) - : getEsSpatialRelationLabel(relation); + const isGeoPoint = geoFieldType === ES_GEO_FIELD_TYPE.GEO_POINT; + + const relationLabel = isGeoPoint + ? i18n.translate('xpack.maps.es_geo_utils.shapeFilter.geoPointRelationLabel', { + defaultMessage: 'in', + }) + : getEsSpatialRelationLabel(relation); const meta = { type: SPATIAL_FILTER_TYPE, negate: false, @@ -301,47 +281,24 @@ function createGeometryFilterWithMeta({ alias: `${geoFieldName} ${relationLabel} ${geometryLabel}`, }; - if (geoFieldType === ES_GEO_FIELD_TYPE.GEO_SHAPE) { - const shapeQuery = { - relation, - }; - - if (preIndexedShape) { - shapeQuery.indexed_shape = preIndexedShape; - } else { - shapeQuery.shape = geometry; - } - - return { - meta, - geo_shape: { - ignore_unmapped: true, - [geoFieldName]: shapeQuery, - }, - }; - } - - // geo_points supports limited geometry types - ensureGeometryType(geometry.type, [GEO_JSON_TYPE.POLYGON, GEO_JSON_TYPE.MULTI_POLYGON]); - - if (geometry.type === GEO_JSON_TYPE.MULTI_POLYGON) { - return { - meta, - query: { - bool: { - should: geometry.coordinates.map(polygonCoordinates => { - return createGeoPolygonFilter(polygonCoordinates, geoFieldName); - }), - }, - }, - }; - } + const shapeQuery = { + // geo_shape query with geo_point field only supports intersects relation + relation: isGeoPoint ? ES_SPATIAL_RELATIONS.INTERSECTS : relation, + }; - if (isBoundingBox) { - return createGeoBoundBoxFilter(geometry, geoFieldName, { meta }); + if (preIndexedShape) { + shapeQuery.indexed_shape = preIndexedShape; + } else { + shapeQuery.shape = geometry; } - return createGeoPolygonFilter(geometry.coordinates, geoFieldName, { meta }); + return { + meta, + geo_shape: { + ignore_unmapped: true, + [geoFieldName]: shapeQuery, + }, + }; } export function createDistanceFilterWithMeta({ diff --git a/x-pack/plugins/maps/public/kibana_services.d.ts b/x-pack/plugins/maps/public/kibana_services.d.ts new file mode 100644 index 0000000000000..867557c296292 --- /dev/null +++ b/x-pack/plugins/maps/public/kibana_services.d.ts @@ -0,0 +1,55 @@ +/* + * 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 { IIndexPattern } from 'src/plugins/data/public'; + +export function getLicenseId(): any; +export function getInspector(): any; +export function getFileUploadComponent(): any; +export function getIndexPatternSelectComponent(): any; +export function getHttp(): any; +export function getTimeFilter(): any; +export function getInjectedVarFunc(): any; +export function getToasts(): any; +export function getIndexPatternService(): { + get: (id: string) => IIndexPattern | undefined; +}; +export function getAutocompleteService(): any; +export function getSavedObjectsClient(): any; +export function getMapsCapabilities(): any; +export function getVisualizations(): any; +export function getDocLinks(): any; +export function getCoreChrome(): any; +export function getUiSettings(): any; +export function getCoreOverlays(): any; +export function getData(): any; +export function getUiActions(): any; +export function getCore(): any; +export function getNavigation(): any; +export function getCoreI18n(): any; + +export function setLicenseId(args: unknown): void; +export function setInspector(args: unknown): void; +export function setFileUpload(args: unknown): void; +export function setIndexPatternSelect(args: unknown): void; +export function setHttp(args: unknown): void; +export function setTimeFilter(args: unknown): void; +export function setInjectedVarFunc(args: unknown): void; +export function setToasts(args: unknown): void; +export function setIndexPatternService(args: unknown): void; +export function setAutocompleteService(args: unknown): void; +export function setSavedObjectsClient(args: unknown): void; +export function setMapsCapabilities(args: unknown): void; +export function setVisualizations(args: unknown): void; +export function setDocLinks(args: unknown): void; +export function setCoreChrome(args: unknown): void; +export function setUiSettings(args: unknown): void; +export function setCoreOverlays(args: unknown): void; +export function setData(args: unknown): void; +export function setUiActions(args: unknown): void; +export function setCore(args: unknown): void; +export function setNavigation(args: unknown): void; +export function setCoreI18n(args: unknown): void; diff --git a/x-pack/plugins/maps/public/kibana_services.js b/x-pack/plugins/maps/public/kibana_services.js index d2ddecfdf915b..dcbd54a09381f 100644 --- a/x-pack/plugins/maps/public/kibana_services.js +++ b/x-pack/plugins/maps/public/kibana_services.js @@ -38,7 +38,9 @@ export const getFileUploadComponent = () => { }; let getInjectedVar; -export const setInjectedVarFunc = getInjectedVarFunc => (getInjectedVar = getInjectedVarFunc); +export const setInjectedVarFunc = getInjectedVarFunc => { + getInjectedVar = getInjectedVarFunc; +}; export const getInjectedVarFunc = () => getInjectedVar; let uiSettings; @@ -89,3 +91,49 @@ export async function fetchSearchSourceAndRecordWithInspector({ return resp; } + +let savedObjectsClient; +export const setSavedObjectsClient = coreSavedObjectsClient => + (savedObjectsClient = coreSavedObjectsClient); +export const getSavedObjectsClient = () => savedObjectsClient; + +let chrome; +export const setCoreChrome = coreChrome => (chrome = coreChrome); +export const getCoreChrome = () => chrome; + +let mapsCapabilities; +export const setMapsCapabilities = coreAppMapsCapabilities => + (mapsCapabilities = coreAppMapsCapabilities); +export const getMapsCapabilities = () => mapsCapabilities; + +let visualizations; +export const setVisualizations = visPlugin => (visualizations = visPlugin); +export const getVisualizations = () => visualizations; + +let docLinks; +export const setDocLinks = coreDocLinks => (docLinks = coreDocLinks); +export const getDocLinks = () => docLinks; + +let overlays; +export const setCoreOverlays = coreOverlays => (overlays = coreOverlays); +export const getCoreOverlays = () => overlays; + +let data; +export const setData = dataPlugin => (data = dataPlugin); +export const getData = () => data; + +let uiActions; +export const setUiActions = pluginUiActions => (uiActions = pluginUiActions); +export const getUiActions = () => uiActions; + +let core; +export const setCore = kibanaCore => (core = kibanaCore); +export const getCore = () => core; + +let navigation; +export const setNavigation = pluginNavigation => (navigation = pluginNavigation); +export const getNavigation = () => navigation; + +let coreI18n; +export const setCoreI18n = kibanaCoreI18n => (coreI18n = kibanaCoreI18n); +export const getCoreI18n = () => coreI18n; diff --git a/x-pack/plugins/maps/public/layers/blended_vector_layer.ts b/x-pack/plugins/maps/public/layers/blended_vector_layer.ts index f5526ad703dd2..9a9ea2968ceeb 100644 --- a/x-pack/plugins/maps/public/layers/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/layers/blended_vector_layer.ts @@ -11,9 +11,9 @@ import { getDefaultDynamicProperties } from './styles/vector/vector_style_defaul import { IDynamicStyleProperty } from './styles/vector/properties/dynamic_style_property'; import { IStyleProperty } from './styles/vector/properties/style_property'; import { + SOURCE_TYPES, COUNT_PROP_LABEL, COUNT_PROP_NAME, - ES_GEO_GRID, LAYER_TYPE, AGG_TYPE, RENDER_AS, @@ -34,6 +34,7 @@ import { VectorStyleDescriptor, SizeDynamicOptions, DynamicStylePropertyOptions, + VectorLayerDescriptor, } from '../../common/descriptor_types'; const ACTIVE_COUNT_DATA_ID = 'ACTIVE_COUNT_DATA_ID'; @@ -147,7 +148,10 @@ function getClusterStyleDescriptor( export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { static type = LAYER_TYPE.BLENDED_VECTOR; - static createDescriptor(options: VectorLayerArguments, mapColors: string[]) { + static createDescriptor( + options: VectorLayerDescriptor, + mapColors: string[] + ): VectorLayerDescriptor { const layerDescriptor = VectorLayer.createDescriptor(options, mapColors); layerDescriptor.type = BlendedVectorLayer.type; return layerDescriptor; @@ -176,7 +180,11 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { const sourceDataRequest = this.getSourceDataRequest(); if (sourceDataRequest) { const requestMeta = sourceDataRequest.getMeta(); - if (requestMeta && requestMeta.sourceType && requestMeta.sourceType === ES_GEO_GRID) { + if ( + requestMeta && + requestMeta.sourceType && + requestMeta.sourceType === SOURCE_TYPES.ES_GEO_GRID + ) { isClustered = true; } } diff --git a/x-pack/plugins/maps/public/layers/layer.d.ts b/x-pack/plugins/maps/public/layers/layer.d.ts index de59642ede8ab..e8fc5d473626c 100644 --- a/x-pack/plugins/maps/public/layers/layer.d.ts +++ b/x-pack/plugins/maps/public/layers/layer.d.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { LayerDescriptor, MapExtent, MapFilters } from '../../common/descriptor_types'; +import { LayerDescriptor, MapExtent, MapFilters, MapQuery } from '../../common/descriptor_types'; import { ISource } from './sources/source'; import { DataRequest } from './util/data_request'; import { SyncContext } from '../actions/map_actions'; @@ -17,6 +17,11 @@ export interface ILayer { getSource(): ISource; getSourceForEditing(): ISource; syncData(syncContext: SyncContext): Promise<void>; + isVisible(): boolean; + showAtZoomLevel(zoomLevel: number): boolean; + getMinZoom(): number; + getMaxZoom(): number; + getMinSourceZoom(): number; } export interface ILayerArguments { @@ -25,6 +30,7 @@ export interface ILayerArguments { } export class AbstractLayer implements ILayer { + static createDescriptor(options: Partial<LayerDescriptor>, mapColors?: string[]): LayerDescriptor; constructor(layerArguments: ILayerArguments); getBounds(mapFilters: MapFilters): Promise<MapExtent>; getDataRequest(id: string): DataRequest | undefined; @@ -34,4 +40,12 @@ export class AbstractLayer implements ILayer { getSource(): ISource; getSourceForEditing(): ISource; syncData(syncContext: SyncContext): Promise<void>; + isVisible(): boolean; + showAtZoomLevel(zoomLevel: number): boolean; + getMinZoom(): number; + getMaxZoom(): number; + getMinSourceZoom(): number; + getQuery(): MapQuery; + _removeStaleMbSourcesAndLayers(mbMap: unknown): void; + _requiresPrevSourceCleanup(mbMap: unknown): boolean; } diff --git a/x-pack/plugins/maps/public/layers/layer.js b/x-pack/plugins/maps/public/layers/layer.js index 26bce872b3c2c..19dcbaf1dfcfd 100644 --- a/x-pack/plugins/maps/public/layers/layer.js +++ b/x-pack/plugins/maps/public/layers/layer.js @@ -141,7 +141,8 @@ export class AbstractLayer { defaultMessage: `Layer is hidden.`, }); } else if (!this.showAtZoomLevel(zoomLevel)) { - const { minZoom, maxZoom } = this.getZoomConfig(); + const minZoom = this.getMinZoom(); + const maxZoom = this.getMaxZoom(); icon = <EuiIcon size="m" type="expand" />; tooltipContent = i18n.translate('xpack.maps.layer.zoomFeedbackTooltip', { defaultMessage: `Layer is visible between zoom levels {minZoom} and {maxZoom}.`, @@ -203,7 +204,7 @@ export class AbstractLayer { } showAtZoomLevel(zoom) { - return zoom >= this._descriptor.minZoom && zoom <= this._descriptor.maxZoom; + return zoom >= this.getMinZoom() && zoom <= this.getMaxZoom(); } getMinZoom() { @@ -214,6 +215,30 @@ export class AbstractLayer { return this._descriptor.maxZoom; } + getMinSourceZoom() { + return this._source.getMinZoom(); + } + + _requiresPrevSourceCleanup() { + return false; + } + + _removeStaleMbSourcesAndLayers(mbMap) { + if (this._requiresPrevSourceCleanup(mbMap)) { + const mbStyle = mbMap.getStyle(); + mbStyle.layers.forEach(mbLayer => { + if (this.ownsMbLayerId(mbLayer.id)) { + mbMap.removeLayer(mbLayer.id); + } + }); + Object.keys(mbStyle.sources).some(mbSourceId => { + if (this.ownsMbSourceId(mbSourceId)) { + mbMap.removeSource(mbSourceId); + } + }); + } + } + getAlpha() { return this._descriptor.alpha; } @@ -222,13 +247,6 @@ export class AbstractLayer { return this._descriptor.query; } - getZoomConfig() { - return { - minZoom: this._descriptor.minZoom, - maxZoom: this._descriptor.maxZoom, - }; - } - getCurrentStyle() { return this._style; } diff --git a/x-pack/plugins/maps/public/layers/layer_wizard_registry.ts b/x-pack/plugins/maps/public/layers/layer_wizard_registry.ts index 3ef4701269994..cb87aeaa9da3f 100644 --- a/x-pack/plugins/maps/public/layers/layer_wizard_registry.ts +++ b/x-pack/plugins/maps/public/layers/layer_wizard_registry.ts @@ -5,17 +5,21 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ -type LayerWizard = { +import { ReactElement } from 'react'; +import { ISource } from './sources/source'; + +export type PreviewSourceHandler = (source: ISource | null) => void; + +export type RenderWizardArguments = { + onPreviewSource: PreviewSourceHandler; + inspectorAdapters: object; +}; + +export type LayerWizard = { description: string; icon: string; isIndexingSource?: boolean; - renderWizard({ - onPreviewSource, - inspectorAdapters, - }: { - onPreviewSource: () => void; - inspectorAdapters: unknown; - }): unknown; + renderWizard(renderWizardArguments: RenderWizardArguments): ReactElement<any>; title: string; }; diff --git a/x-pack/plugins/maps/public/layers/load_layer_wizards.js b/x-pack/plugins/maps/public/layers/load_layer_wizards.js deleted file mode 100644 index d0169165eaa35..0000000000000 --- a/x-pack/plugins/maps/public/layers/load_layer_wizards.js +++ /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. - */ - -import { registerLayerWizard } from './layer_wizard_registry'; -import { uploadLayerWizardConfig } from './sources/client_file_source'; -import { esDocumentsLayerWizardConfig } from './sources/es_search_source'; -import { clustersLayerWizardConfig, heatmapLayerWizardConfig } from './sources/es_geo_grid_source'; -import { point2PointLayerWizardConfig } from './sources/es_pew_pew_source/es_pew_pew_source'; -import { emsBoundariesLayerWizardConfig } from './sources/ems_file_source'; -import { emsBaseMapLayerWizardConfig } from './sources/ems_tms_source'; -import { kibanaRegionMapLayerWizardConfig } from './sources/kibana_regionmap_source'; -import { kibanaBasemapLayerWizardConfig } from './sources/kibana_tilemap_source'; -import { tmsLayerWizardConfig } from './sources/xyz_tms_source'; -import { wmsLayerWizardConfig } from './sources/wms_source'; - -// Registration order determines display order -registerLayerWizard(uploadLayerWizardConfig); -registerLayerWizard(esDocumentsLayerWizardConfig); -registerLayerWizard(clustersLayerWizardConfig); -registerLayerWizard(heatmapLayerWizardConfig); -registerLayerWizard(point2PointLayerWizardConfig); -registerLayerWizard(emsBoundariesLayerWizardConfig); -registerLayerWizard(emsBaseMapLayerWizardConfig); -registerLayerWizard(kibanaRegionMapLayerWizardConfig); -registerLayerWizard(kibanaBasemapLayerWizardConfig); -registerLayerWizard(tmsLayerWizardConfig); -registerLayerWizard(wmsLayerWizardConfig); diff --git a/x-pack/plugins/maps/public/layers/load_layer_wizards.ts b/x-pack/plugins/maps/public/layers/load_layer_wizards.ts new file mode 100644 index 0000000000000..49d128257fe20 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/load_layer_wizards.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { registerLayerWizard } from './layer_wizard_registry'; +// @ts-ignore +import { uploadLayerWizardConfig } from './sources/client_file_source'; +// @ts-ignore +import { esDocumentsLayerWizardConfig } from './sources/es_search_source'; +// @ts-ignore +import { clustersLayerWizardConfig, heatmapLayerWizardConfig } from './sources/es_geo_grid_source'; +// @ts-ignore +import { point2PointLayerWizardConfig } from './sources/es_pew_pew_source/es_pew_pew_source'; +// @ts-ignore +import { emsBoundariesLayerWizardConfig } from './sources/ems_file_source'; +// @ts-ignore +import { emsBaseMapLayerWizardConfig } from './sources/ems_tms_source'; +// @ts-ignore +import { kibanaRegionMapLayerWizardConfig } from './sources/kibana_regionmap_source'; +// @ts-ignore +import { kibanaBasemapLayerWizardConfig } from './sources/kibana_tilemap_source'; +import { tmsLayerWizardConfig } from './sources/xyz_tms_source'; +// @ts-ignore +import { wmsLayerWizardConfig } from './sources/wms_source'; +import { mvtVectorSourceWizardConfig } from './sources/mvt_single_layer_vector_source'; +// @ts-ignore +import { getInjectedVarFunc } from '../kibana_services'; + +// Registration order determines display order +let registered = false; +export function registerLayerWizards() { + if (registered) { + return; + } + // @ts-ignore + registerLayerWizard(uploadLayerWizardConfig); + // @ts-ignore + registerLayerWizard(esDocumentsLayerWizardConfig); + // @ts-ignore + registerLayerWizard(clustersLayerWizardConfig); + // @ts-ignore + registerLayerWizard(heatmapLayerWizardConfig); + // @ts-ignore + registerLayerWizard(point2PointLayerWizardConfig); + // @ts-ignore + registerLayerWizard(emsBoundariesLayerWizardConfig); + // @ts-ignore + registerLayerWizard(emsBaseMapLayerWizardConfig); + // @ts-ignore + registerLayerWizard(kibanaRegionMapLayerWizardConfig); + // @ts-ignore + registerLayerWizard(kibanaBasemapLayerWizardConfig); + registerLayerWizard(tmsLayerWizardConfig); + // @ts-ignore + registerLayerWizard(wmsLayerWizardConfig); + + const getInjectedVar = getInjectedVarFunc(); + if (getInjectedVar && getInjectedVar('enableVectorTiles', false)) { + // eslint-disable-next-line no-console + console.warn('Vector tiles are an experimental feature and should not be used in production.'); + registerLayerWizard(mvtVectorSourceWizardConfig); + } + registered = true; +} diff --git a/x-pack/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js b/x-pack/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js index 1003f8329da22..137513ad7c612 100644 --- a/x-pack/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js +++ b/x-pack/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js @@ -8,18 +8,18 @@ import { AbstractVectorSource } from '../vector_source'; import React from 'react'; import { ES_GEO_FIELD_TYPE, - GEOJSON_FILE, + SOURCE_TYPES, DEFAULT_MAX_RESULT_WINDOW, + SCALING_TYPES, } from '../../../../common/constants'; import { ClientFileCreateSourceEditor } from './create_client_file_source_editor'; import { ESSearchSource } from '../es_search_source'; import uuid from 'uuid/v4'; -import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { registerSource } from '../source_registry'; export class GeojsonFileSource extends AbstractVectorSource { - static type = GEOJSON_FILE; + static type = SOURCE_TYPES.GEOJSON_FILE; static isIndexingSource = true; @@ -91,23 +91,22 @@ const viewIndexedData = ( importErrorHandler(indexResponses); return; } - const { fields, id } = indexPatternResp; - const geoFieldArr = fields.filter(field => - Object.values(ES_GEO_FIELD_TYPE).includes(field.type) - ); - const geoField = _.get(geoFieldArr, '[0].name'); - const indexPatternId = id; + const { fields, id: indexPatternId } = indexPatternResp; + const geoField = fields.find(field => Object.values(ES_GEO_FIELD_TYPE).includes(field.type)); if (!indexPatternId || !geoField) { addAndViewSource(null); } else { - // Only turn on bounds filter for large doc counts - const filterByMapBounds = indexDataResp.docCount > DEFAULT_MAX_RESULT_WINDOW; const source = new ESSearchSource( { id: uuid(), indexPatternId, - geoField, - filterByMapBounds, + geoField: geoField.name, + // Only turn on bounds filter for large doc counts + filterByMapBounds: indexDataResp.docCount > DEFAULT_MAX_RESULT_WINDOW, + scalingType: + geoField.type === ES_GEO_FIELD_TYPE.GEO_POINT + ? SCALING_TYPES.CLUSTERS + : SCALING_TYPES.LIMIT, }, inspectorAdapters ); @@ -131,7 +130,7 @@ const previewGeojsonFile = (onPreviewSource, inspectorAdapters) => { registerSource({ ConstructorFunction: GeojsonFileSource, - type: GEOJSON_FILE, + type: SOURCE_TYPES.GEOJSON_FILE, }); export const uploadLayerWizardConfig = { diff --git a/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.js b/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.js index d3ccc0cb55821..e8af17b911939 100644 --- a/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.js +++ b/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.js @@ -7,7 +7,7 @@ import { AbstractVectorSource } from '../vector_source'; import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; import React from 'react'; -import { EMS_FILE, FIELD_ORIGIN } from '../../../../common/constants'; +import { SOURCE_TYPES, FIELD_ORIGIN } from '../../../../common/constants'; import { getEMSClient } from '../../../meta'; import { EMSFileCreateSourceEditor } from './create_source_editor'; import { i18n } from '@kbn/i18n'; @@ -21,7 +21,7 @@ const sourceTitle = i18n.translate('xpack.maps.source.emsFileTitle', { }); export class EMSFileSource extends AbstractVectorSource { - static type = EMS_FILE; + static type = SOURCE_TYPES.EMS_FILE; static createDescriptor({ id, tooltipProperties = [] }) { return { @@ -159,7 +159,7 @@ export class EMSFileSource extends AbstractVectorSource { registerSource({ ConstructorFunction: EMSFileSource, - type: EMS_FILE, + type: SOURCE_TYPES.EMS_FILE, }); export const emsBoundariesLayerWizardConfig = { diff --git a/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_tms_source.js b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_tms_source.js index 1da3680dfdc86..79121c4cdb31f 100644 --- a/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_tms_source.js +++ b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_tms_source.js @@ -14,7 +14,7 @@ import { TileServiceSelect } from './tile_service_select'; import { UpdateSourceEditor } from './update_source_editor'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; -import { EMS_TMS } from '../../../../common/constants'; +import { SOURCE_TYPES } from '../../../../common/constants'; import { getInjectedVarFunc, getUiSettings } from '../../../kibana_services'; import { registerSource } from '../source_registry'; @@ -23,7 +23,7 @@ const sourceTitle = i18n.translate('xpack.maps.source.emsTileTitle', { }); export class EMSTMSSource extends AbstractTMSSource { - static type = EMS_TMS; + static type = SOURCE_TYPES.EMS_TMS; static createDescriptor(sourceConfig) { return { @@ -148,7 +148,7 @@ export class EMSTMSSource extends AbstractTMSSource { registerSource({ ConstructorFunction: EMSTMSSource, - type: EMS_TMS, + type: SOURCE_TYPES.EMS_TMS, }); export const emsBaseMapLayerWizardConfig = { diff --git a/x-pack/plugins/maps/public/layers/sources/es_agg_source.d.ts b/x-pack/plugins/maps/public/layers/sources/es_agg_source.d.ts deleted file mode 100644 index 99ee1ec652b54..0000000000000 --- a/x-pack/plugins/maps/public/layers/sources/es_agg_source.d.ts +++ /dev/null @@ -1,25 +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 { IESSource } from './es_source'; -import { AbstractESSource } from './es_source'; -import { AGG_TYPE } from '../../../common/constants'; -import { IESAggField } from '../fields/es_agg_field'; -import { AbstractESAggSourceDescriptor } from '../../../common/descriptor_types'; - -export interface IESAggSource extends IESSource { - getAggKey(aggType: AGG_TYPE, fieldName: string): string; - getAggLabel(aggType: AGG_TYPE, fieldName: string): string; - getMetricFields(): IESAggField[]; -} - -export class AbstractESAggSource extends AbstractESSource implements IESAggSource { - constructor(sourceDescriptor: AbstractESAggSourceDescriptor, inspectorAdapters: object); - - getAggKey(aggType: AGG_TYPE, fieldName: string): string; - getAggLabel(aggType: AGG_TYPE, fieldName: string): string; - getMetricFields(): IESAggField[]; -} diff --git a/x-pack/plugins/maps/public/layers/sources/es_agg_source/es_agg_source.d.ts b/x-pack/plugins/maps/public/layers/sources/es_agg_source/es_agg_source.d.ts new file mode 100644 index 0000000000000..a93f9121d1e62 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_agg_source/es_agg_source.d.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 { IESSource } from '../es_source'; +import { AbstractESSource } from '../es_source'; +import { AGG_TYPE } from '../../../../common/constants'; +import { IESAggField } from '../../fields/es_agg_field'; +import { AbstractESAggSourceDescriptor } from '../../../../common/descriptor_types'; + +export interface IESAggSource extends IESSource { + getAggKey(aggType: AGG_TYPE, fieldName: string): string; + getAggLabel(aggType: AGG_TYPE, fieldName: string): string; + getMetricFields(): IESAggField[]; +} + +export class AbstractESAggSource extends AbstractESSource implements IESAggSource { + constructor(sourceDescriptor: AbstractESAggSourceDescriptor, inspectorAdapters: object); + + getAggKey(aggType: AGG_TYPE, fieldName: string): string; + getAggLabel(aggType: AGG_TYPE, fieldName: string): string; + getMetricFields(): IESAggField[]; +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_agg_source.js b/x-pack/plugins/maps/public/layers/sources/es_agg_source/es_agg_source.js similarity index 95% rename from x-pack/plugins/maps/public/layers/sources/es_agg_source.js rename to x-pack/plugins/maps/public/layers/sources/es_agg_source/es_agg_source.js index 9f4b89cadc777..c6197f137f212 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_agg_source.js +++ b/x-pack/plugins/maps/public/layers/sources/es_agg_source/es_agg_source.js @@ -5,15 +5,15 @@ */ import { i18n } from '@kbn/i18n'; -import { AbstractESSource } from './es_source'; -import { esAggFieldsFactory } from '../fields/es_agg_field'; +import { AbstractESSource } from '../es_source'; +import { esAggFieldsFactory } from '../../fields/es_agg_field'; import { AGG_TYPE, COUNT_PROP_LABEL, COUNT_PROP_NAME, FIELD_ORIGIN, -} from '../../../common/constants'; +} from '../../../../common/constants'; export const AGG_DELIMITER = '_of_'; diff --git a/x-pack/plugins/maps/public/layers/sources/es_agg_source.test.ts b/x-pack/plugins/maps/public/layers/sources/es_agg_source/es_agg_source.test.ts similarity index 89% rename from x-pack/plugins/maps/public/layers/sources/es_agg_source.test.ts rename to x-pack/plugins/maps/public/layers/sources/es_agg_source/es_agg_source.test.ts index 848091586eb9c..87abbedfdf50e 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_agg_source.test.ts +++ b/x-pack/plugins/maps/public/layers/sources/es_agg_source/es_agg_source.test.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AbstractESAggSource } from './es_agg_source'; -import { IField } from '../fields/field'; -import { IESAggField } from '../fields/es_agg_field'; +import { AbstractESAggSource } from '../es_agg_source'; +import { IField } from '../../fields/field'; +import { IESAggField } from '../../fields/es_agg_field'; import _ from 'lodash'; -import { AGG_TYPE } from '../../../common/constants'; -import { AggDescriptor } from '../../../common/descriptor_types'; +import { AGG_TYPE } from '../../../../common/constants'; +import { AggDescriptor } from '../../../../common/descriptor_types'; jest.mock('ui/new_platform'); diff --git a/x-pack/plugins/maps/public/layers/sources/es_agg_source/index.ts b/x-pack/plugins/maps/public/layers/sources/es_agg_source/index.ts new file mode 100644 index 0000000000000..cbf4eceefd432 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_agg_source/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 * from './es_agg_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.ts b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.ts index 3f596cea1ae39..96347c444dd5b 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.ts +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.ts @@ -23,6 +23,7 @@ export class ESGeoGridSource extends AbstractESAggSource { constructor(sourceDescriptor: ESGeoGridSourceDescriptor, inspectorAdapters: unknown); + getFieldNames(): string[]; getGridResolution(): GRID_RESOLUTION; getGeoGridPrecision(zoom: number): number; } diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js index 04f944396ab35..b9ef13e520bf8 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js @@ -17,8 +17,8 @@ import { COLOR_GRADIENTS } from '../../styles/color_utils'; import { CreateSourceEditor } from './create_source_editor'; import { UpdateSourceEditor } from './update_source_editor'; import { + SOURCE_TYPES, DEFAULT_MAX_BUCKETS_LIMIT, - ES_GEO_GRID, COUNT_PROP_NAME, COLOR_MAP_TYPE, RENDER_AS, @@ -45,7 +45,7 @@ const heatmapTitle = i18n.translate('xpack.maps.source.esGridHeatmapTitle', { }); export class ESGeoGridSource extends AbstractESAggSource { - static type = ES_GEO_GRID; + static type = SOURCE_TYPES.ES_GEO_GRID; static createDescriptor({ indexPatternId, geoField, requestType, resolution }) { return { @@ -311,7 +311,7 @@ export class ESGeoGridSource extends AbstractESAggSource { }, meta: { areResultsTrimmed: false, - sourceType: ES_GEO_GRID, + sourceType: SOURCE_TYPES.ES_GEO_GRID, }, }; } @@ -420,7 +420,7 @@ export class ESGeoGridSource extends AbstractESAggSource { registerSource({ ConstructorFunction: ESGeoGridSource, - type: ES_GEO_GRID, + type: SOURCE_TYPES.ES_GEO_GRID, }); export const clustersLayerWizardConfig = { diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.test.ts b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.test.ts index 727435c3cbfef..e35bb998ce7db 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.test.ts +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.test.ts @@ -7,7 +7,7 @@ jest.mock('../../../kibana_services', () => {}); jest.mock('ui/new_platform'); import { ESGeoGridSource } from './es_geo_grid_source'; -import { ES_GEO_GRID, GRID_RESOLUTION, RENDER_AS } from '../../../../common/constants'; +import { GRID_RESOLUTION, RENDER_AS, SOURCE_TYPES } from '../../../../common/constants'; describe('ESGeoGridSource', () => { const geogridSource = new ESGeoGridSource( @@ -17,7 +17,7 @@ describe('ESGeoGridSource', () => { geoField: 'bar', metrics: [], resolution: GRID_RESOLUTION.COARSE, - type: ES_GEO_GRID, + type: SOURCE_TYPES.ES_GEO_GRID, requestType: RENDER_AS.HEATMAP, }, {} diff --git a/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js index ea3a2d2fe634d..57e5afb99404b 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js @@ -16,7 +16,7 @@ import { getDefaultDynamicProperties } from '../../styles/vector/vector_style_de import { i18n } from '@kbn/i18n'; import { FIELD_ORIGIN, - ES_PEW_PEW, + SOURCE_TYPES, COUNT_PROP_NAME, VECTOR_STYLES, } from '../../../../common/constants'; @@ -35,7 +35,7 @@ const sourceTitle = i18n.translate('xpack.maps.source.pewPewTitle', { }); export class ESPewPewSource extends AbstractESAggSource { - static type = ES_PEW_PEW; + static type = SOURCE_TYPES.ES_PEW_PEW; static createDescriptor({ indexPatternId, sourceGeoField, destGeoField }) { return { @@ -232,7 +232,7 @@ export class ESPewPewSource extends AbstractESAggSource { registerSource({ ConstructorFunction: ESPewPewSource, - type: ES_PEW_PEW, + type: SOURCE_TYPES.ES_PEW_PEW, }); export const point2PointLayerWizardConfig = { diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.d.ts b/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.d.ts index 0a4e48a195ec6..c904280a38c85 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.d.ts +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.d.ts @@ -9,4 +9,5 @@ import { ESSearchSourceDescriptor } from '../../../../common/descriptor_types'; export class ESSearchSource extends AbstractESSource { constructor(sourceDescriptor: Partial<ESSearchSourceDescriptor>, inspectorAdapters: unknown); + getFieldNames(): string[]; } diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.js b/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.js index ce9932bd15cea..96679f0e85941 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.js +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.js @@ -17,7 +17,7 @@ import { hitsToGeoJson } from '../../../elasticsearch_geo_utils'; import { CreateSourceEditor } from './create_source_editor'; import { UpdateSourceEditor } from './update_source_editor'; import { - ES_SEARCH, + SOURCE_TYPES, ES_GEO_FIELD_TYPE, DEFAULT_MAX_BUCKETS_LIMIT, SORT_ORDER, @@ -69,7 +69,7 @@ function getDocValueAndSourceFields(indexPattern, fieldNames) { } export class ESSearchSource extends AbstractESSource { - static type = ES_SEARCH; + static type = SOURCE_TYPES.ES_SEARCH; constructor(descriptor, inspectorAdapters) { super( @@ -404,7 +404,7 @@ export class ESSearchSource extends AbstractESSource { return { data: featureCollection, - meta: { ...meta, sourceType: ES_SEARCH }, + meta: { ...meta, sourceType: SOURCE_TYPES.ES_SEARCH }, }; } @@ -570,7 +570,7 @@ export class ESSearchSource extends AbstractESSource { registerSource({ ConstructorFunction: ESSearchSource, - type: ES_SEARCH, + type: SOURCE_TYPES.ES_SEARCH, }); export const esDocumentsLayerWizardConfig = { diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.test.ts b/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.test.ts index 2197e24aedb59..66cc2ddd85404 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.test.ts +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.test.ts @@ -8,11 +8,11 @@ jest.mock('../../../kibana_services'); import { ESSearchSource } from './es_search_source'; import { VectorLayer } from '../../vector_layer'; -import { ES_SEARCH, SCALING_TYPES } from '../../../../common/constants'; +import { SCALING_TYPES, SOURCE_TYPES } from '../../../../common/constants'; import { ESSearchSourceDescriptor } from '../../../../common/descriptor_types'; const descriptor: ESSearchSourceDescriptor = { - type: ES_SEARCH, + type: SOURCE_TYPES.ES_SEARCH, id: '1234', indexPatternId: 'myIndexPattern', geoField: 'myLocation', diff --git a/x-pack/plugins/maps/public/layers/sources/es_source.d.ts b/x-pack/plugins/maps/public/layers/sources/es_source/es_source.d.ts similarity index 78% rename from x-pack/plugins/maps/public/layers/sources/es_source.d.ts rename to x-pack/plugins/maps/public/layers/sources/es_source/es_source.d.ts index 65851d0e7bd38..092dc3bf0d5a8 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_source.d.ts +++ b/x-pack/plugins/maps/public/layers/sources/es_source/es_source.d.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AbstractVectorSource } from './vector_source'; -import { IVectorSource } from './vector_source'; -import { IndexPattern, SearchSource } from '../../../../../../src/plugins/data/public'; -import { VectorSourceRequestMeta } from '../../../common/descriptor_types'; +import { AbstractVectorSource } from '../vector_source'; +import { IVectorSource } from '../vector_source'; +import { IndexPattern, SearchSource } from '../../../../../../../src/plugins/data/public'; +import { VectorSourceRequestMeta } from '../../../../common/descriptor_types'; export interface IESSource extends IVectorSource { getId(): string; diff --git a/x-pack/plugins/maps/public/layers/sources/es_source.js b/x-pack/plugins/maps/public/layers/sources/es_source/es_source.js similarity index 95% rename from x-pack/plugins/maps/public/layers/sources/es_source.js rename to x-pack/plugins/maps/public/layers/sources/es_source/es_source.js index d90a802a38344..3402e367cbd73 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_source.js +++ b/x-pack/plugins/maps/public/layers/sources/es_source/es_source.js @@ -4,23 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AbstractVectorSource } from './vector_source'; +import { AbstractVectorSource } from '../vector_source'; import { getAutocompleteService, fetchSearchSourceAndRecordWithInspector, getIndexPatternService, SearchSource, getTimeFilter, -} from '../../kibana_services'; -import { createExtentFilter } from '../../elasticsearch_geo_utils'; +} from '../../../kibana_services'; +import { createExtentFilter } from '../../../elasticsearch_geo_utils'; import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import uuid from 'uuid/v4'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { copyPersistentState } from '../../reducers/util'; -import { ES_GEO_FIELD_TYPE } from '../../../common/constants'; -import { DataRequestAbortError } from '../util/data_request'; -import { expandToTileBoundaries } from './es_geo_grid_source/geo_tile_utils'; +import { copyPersistentState } from '../../../reducers/util'; +import { ES_GEO_FIELD_TYPE } from '../../../../common/constants'; +import { DataRequestAbortError } from '../../util/data_request'; +import { expandToTileBoundaries } from '../es_geo_grid_source/geo_tile_utils'; export class AbstractESSource extends AbstractVectorSource { constructor(descriptor, inspectorAdapters) { diff --git a/x-pack/plugins/maps/public/layers/sources/es_source/index.ts b/x-pack/plugins/maps/public/layers/sources/es_source/index.ts new file mode 100644 index 0000000000000..227a4dd634457 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_source/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 * from './es_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/es_term_source.d.ts b/x-pack/plugins/maps/public/layers/sources/es_term_source/es_term_source.d.ts similarity index 77% rename from x-pack/plugins/maps/public/layers/sources/es_term_source.d.ts rename to x-pack/plugins/maps/public/layers/sources/es_term_source/es_term_source.d.ts index 44cdc851b4fc7..701bd5e2c8b5e 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_term_source.d.ts +++ b/x-pack/plugins/maps/public/layers/sources/es_term_source/es_term_source.d.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IField } from '../fields/field'; -import { IESAggSource } from './es_agg_source'; +import { IField } from '../../fields/field'; +import { IESAggSource } from '../es_agg_source'; export interface IESTermSource extends IESAggSource { getTermField(): IField; diff --git a/x-pack/plugins/maps/public/layers/sources/es_term_source.js b/x-pack/plugins/maps/public/layers/sources/es_term_source/es_term_source.js similarity index 95% rename from x-pack/plugins/maps/public/layers/sources/es_term_source.js rename to x-pack/plugins/maps/public/layers/sources/es_term_source/es_term_source.js index 3ce0fb58aba19..826197cc4fec7 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_term_source.js +++ b/x-pack/plugins/maps/public/layers/sources/es_term_source/es_term_source.js @@ -7,10 +7,10 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { DEFAULT_MAX_BUCKETS_LIMIT, FIELD_ORIGIN, AGG_TYPE } from '../../../common/constants'; -import { ESDocField } from '../fields/es_doc_field'; -import { AbstractESAggSource, AGG_DELIMITER } from './es_agg_source'; -import { getField, addFieldToDSL, extractPropertiesFromBucket } from '../util/es_agg_utils'; +import { DEFAULT_MAX_BUCKETS_LIMIT, FIELD_ORIGIN, AGG_TYPE } from '../../../../common/constants'; +import { ESDocField } from '../../fields/es_doc_field'; +import { AbstractESAggSource, AGG_DELIMITER } from '../es_agg_source'; +import { getField, addFieldToDSL, extractPropertiesFromBucket } from '../../util/es_agg_utils'; const TERMS_AGG_NAME = 'join'; diff --git a/x-pack/plugins/maps/public/layers/sources/es_term_source.test.js b/x-pack/plugins/maps/public/layers/sources/es_term_source/es_term_source.test.js similarity index 98% rename from x-pack/plugins/maps/public/layers/sources/es_term_source.test.js rename to x-pack/plugins/maps/public/layers/sources/es_term_source/es_term_source.test.js index 14ffd068df465..b6cd3b670d3ce 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_term_source.test.js +++ b/x-pack/plugins/maps/public/layers/sources/es_term_source/es_term_source.test.js @@ -7,7 +7,7 @@ import { ESTermSource, extractPropertiesMap } from './es_term_source'; jest.mock('ui/new_platform'); -jest.mock('../vector_layer', () => {}); +jest.mock('../../vector_layer', () => {}); const indexPatternTitle = 'myIndex'; const termFieldName = 'myTermField'; diff --git a/x-pack/plugins/maps/public/layers/sources/es_term_source/index.ts b/x-pack/plugins/maps/public/layers/sources/es_term_source/index.ts new file mode 100644 index 0000000000000..6fad8384c690d --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_term_source/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 * from './es_term_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_source.js b/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_source.js index 7f4bcfa41f7c4..be333f8ee85a4 100644 --- a/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_source.js +++ b/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_source.js @@ -10,7 +10,7 @@ import { CreateSourceEditor } from './create_source_editor'; import { getKibanaRegionList } from '../../../meta'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; -import { FIELD_ORIGIN, REGIONMAP_FILE } from '../../../../common/constants'; +import { FIELD_ORIGIN, SOURCE_TYPES } from '../../../../common/constants'; import { KibanaRegionField } from '../../fields/kibana_region_field'; import { registerSource } from '../source_registry'; @@ -19,7 +19,7 @@ const sourceTitle = i18n.translate('xpack.maps.source.kbnRegionMapTitle', { }); export class KibanaRegionmapSource extends AbstractVectorSource { - static type = REGIONMAP_FILE; + static type = SOURCE_TYPES.REGIONMAP_FILE; static createDescriptor({ name }) { return { @@ -99,7 +99,7 @@ export class KibanaRegionmapSource extends AbstractVectorSource { registerSource({ ConstructorFunction: KibanaRegionmapSource, - type: REGIONMAP_FILE, + type: SOURCE_TYPES.REGIONMAP_FILE, }); export const kibanaRegionMapLayerWizardConfig = { diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_tilemap_source.js b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_tilemap_source.js index b21bb6bdbbad4..bbb653eff32e2 100644 --- a/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_tilemap_source.js +++ b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_tilemap_source.js @@ -11,7 +11,7 @@ import { getKibanaTileMap } from '../../../meta'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; import _ from 'lodash'; -import { KIBANA_TILEMAP } from '../../../../common/constants'; +import { SOURCE_TYPES } from '../../../../common/constants'; import { registerSource } from '../source_registry'; const sourceTitle = i18n.translate('xpack.maps.source.kbnTMSTitle', { @@ -19,7 +19,7 @@ const sourceTitle = i18n.translate('xpack.maps.source.kbnTMSTitle', { }); export class KibanaTilemapSource extends AbstractTMSSource { - static type = KIBANA_TILEMAP; + static type = SOURCE_TYPES.KIBANA_TILEMAP; static createDescriptor() { return { @@ -86,7 +86,7 @@ export class KibanaTilemapSource extends AbstractTMSSource { registerSource({ ConstructorFunction: KibanaTilemapSource, - type: KIBANA_TILEMAP, + type: SOURCE_TYPES.KIBANA_TILEMAP, }); export const kibanaBasemapLayerWizardConfig = { diff --git a/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/index.ts b/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/index.ts new file mode 100644 index 0000000000000..89b7e76a7e359 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './mvt_single_layer_vector_source'; +export * from './layer_wizard'; diff --git a/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/layer_wizard.tsx new file mode 100644 index 0000000000000..dfdea1489d50c --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/layer_wizard.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { + MVTSingleLayerVectorSourceEditor, + MVTSingleLayerVectorSourceConfig, +} from './mvt_single_layer_vector_source_editor'; +import { MVTSingleLayerVectorSource, sourceTitle } from './mvt_single_layer_vector_source'; +import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; +import { SOURCE_TYPES } from '../../../../common/constants'; + +export const mvtVectorSourceWizardConfig: LayerWizard = { + description: i18n.translate('xpack.maps.source.mvtVectorSourceWizard', { + defaultMessage: 'Vector source wizard', + }), + icon: 'grid', + renderWizard: ({ onPreviewSource, inspectorAdapters }: RenderWizardArguments) => { + const onSourceConfigChange = ({ + urlTemplate, + layerName, + minSourceZoom, + maxSourceZoom, + }: MVTSingleLayerVectorSourceConfig) => { + const sourceDescriptor = MVTSingleLayerVectorSource.createDescriptor({ + urlTemplate, + layerName, + minSourceZoom, + maxSourceZoom, + type: SOURCE_TYPES.MVT_SINGLE_LAYER, + }); + const source = new MVTSingleLayerVectorSource(sourceDescriptor, inspectorAdapters); + onPreviewSource(source); + }; + return <MVTSingleLayerVectorSourceEditor onSourceConfigChange={onSourceConfigChange} />; + }, + title: sourceTitle, +}; diff --git a/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.ts b/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.ts new file mode 100644 index 0000000000000..0bfda6be72203 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.ts @@ -0,0 +1,175 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import uuid from 'uuid/v4'; +import { AbstractSource, ImmutableSourceProperty } from '../source'; +import { TiledVectorLayer } from '../../tiled_vector_layer'; +import { GeoJsonWithMeta, ITiledSingleLayerVectorSource } from '../vector_source'; +import { MAX_ZOOM, MIN_ZOOM, SOURCE_TYPES } from '../../../../common/constants'; +import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; +import { IField } from '../../fields/field'; +import { registerSource } from '../source_registry'; +import { getDataSourceLabel, getUrlLabel } from '../../../../common/i18n_getters'; +import { + LayerDescriptor, + MapExtent, + TiledSingleLayerVectorSourceDescriptor, + VectorSourceRequestMeta, + VectorSourceSyncMeta, +} from '../../../../common/descriptor_types'; +import { VectorLayerArguments } from '../../vector_layer'; + +export const sourceTitle = i18n.translate( + 'xpack.maps.source.MVTSingleLayerVectorSource.sourceTitle', + { + defaultMessage: 'Vector Tile Layer', + } +); + +export class MVTSingleLayerVectorSource extends AbstractSource + implements ITiledSingleLayerVectorSource { + static createDescriptor({ + urlTemplate, + layerName, + minSourceZoom, + maxSourceZoom, + }: TiledSingleLayerVectorSourceDescriptor) { + return { + type: SOURCE_TYPES.MVT_SINGLE_LAYER, + id: uuid(), + urlTemplate, + layerName, + minSourceZoom: Math.max(MIN_ZOOM, minSourceZoom), + maxSourceZoom: Math.min(MAX_ZOOM, maxSourceZoom), + }; + } + + readonly _descriptor: TiledSingleLayerVectorSourceDescriptor; + + constructor( + sourceDescriptor: TiledSingleLayerVectorSourceDescriptor, + inspectorAdapters?: object + ) { + super(sourceDescriptor, inspectorAdapters); + this._descriptor = sourceDescriptor; + } + + renderSourceSettingsEditor() { + return null; + } + + getFieldNames(): string[] { + return []; + } + + createDefaultLayer(options: LayerDescriptor): TiledVectorLayer { + const layerDescriptor = { + sourceDescriptor: this._descriptor, + ...options, + }; + const normalizedLayerDescriptor = TiledVectorLayer.createDescriptor(layerDescriptor, []); + const vectorLayerArguments: VectorLayerArguments = { + layerDescriptor: normalizedLayerDescriptor, + source: this, + }; + return new TiledVectorLayer(vectorLayerArguments); + } + + getGeoJsonWithMeta( + layerName: 'string', + searchFilters: unknown[], + registerCancelCallback: (callback: () => void) => void + ): Promise<GeoJsonWithMeta> { + // todo: remove this method + // This is a consequence of ITiledSingleLayerVectorSource extending IVectorSource. + throw new Error('Does not implement getGeoJsonWithMeta'); + } + + async getFields(): Promise<IField[]> { + return []; + } + + async getImmutableProperties(): Promise<ImmutableSourceProperty[]> { + return [ + { label: getDataSourceLabel(), value: sourceTitle }, + { label: getUrlLabel(), value: this._descriptor.urlTemplate }, + { + label: i18n.translate('xpack.maps.source.MVTSingleLayerVectorSource.layerNameMessage', { + defaultMessage: 'Layer name', + }), + value: this._descriptor.layerName, + }, + { + label: i18n.translate('xpack.maps.source.MVTSingleLayerVectorSource.minZoomMessage', { + defaultMessage: 'Min zoom', + }), + value: this._descriptor.minSourceZoom.toString(), + }, + { + label: i18n.translate('xpack.maps.source.MVTSingleLayerVectorSource.maxZoomMessage', { + defaultMessage: 'Max zoom', + }), + value: this._descriptor.maxSourceZoom.toString(), + }, + ]; + } + + async getDisplayName(): Promise<string> { + return this._descriptor.layerName; + } + + async getUrlTemplateWithMeta() { + return { + urlTemplate: this._descriptor.urlTemplate, + layerName: this._descriptor.layerName, + minSourceZoom: this._descriptor.minSourceZoom, + maxSourceZoom: this._descriptor.maxSourceZoom, + }; + } + + async getSupportedShapeTypes(): Promise<VECTOR_SHAPE_TYPES[]> { + return [VECTOR_SHAPE_TYPES.POINT, VECTOR_SHAPE_TYPES.LINE, VECTOR_SHAPE_TYPES.POLYGON]; + } + + canFormatFeatureProperties() { + return false; + } + + getMinZoom() { + return this._descriptor.minSourceZoom; + } + + getMaxZoom() { + return this._descriptor.maxSourceZoom; + } + + getBoundsForFilters(searchFilters: VectorSourceRequestMeta): MapExtent { + return { + maxLat: 90, + maxLon: 180, + minLat: -90, + minLon: -180, + }; + } + + getFieldByName(fieldName: string): IField | null { + return null; + } + + getSyncMeta(): VectorSourceSyncMeta { + return null; + } + + getApplyGlobalQuery(): boolean { + return false; + } +} + +registerSource({ + ConstructorFunction: MVTSingleLayerVectorSource, + type: SOURCE_TYPES.MVT_SINGLE_LAYER, +}); diff --git a/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source_editor.tsx b/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source_editor.tsx new file mode 100644 index 0000000000000..7a4b8d43811da --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source_editor.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + +import React, { Fragment, Component, ChangeEvent } from 'react'; +import _ from 'lodash'; +import { EuiFieldText, EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { MAX_ZOOM, MIN_ZOOM } from '../../../../common/constants'; +import { ValidatedDualRange, Value } from '../../../../../../../src/plugins/kibana_react/public'; + +export type MVTSingleLayerVectorSourceConfig = { + urlTemplate: string; + layerName: string; + minSourceZoom: number; + maxSourceZoom: number; +}; + +export interface Props { + onSourceConfigChange: (sourceConfig: MVTSingleLayerVectorSourceConfig) => void; +} + +interface State { + urlTemplate: string; + layerName: string; + minSourceZoom: number; + maxSourceZoom: number; +} + +export class MVTSingleLayerVectorSourceEditor extends Component<Props, State> { + state = { + urlTemplate: '', + layerName: '', + minSourceZoom: MIN_ZOOM, + maxSourceZoom: MAX_ZOOM, + }; + + _sourceConfigChange = _.debounce(() => { + const canPreview = + this.state.urlTemplate.indexOf('{x}') >= 0 && + this.state.urlTemplate.indexOf('{y}') >= 0 && + this.state.urlTemplate.indexOf('{z}') >= 0; + + if (canPreview && this.state.layerName) { + this.props.onSourceConfigChange({ + urlTemplate: this.state.urlTemplate, + layerName: this.state.layerName, + minSourceZoom: this.state.minSourceZoom, + maxSourceZoom: this.state.maxSourceZoom, + }); + } + }, 200); + + _handleUrlTemplateChange = (e: ChangeEvent<HTMLInputElement>) => { + const url = e.target.value; + this.setState( + { + urlTemplate: url, + }, + () => this._sourceConfigChange() + ); + }; + + _handleLayerNameInputChange = (e: ChangeEvent<HTMLInputElement>) => { + const layerName = e.target.value; + this.setState( + { + layerName, + }, + () => this._sourceConfigChange() + ); + }; + + _handleZoomRangeChange = (e: Value) => { + const minSourceZoom = parseInt(e[0] as string, 10); + const maxSourceZoom = parseInt(e[1] as string, 10); + + if (this.state.minSourceZoom !== minSourceZoom || this.state.maxSourceZoom !== maxSourceZoom) { + this.setState({ minSourceZoom, maxSourceZoom }, () => this._sourceConfigChange()); + } + }; + + render() { + return ( + <Fragment> + <EuiFormRow + label={i18n.translate('xpack.maps.source.MVTSingleLayerVectorSourceEditor.urlMessage', { + defaultMessage: 'Url', + })} + > + <EuiFieldText value={this.state.urlTemplate} onChange={this._handleUrlTemplateChange} /> + </EuiFormRow> + <EuiFormRow + label={i18n.translate( + 'xpack.maps.source.MVTSingleLayerVectorSourceEditor.layerNameMessage', + { + defaultMessage: 'Layer name', + } + )} + > + <EuiFieldText value={this.state.layerName} onChange={this._handleLayerNameInputChange} /> + </EuiFormRow> + <ValidatedDualRange + label="" + formRowDisplay="columnCompressed" + min={MIN_ZOOM} + max={MAX_ZOOM} + value={[this.state.minSourceZoom, this.state.maxSourceZoom]} + showInput="inputWithPopover" + showRange + showLabels + onChange={this._handleZoomRangeChange} + allowEmptyRange={false} + compressed + prepend={i18n.translate( + 'xpack.maps.source.MVTSingleLayerVectorSourceEditor.dataZoomRangeMessage', + { + defaultMessage: 'Zoom levels', + } + )} + /> + </Fragment> + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/source.d.ts b/x-pack/plugins/maps/public/layers/sources/source.d.ts index e1706ad7b7d77..5a01da02adaae 100644 --- a/x-pack/plugins/maps/public/layers/sources/source.d.ts +++ b/x-pack/plugins/maps/public/layers/sources/source.d.ts @@ -3,12 +3,23 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ -import { AbstractSourceDescriptor } from '../../../common/descriptor_types'; +import { AbstractSourceDescriptor, LayerDescriptor } from '../../../common/descriptor_types'; import { ILayer } from '../layer'; +export type ImmutableSourceProperty = { + label: string; + value: string; +}; + +export type Attribution = { + url: string; + label: string; +}; + export interface ISource { - createDefaultLayer(): ILayer; + createDefaultLayer(options?: LayerDescriptor): ILayer; destroy(): void; getDisplayName(): Promise<string>; getInspectorAdapters(): object; @@ -18,13 +29,18 @@ export interface ISource { isQueryAware(): boolean; isRefreshTimerAware(): Promise<boolean>; isTimeAware(): Promise<boolean>; + getImmutableProperties(): Promise<ImmutableSourceProperty[]>; + getAttributions(): Promise<Attribution[]>; + getMinZoom(): number; + getMaxZoom(): number; } export class AbstractSource implements ISource { - constructor(sourceDescriptor: AbstractSourceDescriptor, inspectorAdapters: object); + readonly _descriptor: AbstractSourceDescriptor; + constructor(sourceDescriptor: AbstractSourceDescriptor, inspectorAdapters?: object); destroy(): void; - createDefaultLayer(): ILayer; + createDefaultLayer(options?: LayerDescriptor, mapColors?: string[]): ILayer; getDisplayName(): Promise<string>; getInspectorAdapters(): object; isFieldAware(): boolean; @@ -33,4 +49,8 @@ export class AbstractSource implements ISource { isQueryAware(): boolean; isRefreshTimerAware(): Promise<boolean>; isTimeAware(): Promise<boolean>; + getImmutableProperties(): Promise<ImmutableSourceProperty[]>; + getAttributions(): Promise<Attribution[]>; + getMinZoom(): number; + getMaxZoom(): number; } diff --git a/x-pack/plugins/maps/public/layers/sources/source.js b/x-pack/plugins/maps/public/layers/sources/source.js index 3029a5c091202..555b8999d6284 100644 --- a/x-pack/plugins/maps/public/layers/sources/source.js +++ b/x-pack/plugins/maps/public/layers/sources/source.js @@ -6,6 +6,7 @@ // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { copyPersistentState } from '../../reducers/util'; +import { MIN_ZOOM, MAX_ZOOM } from '../../../common/constants'; export class AbstractSource { static isIndexingSource = false; @@ -79,7 +80,7 @@ export class AbstractSource { return false; } - isQueryAware() { + async isTimeAware() { return false; } @@ -107,6 +108,14 @@ export class AbstractSource { return []; } + isFilterByMapBounds() { + return false; + } + + isQueryAware() { + return false; + } + getGeoGridPrecision() { return 0; } @@ -140,4 +149,12 @@ export class AbstractSource { async getValueSuggestions(/* field, query */) { return []; } + + getMinZoom() { + return MIN_ZOOM; + } + + getMaxZoom() { + return MAX_ZOOM; + } } diff --git a/x-pack/plugins/maps/public/layers/sources/source_registry.ts b/x-pack/plugins/maps/public/layers/sources/source_registry.ts index 518cab68b601b..3b334d45092ad 100644 --- a/x-pack/plugins/maps/public/layers/sources/source_registry.ts +++ b/x-pack/plugins/maps/public/layers/sources/source_registry.ts @@ -5,13 +5,12 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ -import { AbstractSourceDescriptor } from '../../../common/descriptor_types'; import { ISource } from './source'; type SourceRegistryEntry = { ConstructorFunction: new ( - sourceDescriptor: AbstractSourceDescriptor, - inspectorAdapters: unknown + sourceDescriptor: any, // this is the source-descriptor that corresponds specifically to the particular ISource instance + inspectorAdapters?: object ) => ISource; type: string; }; diff --git a/x-pack/plugins/maps/public/layers/sources/tms_source/index.ts b/x-pack/plugins/maps/public/layers/sources/tms_source/index.ts new file mode 100644 index 0000000000000..f6bb1c6bc34ec --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/tms_source/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 * from './tms_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/tms_source.d.ts b/x-pack/plugins/maps/public/layers/sources/tms_source/tms_source.d.ts similarity index 80% rename from x-pack/plugins/maps/public/layers/sources/tms_source.d.ts rename to x-pack/plugins/maps/public/layers/sources/tms_source/tms_source.d.ts index 90b6f28e050fd..b31138f4cdb86 100644 --- a/x-pack/plugins/maps/public/layers/sources/tms_source.d.ts +++ b/x-pack/plugins/maps/public/layers/sources/tms_source/tms_source.d.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AbstractSource, ISource } from './source'; +import { AbstractSource, Attribution, ISource } from '../source'; export interface ITMSSource extends ISource { getUrlTemplate(): Promise<string>; @@ -12,4 +12,5 @@ export interface ITMSSource extends ISource { export class AbstractTMSSource extends AbstractSource implements ITMSSource { getUrlTemplate(): Promise<string>; + getAttributions(): Promise<Attribution[]>; } diff --git a/x-pack/plugins/maps/public/layers/sources/tms_source.js b/x-pack/plugins/maps/public/layers/sources/tms_source/tms_source.js similarity index 94% rename from x-pack/plugins/maps/public/layers/sources/tms_source.js rename to x-pack/plugins/maps/public/layers/sources/tms_source/tms_source.js index f2ec9f2a29378..13b8da11633bc 100644 --- a/x-pack/plugins/maps/public/layers/sources/tms_source.js +++ b/x-pack/plugins/maps/public/layers/sources/tms_source/tms_source.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AbstractSource } from './source'; +import { AbstractSource } from '../source'; export class AbstractTMSSource extends AbstractSource { async getUrlTemplate() { diff --git a/x-pack/plugins/maps/public/layers/sources/vector_feature_types.js b/x-pack/plugins/maps/public/layers/sources/vector_feature_types.js deleted file mode 100644 index cc5f30389c4f3..0000000000000 --- a/x-pack/plugins/maps/public/layers/sources/vector_feature_types.js +++ /dev/null @@ -1,11 +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 const VECTOR_SHAPE_TYPES = { - POINT: 'POINT', - LINE: 'LINE', - POLYGON: 'POLYGON', -}; diff --git a/x-pack/plugins/maps/public/layers/sources/vector_feature_types.ts b/x-pack/plugins/maps/public/layers/sources/vector_feature_types.ts new file mode 100644 index 0000000000000..9f03357e17dad --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/vector_feature_types.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. + */ + +export enum VECTOR_SHAPE_TYPES { + POINT = 'POINT', + LINE = 'LINE', + POLYGON = 'POLYGON', +} diff --git a/x-pack/plugins/maps/public/layers/sources/vector_source.d.ts b/x-pack/plugins/maps/public/layers/sources/vector_source.d.ts deleted file mode 100644 index d597e64277186..0000000000000 --- a/x-pack/plugins/maps/public/layers/sources/vector_source.d.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. - */ -/* eslint-disable @typescript-eslint/consistent-type-definitions */ - -import { FeatureCollection } from 'geojson'; -import { AbstractSource, ISource } from './source'; -import { IField } from '../fields/field'; -import { - ESSearchSourceResponseMeta, - MapExtent, - VectorSourceRequestMeta, - VectorSourceSyncMeta, -} from '../../../common/descriptor_types'; - -export type GeoJsonFetchMeta = ESSearchSourceResponseMeta; - -export type GeoJsonWithMeta = { - data: FeatureCollection; - meta?: GeoJsonFetchMeta; -}; - -export interface IVectorSource extends ISource { - getBoundsForFilters(searchFilters: VectorSourceRequestMeta): MapExtent; - getGeoJsonWithMeta( - layerName: 'string', - searchFilters: unknown[], - registerCancelCallback: (callback: () => void) => void - ): Promise<GeoJsonWithMeta>; - - getFields(): Promise<IField[]>; - getFieldByName(fieldName: string): IField; - getSyncMeta(): VectorSourceSyncMeta; -} - -export class AbstractVectorSource extends AbstractSource implements IVectorSource { - getBoundsForFilters(searchFilters: VectorSourceRequestMeta): MapExtent; - getGeoJsonWithMeta( - layerName: 'string', - searchFilters: unknown[], - registerCancelCallback: (callback: () => void) => void - ): Promise<GeoJsonWithMeta>; - - getFields(): Promise<IField[]>; - getFieldByName(fieldName: string): IField; - getSyncMeta(): VectorSourceSyncMeta; -} diff --git a/x-pack/plugins/maps/public/layers/sources/vector_source/index.ts b/x-pack/plugins/maps/public/layers/sources/vector_source/index.ts new file mode 100644 index 0000000000000..62e801d027634 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/vector_source/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 * from './vector_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/vector_source/vector_source.d.ts b/x-pack/plugins/maps/public/layers/sources/vector_source/vector_source.d.ts new file mode 100644 index 0000000000000..804915dd73052 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/vector_source/vector_source.d.ts @@ -0,0 +1,67 @@ +/* + * 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. + */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + +import { FeatureCollection } from 'geojson'; +import { AbstractSource, ISource } from '../source'; +import { IField } from '../../fields/field'; +import { + ESSearchSourceResponseMeta, + MapExtent, + VectorSourceRequestMeta, + VectorSourceSyncMeta, +} from '../../../../common/descriptor_types'; +import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; + +export type GeoJsonFetchMeta = ESSearchSourceResponseMeta; + +export type GeoJsonWithMeta = { + data: FeatureCollection; + meta?: GeoJsonFetchMeta; +}; + +export interface IVectorSource extends ISource { + getBoundsForFilters(searchFilters: VectorSourceRequestMeta): MapExtent; + getGeoJsonWithMeta( + layerName: 'string', + searchFilters: unknown[], + registerCancelCallback: (callback: () => void) => void + ): Promise<GeoJsonWithMeta>; + + getFields(): Promise<IField[]>; + getFieldByName(fieldName: string): IField | null; + getSyncMeta(): VectorSourceSyncMeta; + getFieldNames(): string[]; + getApplyGlobalQuery(): boolean; +} + +export class AbstractVectorSource extends AbstractSource implements IVectorSource { + getBoundsForFilters(searchFilters: VectorSourceRequestMeta): MapExtent; + getGeoJsonWithMeta( + layerName: 'string', + searchFilters: unknown[], + registerCancelCallback: (callback: () => void) => void + ): Promise<GeoJsonWithMeta>; + + getFields(): Promise<IField[]>; + getFieldByName(fieldName: string): IField | null; + getSyncMeta(): VectorSourceSyncMeta; + getSupportedShapeTypes(): Promise<VECTOR_SHAPE_TYPES[]>; + canFormatFeatureProperties(): boolean; + getApplyGlobalQuery(): boolean; + getFieldNames(): string[]; +} + +export interface ITiledSingleLayerVectorSource extends IVectorSource { + getUrlTemplateWithMeta(): Promise<{ + layerName: string; + urlTemplate: string; + minSourceZoom: number; + maxSourceZoom: number; + }>; + getMinZoom(): number; + getMaxZoom(): number; +} diff --git a/x-pack/plugins/maps/public/layers/sources/vector_source.js b/x-pack/plugins/maps/public/layers/sources/vector_source/vector_source.js similarity index 92% rename from x-pack/plugins/maps/public/layers/sources/vector_source.js rename to x-pack/plugins/maps/public/layers/sources/vector_source/vector_source.js index 7f97b1b21d189..509584cbc415a 100644 --- a/x-pack/plugins/maps/public/layers/sources/vector_source.js +++ b/x-pack/plugins/maps/public/layers/sources/vector_source/vector_source.js @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { VectorLayer } from '../vector_layer'; -import { TooltipProperty } from '../tooltips/tooltip_property'; -import { VectorStyle } from '../styles/vector/vector_style'; -import { AbstractSource } from './source'; +import { VectorLayer } from '../../vector_layer'; +import { TooltipProperty } from '../../tooltips/tooltip_property'; +import { VectorStyle } from '../../styles/vector/vector_style'; +import { AbstractSource } from './../source'; import * as topojson from 'topojson-client'; import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { VECTOR_SHAPE_TYPES } from './vector_feature_types'; +import { VECTOR_SHAPE_TYPES } from './../vector_feature_types'; export class AbstractVectorSource extends AbstractSource { static async getGeoJson({ format, featureCollectionPath, fetchUrl }) { @@ -60,6 +60,10 @@ export class AbstractVectorSource extends AbstractSource { throw new Error(`Should implemement ${this.constructor.type} ${this}`); } + getFieldNames() { + return []; + } + /** * Retrieves a field. This may be an existing instance. * @param fieldName diff --git a/x-pack/plugins/maps/public/layers/sources/wms_source/wms_source.js b/x-pack/plugins/maps/public/layers/sources/wms_source/wms_source.js index 749560a2bb4b1..33f764784124e 100644 --- a/x-pack/plugins/maps/public/layers/sources/wms_source/wms_source.js +++ b/x-pack/plugins/maps/public/layers/sources/wms_source/wms_source.js @@ -12,7 +12,7 @@ import { WMSCreateSourceEditor } from './wms_create_source_editor'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel, getUrlLabel } from '../../../../common/i18n_getters'; import { WmsClient } from './wms_client'; -import { WMS } from '../../../../common/constants'; +import { SOURCE_TYPES } from '../../../../common/constants'; import { registerSource } from '../source_registry'; const sourceTitle = i18n.translate('xpack.maps.source.wmsTitle', { @@ -20,7 +20,7 @@ const sourceTitle = i18n.translate('xpack.maps.source.wmsTitle', { }); export class WMSSource extends AbstractTMSSource { - static type = WMS; + static type = SOURCE_TYPES.WMS; static createDescriptor({ serviceUrl, layers, styles, attributionText, attributionUrl }) { return { @@ -92,7 +92,7 @@ export class WMSSource extends AbstractTMSSource { registerSource({ ConstructorFunction: WMSSource, - type: WMS, + type: SOURCE_TYPES.WMS, }); export const wmsLayerWizardConfig = { diff --git a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source.d.ts b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source.d.ts deleted file mode 100644 index 579c9debeab3e..0000000000000 --- a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source.d.ts +++ /dev/null @@ -1,11 +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 { AbstractTMSSource } from './tms_source'; -import { XYZTMSSourceDescriptor } from '../../../common/descriptor_types'; - -export class XYZTMSSource extends AbstractTMSSource { - constructor(sourceDescriptor: XYZTMSSourceDescriptor, inspectorAdapters: unknown); -} diff --git a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source.js b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source.js deleted file mode 100644 index d53fbffd21512..0000000000000 --- a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source.js +++ /dev/null @@ -1,187 +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 React, { Fragment } from 'react'; -import { EuiFieldText, EuiFormRow } from '@elastic/eui'; - -import { AbstractTMSSource } from './tms_source'; -import { TileLayer } from '../tile_layer'; -import { i18n } from '@kbn/i18n'; -import { getDataSourceLabel, getUrlLabel } from '../../../common/i18n_getters'; -import _ from 'lodash'; -import { EMS_XYZ } from '../../../common/constants'; -import { registerSource } from './source_registry'; - -const sourceTitle = i18n.translate('xpack.maps.source.ems_xyzTitle', { - defaultMessage: 'Tile Map Service', -}); - -export class XYZTMSSource extends AbstractTMSSource { - static type = EMS_XYZ; - - static createDescriptor({ urlTemplate, attributionText, attributionUrl }) { - return { - type: XYZTMSSource.type, - urlTemplate, - attributionText, - attributionUrl, - }; - } - - async getImmutableProperties() { - return [ - { label: getDataSourceLabel(), value: sourceTitle }, - { label: getUrlLabel(), value: this._descriptor.urlTemplate }, - ]; - } - - _createDefaultLayerDescriptor(options) { - return TileLayer.createDescriptor({ - sourceDescriptor: this._descriptor, - ...options, - }); - } - - createDefaultLayer(options) { - return new TileLayer({ - layerDescriptor: this._createDefaultLayerDescriptor(options), - source: this, - }); - } - - async getDisplayName() { - return this._descriptor.urlTemplate; - } - - getAttributions() { - const { attributionText, attributionUrl } = this._descriptor; - const attributionComplete = !!attributionText && !!attributionUrl; - - return attributionComplete - ? [ - { - url: attributionUrl, - label: attributionText, - }, - ] - : []; - } - - getUrlTemplate() { - return this._descriptor.urlTemplate; - } -} - -class XYZTMSEditor extends React.Component { - state = { - tmsInput: '', - tmsCanPreview: false, - attributionText: '', - attributionUrl: '', - }; - - _sourceConfigChange = _.debounce(updatedSourceConfig => { - if (this.state.tmsCanPreview) { - this.props.onSourceConfigChange(updatedSourceConfig); - } - }, 2000); - - _handleTMSInputChange(e) { - const url = e.target.value; - - const canPreview = - url.indexOf('{x}') >= 0 && url.indexOf('{y}') >= 0 && url.indexOf('{z}') >= 0; - this.setState( - { - tmsInput: url, - tmsCanPreview: canPreview, - }, - () => this._sourceConfigChange({ urlTemplate: url }) - ); - } - - _handleTMSAttributionChange(attributionUpdate) { - this.setState(attributionUpdate, () => { - const { attributionText, attributionUrl, tmsInput } = this.state; - - if (tmsInput && attributionText && attributionUrl) { - this._sourceConfigChange({ - urlTemplate: tmsInput, - attributionText, - attributionUrl, - }); - } - }); - } - - render() { - const { attributionText, attributionUrl } = this.state; - - return ( - <Fragment> - <EuiFormRow label="Url"> - <EuiFieldText - placeholder={'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'} - onChange={e => this._handleTMSInputChange(e)} - /> - </EuiFormRow> - <EuiFormRow - label="Attribution text" - isInvalid={attributionUrl !== '' && attributionText === ''} - error={[ - i18n.translate('xpack.maps.xyztmssource.attributionText', { - defaultMessage: 'Attribution url must have accompanying text', - }), - ]} - > - <EuiFieldText - placeholder={'© OpenStreetMap contributors'} - onChange={({ target }) => - this._handleTMSAttributionChange({ attributionText: target.value }) - } - /> - </EuiFormRow> - <EuiFormRow - label="Attribution link" - isInvalid={attributionText !== '' && attributionUrl === ''} - error={[ - i18n.translate('xpack.maps.xyztmssource.attributionLink', { - defaultMessage: 'Attribution text must have an accompanying link', - }), - ]} - > - <EuiFieldText - placeholder={'https://www.openstreetmap.org/copyright'} - onChange={({ target }) => - this._handleTMSAttributionChange({ attributionUrl: target.value }) - } - /> - </EuiFormRow> - </Fragment> - ); - } -} - -registerSource({ - ConstructorFunction: XYZTMSSource, - type: EMS_XYZ, -}); - -export const tmsLayerWizardConfig = { - description: i18n.translate('xpack.maps.source.ems_xyzDescription', { - defaultMessage: 'Tile map service configured in interface', - }), - icon: 'grid', - renderWizard: ({ onPreviewSource, inspectorAdapters }) => { - const onSourceConfigChange = sourceConfig => { - const sourceDescriptor = XYZTMSSource.createDescriptor(sourceConfig); - const source = new XYZTMSSource(sourceDescriptor, inspectorAdapters); - onPreviewSource(source); - }; - return <XYZTMSEditor onSourceConfigChange={onSourceConfigChange} />; - }, - title: sourceTitle, -}; diff --git a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source.test.ts b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source.test.ts deleted file mode 100644 index e5ab5e57122ba..0000000000000 --- a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source.test.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. - */ - -import { XYZTMSSource } from './xyz_tms_source'; -import { ILayer } from '../layer'; -import { TileLayer } from '../tile_layer'; -import { EMS_XYZ } from '../../../common/constants'; -import { XYZTMSSourceDescriptor } from '../../../common/descriptor_types'; - -const descriptor: XYZTMSSourceDescriptor = { - type: EMS_XYZ, - urlTemplate: 'https://example.com/{x}/{y}/{z}.png', - id: 'foobar', -}; -describe('xyz Tilemap Source', () => { - it('should create a tile-layer', () => { - const source = new XYZTMSSource(descriptor, null); - const layer: ILayer = source.createDefaultLayer(); - expect(layer instanceof TileLayer).toEqual(true); - }); - - it('should echo url template for url template', async () => { - const source = new XYZTMSSource(descriptor, null); - const template = await source.getUrlTemplate(); - expect(template).toEqual(descriptor.urlTemplate); - }); -}); diff --git a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/index.ts b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/index.ts new file mode 100644 index 0000000000000..c1116ad47a375 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './xyz_tms_source'; +export * from './layer_wizard'; diff --git a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/layer_wizard.tsx new file mode 100644 index 0000000000000..8b1ed588c8dd1 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/layer_wizard.tsx @@ -0,0 +1,27 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import React from 'react'; +import { XYZTMSEditor, XYZTMSSourceConfig } from './xyz_tms_editor'; +import { XYZTMSSource, sourceTitle } from './xyz_tms_source'; +import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; + +export const tmsLayerWizardConfig: LayerWizard = { + description: i18n.translate('xpack.maps.source.ems_xyzDescription', { + defaultMessage: 'Tile map service configured in interface', + }), + icon: 'grid', + renderWizard: ({ onPreviewSource }: RenderWizardArguments) => { + const onSourceConfigChange = (sourceConfig: XYZTMSSourceConfig) => { + const sourceDescriptor = XYZTMSSource.createDescriptor(sourceConfig); + const source = new XYZTMSSource(sourceDescriptor); + onPreviewSource(source); + }; + return <XYZTMSEditor onSourceConfigChange={onSourceConfigChange} />; + }, + title: sourceTitle, +}; diff --git a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_editor.tsx b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_editor.tsx new file mode 100644 index 0000000000000..0ee0fbb23bcff --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_editor.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + +import React, { Fragment, Component, ChangeEvent } from 'react'; +import _ from 'lodash'; +import { EuiFormRow, EuiFieldText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { AttributionDescriptor } from '../../../../common/descriptor_types'; + +export type XYZTMSSourceConfig = AttributionDescriptor & { + urlTemplate: string; +}; + +export interface Props { + onSourceConfigChange: (sourceConfig: XYZTMSSourceConfig) => void; +} + +interface State { + tmsInput: string; + tmsCanPreview: boolean; + attributionText: string; + attributionUrl: string; +} + +export class XYZTMSEditor extends Component<Props, State> { + state = { + tmsInput: '', + tmsCanPreview: false, + attributionText: '', + attributionUrl: '', + }; + + _sourceConfigChange = _.debounce((updatedSourceConfig: XYZTMSSourceConfig) => { + if (this.state.tmsCanPreview) { + this.props.onSourceConfigChange(updatedSourceConfig); + } + }, 2000); + + _handleTMSInputChange(e: ChangeEvent<HTMLInputElement>) { + const url = e.target.value; + + const canPreview = + url.indexOf('{x}') >= 0 && url.indexOf('{y}') >= 0 && url.indexOf('{z}') >= 0; + this.setState( + { + tmsInput: url, + tmsCanPreview: canPreview, + }, + () => this._sourceConfigChange({ urlTemplate: url }) + ); + } + + _handleTMSAttributionChange(attributionUpdate: AttributionDescriptor) { + this.setState( + { + attributionUrl: attributionUpdate.attributionUrl || '', + attributionText: attributionUpdate.attributionText || '', + }, + () => { + const { attributionText, attributionUrl, tmsInput } = this.state; + + if (tmsInput && attributionText && attributionUrl) { + this._sourceConfigChange({ + urlTemplate: tmsInput, + attributionText, + attributionUrl, + }); + } + } + ); + } + + render() { + const { attributionText, attributionUrl } = this.state; + return ( + <Fragment> + <EuiFormRow label="Url"> + <EuiFieldText + placeholder={'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'} + onChange={e => this._handleTMSInputChange(e)} + /> + </EuiFormRow> + <EuiFormRow + label="Attribution text" + isInvalid={attributionUrl !== '' && attributionText === ''} + error={[ + i18n.translate('xpack.maps.xyztmssource.attributionText', { + defaultMessage: 'Attribution url must have accompanying text', + }), + ]} + > + <EuiFieldText + placeholder={'© OpenStreetMap contributors'} + onChange={({ target }: ChangeEvent<HTMLInputElement>) => + this._handleTMSAttributionChange({ attributionText: target.value }) + } + /> + </EuiFormRow> + <EuiFormRow + label="Attribution link" + isInvalid={attributionText !== '' && attributionUrl === ''} + error={[ + i18n.translate('xpack.maps.xyztmssource.attributionLink', { + defaultMessage: 'Attribution text must have an accompanying link', + }), + ]} + > + <EuiFieldText + placeholder={'https://www.openstreetmap.org/copyright'} + onChange={({ target }: ChangeEvent<HTMLInputElement>) => + this._handleTMSAttributionChange({ attributionUrl: target.value }) + } + /> + </EuiFormRow> + </Fragment> + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_source.test.ts b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_source.test.ts new file mode 100644 index 0000000000000..4031a18bff7cb --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_source.test.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 { XYZTMSSource } from './xyz_tms_source'; +import { ILayer } from '../../layer'; +import { TileLayer } from '../../tile_layer'; +import { SOURCE_TYPES } from '../../../../common/constants'; +import { XYZTMSSourceDescriptor } from '../../../../common/descriptor_types'; + +const descriptor: XYZTMSSourceDescriptor = { + type: SOURCE_TYPES.EMS_XYZ, + urlTemplate: 'https://example.com/{x}/{y}/{z}.png', + id: 'foobar', +}; +describe('xyz Tilemap Source', () => { + it('should create a tile-layer', () => { + const source = new XYZTMSSource(descriptor); + const layer: ILayer = source.createDefaultLayer(); + expect(layer instanceof TileLayer).toEqual(true); + }); + + it('should echo url template for url template', async () => { + const source = new XYZTMSSource(descriptor); + const template = await source.getUrlTemplate(); + expect(template).toEqual(descriptor.urlTemplate); + }); +}); diff --git a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_source.ts b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_source.ts new file mode 100644 index 0000000000000..8b64480f92961 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_source.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { TileLayer } from '../../tile_layer'; +import { getDataSourceLabel, getUrlLabel } from '../../../../common/i18n_getters'; +import { SOURCE_TYPES } from '../../../../common/constants'; +import { registerSource } from '../source_registry'; +import { AbstractTMSSource } from '../tms_source'; +import { LayerDescriptor, XYZTMSSourceDescriptor } from '../../../../common/descriptor_types'; +import { Attribution, ImmutableSourceProperty } from '../source'; +import { XYZTMSSourceConfig } from './xyz_tms_editor'; + +export const sourceTitle = i18n.translate('xpack.maps.source.ems_xyzTitle', { + defaultMessage: 'Tile Map Service', +}); + +export class XYZTMSSource extends AbstractTMSSource { + static type = SOURCE_TYPES.EMS_XYZ; + + readonly _descriptor: XYZTMSSourceDescriptor; + + static createDescriptor({ + urlTemplate, + attributionText, + attributionUrl, + }: XYZTMSSourceConfig): XYZTMSSourceDescriptor { + return { + type: XYZTMSSource.type, + urlTemplate, + attributionText, + attributionUrl, + }; + } + + constructor(sourceDescriptor: XYZTMSSourceDescriptor) { + super(sourceDescriptor); + this._descriptor = sourceDescriptor; + } + + async getImmutableProperties(): Promise<ImmutableSourceProperty[]> { + return [ + { label: getDataSourceLabel(), value: sourceTitle }, + { label: getUrlLabel(), value: this._descriptor.urlTemplate }, + ]; + } + + createDefaultLayer(options?: LayerDescriptor): TileLayer { + const layerDescriptor: LayerDescriptor = TileLayer.createDescriptor({ + sourceDescriptor: this._descriptor, + ...options, + }); + return new TileLayer({ + layerDescriptor, + source: this, + }); + } + + async getDisplayName(): Promise<string> { + return this._descriptor.urlTemplate; + } + + async getAttributions(): Promise<Attribution[]> { + const { attributionText, attributionUrl } = this._descriptor; + const attributionComplete = !!attributionText && !!attributionUrl; + return attributionComplete + ? [ + { + url: attributionUrl as string, + label: attributionText as string, + }, + ] + : []; + } + + async getUrlTemplate(): Promise<string> { + return this._descriptor.urlTemplate; + } +} + +registerSource({ + ConstructorFunction: XYZTMSSource, + type: SOURCE_TYPES.EMS_XYZ, +}); diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js b/x-pack/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js index 6cece5efb3a5d..c46dc2cb4b73e 100644 --- a/x-pack/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js @@ -37,7 +37,7 @@ export class VectorStyleEditor extends Component { defaultDynamicProperties: getDefaultDynamicProperties(), defaultStaticProperties: getDefaultStaticProperties(), supportedFeatures: undefined, - selectedFeatureType: undefined, + selectedFeature: null, }; componentWillUnmount() { @@ -91,18 +91,20 @@ export class VectorStyleEditor extends Component { return; } - let selectedFeature = VECTOR_SHAPE_TYPES.POLYGON; - if (this.props.isPointsOnly) { - selectedFeature = VECTOR_SHAPE_TYPES.POINT; - } else if (this.props.isLinesOnly) { - selectedFeature = VECTOR_SHAPE_TYPES.LINE; + if (!_.isEqual(supportedFeatures, this.state.supportedFeatures)) { + this.setState({ supportedFeatures }); } - if ( - !_.isEqual(supportedFeatures, this.state.supportedFeatures) || - selectedFeature !== this.state.selectedFeature - ) { - this.setState({ supportedFeatures, selectedFeature }); + if (this.state.selectedFeature === null) { + let selectedFeature = VECTOR_SHAPE_TYPES.POLYGON; + if (this.props.isPointsOnly) { + selectedFeature = VECTOR_SHAPE_TYPES.POINT; + } else if (this.props.isLinesOnly) { + selectedFeature = VECTOR_SHAPE_TYPES.LINE; + } + this.setState({ + selectedFeature: selectedFeature, + }); } } diff --git a/x-pack/plugins/maps/public/layers/styles/vector/vector_style.d.ts b/x-pack/plugins/maps/public/layers/styles/vector/vector_style.d.ts index 77ea44ac26bf9..e010d5ac7d7a3 100644 --- a/x-pack/plugins/maps/public/layers/styles/vector/vector_style.d.ts +++ b/x-pack/plugins/maps/public/layers/styles/vector/vector_style.d.ts @@ -7,17 +7,23 @@ import { IStyleProperty } from './properties/style_property'; import { IDynamicStyleProperty } from './properties/dynamic_style_property'; import { IVectorLayer } from '../../vector_layer'; import { IVectorSource } from '../../sources/vector_source'; -import { VectorStyleDescriptor } from '../../../../common/descriptor_types'; +import { + VectorStyleDescriptor, + VectorStylePropertiesDescriptor, +} from '../../../../common/descriptor_types'; export interface IVectorStyle { getAllStyleProperties(): IStyleProperty[]; getDescriptor(): VectorStyleDescriptor; getDynamicPropertiesArray(): IDynamicStyleProperty[]; + getSourceFieldNames(): string[]; } export class VectorStyle implements IVectorStyle { + static createDescriptor(properties: VectorStylePropertiesDescriptor): VectorStyleDescriptor; + static createDefaultStyleProperties(mapColors: string[]): VectorStylePropertiesDescriptor; constructor(descriptor: VectorStyleDescriptor, source: IVectorSource, layer: IVectorLayer); - + getSourceFieldNames(): string[]; getAllStyleProperties(): IStyleProperty[]; getDescriptor(): VectorStyleDescriptor; getDynamicPropertiesArray(): IDynamicStyleProperty[]; diff --git a/x-pack/plugins/maps/public/layers/tile_layer.js b/x-pack/plugins/maps/public/layers/tile_layer.js index aa2619e96f834..2ac60e12d137a 100644 --- a/x-pack/plugins/maps/public/layers/tile_layer.js +++ b/x-pack/plugins/maps/public/layers/tile_layer.js @@ -11,8 +11,8 @@ import { SOURCE_DATA_ID_ORIGIN, LAYER_TYPE } from '../../common/constants'; export class TileLayer extends AbstractLayer { static type = LAYER_TYPE.TILE; - static createDescriptor(options) { - const tileLayerDescriptor = super.createDescriptor(options); + static createDescriptor(options, mapColors) { + const tileLayerDescriptor = super.createDescriptor(options, mapColors); tileLayerDescriptor.type = TileLayer.type; tileLayerDescriptor.alpha = _.get(options, 'alpha', 1); return tileLayerDescriptor; diff --git a/x-pack/plugins/maps/public/layers/tile_layer.test.ts b/x-pack/plugins/maps/public/layers/tile_layer.test.ts index 1cb99dcbc1a70..f8c2fd9db60fa 100644 --- a/x-pack/plugins/maps/public/layers/tile_layer.test.ts +++ b/x-pack/plugins/maps/public/layers/tile_layer.test.ts @@ -5,19 +5,19 @@ */ import { TileLayer } from './tile_layer'; -import { EMS_XYZ } from '../../common/constants'; +import { SOURCE_TYPES } from '../../common/constants'; import { XYZTMSSourceDescriptor } from '../../common/descriptor_types'; import { ITMSSource, AbstractTMSSource } from './sources/tms_source'; import { ILayer } from './layer'; const sourceDescriptor: XYZTMSSourceDescriptor = { - type: EMS_XYZ, + type: SOURCE_TYPES.EMS_XYZ, urlTemplate: 'https://example.com/{x}/{y}/{z}.png', id: 'foobar', }; class MockTileSource extends AbstractTMSSource implements ITMSSource { - private readonly _descriptor: XYZTMSSourceDescriptor; + readonly _descriptor: XYZTMSSourceDescriptor; constructor(descriptor: XYZTMSSourceDescriptor) { super(descriptor, {}); this._descriptor = descriptor; diff --git a/x-pack/plugins/maps/public/layers/tiled_vector_layer.tsx b/x-pack/plugins/maps/public/layers/tiled_vector_layer.tsx new file mode 100644 index 0000000000000..c47cae5641e56 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/tiled_vector_layer.tsx @@ -0,0 +1,170 @@ +/* + * 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 { EuiIcon } from '@elastic/eui'; +import { VectorStyle } from './styles/vector/vector_style'; +import { SOURCE_DATA_ID_ORIGIN, LAYER_TYPE } from '../../common/constants'; +import { VectorLayer, VectorLayerArguments } from './vector_layer'; +import { canSkipSourceUpdate } from './util/can_skip_fetch'; +import { ITiledSingleLayerVectorSource } from './sources/vector_source'; +import { SyncContext } from '../actions/map_actions'; +import { ISource } from './sources/source'; +import { VectorLayerDescriptor, VectorSourceRequestMeta } from '../../common/descriptor_types'; +import { MVTSingleLayerVectorSourceConfig } from './sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source_editor'; + +export class TiledVectorLayer extends VectorLayer { + static type = LAYER_TYPE.TILED_VECTOR; + + static createDescriptor( + descriptor: VectorLayerDescriptor, + mapColors: string[] + ): VectorLayerDescriptor { + const layerDescriptor = super.createDescriptor(descriptor, mapColors); + layerDescriptor.type = TiledVectorLayer.type; + + if (!layerDescriptor.style) { + const styleProperties = VectorStyle.createDefaultStyleProperties(mapColors); + layerDescriptor.style = VectorStyle.createDescriptor(styleProperties); + } + + return layerDescriptor; + } + + readonly _source: ITiledSingleLayerVectorSource; // downcast to the more specific type + + constructor({ layerDescriptor, source }: VectorLayerArguments) { + super({ layerDescriptor, source }); + this._source = source as ITiledSingleLayerVectorSource; + } + + getCustomIconAndTooltipContent() { + return { + icon: <EuiIcon size="m" type={this.getLayerTypeIconName()} />, + }; + } + + async _syncMVTUrlTemplate({ startLoading, stopLoading, onLoadError, dataFilters }: SyncContext) { + const requestToken: symbol = Symbol(`layer-${this.getId()}-${SOURCE_DATA_ID_ORIGIN}`); + const searchFilters: VectorSourceRequestMeta = this._getSearchFilters( + dataFilters, + this.getSource(), + this._style + ); + const prevDataRequest = this.getSourceDataRequest(); + + const canSkip = await canSkipSourceUpdate({ + source: this._source as ISource, + prevDataRequest, + nextMeta: searchFilters, + }); + if (canSkip) { + return null; + } + + startLoading(SOURCE_DATA_ID_ORIGIN, requestToken, searchFilters); + try { + const templateWithMeta = await this._source.getUrlTemplateWithMeta(); + stopLoading(SOURCE_DATA_ID_ORIGIN, requestToken, templateWithMeta, {}); + } catch (error) { + onLoadError(SOURCE_DATA_ID_ORIGIN, requestToken, error.message); + } + } + + async syncData(syncContext: SyncContext) { + if (!this.isVisible() || !this.showAtZoomLevel(syncContext.dataFilters.zoom)) { + return; + } + + await this._syncSourceStyleMeta(syncContext, this._source, this._style); + await this._syncSourceFormatters(syncContext, this._source, this._style); + await this._syncMVTUrlTemplate(syncContext); + } + + _syncSourceBindingWithMb(mbMap: unknown) { + // @ts-ignore + const mbSource = mbMap.getSource(this.getId()); + if (!mbSource) { + const sourceDataRequest = this.getSourceDataRequest(); + if (!sourceDataRequest) { + // this is possible if the layer was invisible at startup. + // the actions will not perform any data=syncing as an optimization when a layer is invisible + // when turning the layer back into visible, it's possible the url has not been resovled yet. + return; + } + + const sourceMeta: MVTSingleLayerVectorSourceConfig | null = sourceDataRequest.getData() as MVTSingleLayerVectorSourceConfig; + if (!sourceMeta) { + return; + } + + const sourceId = this.getId(); + + // @ts-ignore + mbMap.addSource(sourceId, { + type: 'vector', + tiles: [sourceMeta.urlTemplate], + minzoom: sourceMeta.minSourceZoom, + maxzoom: sourceMeta.maxSourceZoom, + }); + } + } + + _syncStylePropertiesWithMb(mbMap: unknown) { + // @ts-ignore + const mbSource = mbMap.getSource(this.getId()); + if (!mbSource) { + return; + } + + const sourceDataRequest = this.getSourceDataRequest(); + if (!sourceDataRequest) { + return; + } + const sourceMeta: MVTSingleLayerVectorSourceConfig = sourceDataRequest.getData() as MVTSingleLayerVectorSourceConfig; + + this._setMbPointsProperties(mbMap, sourceMeta.layerName); + this._setMbLinePolygonProperties(mbMap, sourceMeta.layerName); + } + + _requiresPrevSourceCleanup(mbMap: unknown): boolean { + // @ts-ignore + const mbTileSource = mbMap.getSource(this.getId()); + if (!mbTileSource) { + return false; + } + const dataRequest = this.getSourceDataRequest(); + if (!dataRequest) { + return false; + } + const tiledSourceMeta: MVTSingleLayerVectorSourceConfig | null = dataRequest.getData() as MVTSingleLayerVectorSourceConfig; + if ( + mbTileSource.tiles[0] === tiledSourceMeta.urlTemplate && + mbTileSource.minzoom === tiledSourceMeta.minSourceZoom && + mbTileSource.maxzoom === tiledSourceMeta.maxSourceZoom + ) { + // TileURL and zoom-range captures all the state. If this does not change, no updates are required. + return false; + } + + return true; + } + + syncLayerWithMB(mbMap: unknown) { + this._removeStaleMbSourcesAndLayers(mbMap); + this._syncSourceBindingWithMb(mbMap); + this._syncStylePropertiesWithMb(mbMap); + } + + getJoins() { + return []; + } + + getMinZoom() { + // higher resolution vector tiles cannot be displayed at lower-res + return Math.max(this._source.getMinZoom(), super.getMinZoom()); + } +} diff --git a/x-pack/plugins/maps/public/layers/util/data_request.ts b/x-pack/plugins/maps/public/layers/util/data_request.ts index eeef5c49c6ef8..44b7b2ffb6ae7 100644 --- a/x-pack/plugins/maps/public/layers/util/data_request.ts +++ b/x-pack/plugins/maps/public/layers/util/data_request.ts @@ -26,9 +26,13 @@ export class DataRequest { } getMeta(): DataMeta { - return this.hasData() - ? _.get(this._descriptor, 'dataMeta', {}) - : _.get(this._descriptor, 'dataMetaAtStart', {}); + if (this._descriptor.dataMetaAtStart) { + return this._descriptor.dataMetaAtStart; + } else if (this._descriptor.dataMeta) { + return this._descriptor.dataMeta; + } else { + return {}; + } } hasData(): boolean { diff --git a/x-pack/plugins/maps/public/layers/vector_layer.d.ts b/x-pack/plugins/maps/public/layers/vector_layer.d.ts index 70fd9927b7732..3d5b8054ff3fd 100644 --- a/x-pack/plugins/maps/public/layers/vector_layer.d.ts +++ b/x-pack/plugins/maps/public/layers/vector_layer.d.ts @@ -9,6 +9,7 @@ import { AbstractLayer } from './layer'; import { IVectorSource } from './sources/vector_source'; import { MapFilters, + LayerDescriptor, VectorLayerDescriptor, VectorSourceRequestMeta, } from '../../common/descriptor_types'; @@ -20,7 +21,7 @@ import { SyncContext } from '../actions/map_actions'; type VectorLayerArguments = { source: IVectorSource; - joins: IJoin[]; + joins?: IJoin[]; layerDescriptor: VectorLayerDescriptor; }; @@ -28,26 +29,43 @@ export interface IVectorLayer extends ILayer { getFields(): Promise<IField[]>; getStyleEditorFields(): Promise<IField[]>; getValidJoins(): IJoin[]; + getSource(): IVectorSource; } export class VectorLayer extends AbstractLayer implements IVectorLayer { static createDescriptor( - options: VectorLayerArguments, - mapColors: string[] + options: Partial<LayerDescriptor>, + mapColors?: string[] ): VectorLayerDescriptor; protected readonly _source: IVectorSource; protected readonly _style: IVectorStyle; constructor(options: VectorLayerArguments); - + getLayerTypeIconName(): string; getFields(): Promise<IField[]>; getStyleEditorFields(): Promise<IField[]>; getValidJoins(): IJoin[]; + _syncSourceStyleMeta( + syncContext: SyncContext, + source: IVectorSource, + style: IVectorStyle + ): Promise<void>; + _syncSourceFormatters( + syncContext: SyncContext, + source: IVectorSource, + style: IVectorStyle + ): Promise<void>; + syncLayerWithMB(mbMap: unknown): void; _getSearchFilters( dataFilters: MapFilters, source: IVectorSource, style: IVectorStyle ): VectorSourceRequestMeta; _syncData(syncContext: SyncContext, source: IVectorSource, style: IVectorStyle): Promise<void>; + ownsMbSourceId(sourceId: string): boolean; + ownsMbLayerId(sourceId: string): boolean; + _setMbPointsProperties(mbMap: unknown, mvtSourceLayer?: string): void; + _setMbLinePolygonProperties(mbMap: unknown, mvtSourceLayer?: string): void; + getSource(): IVectorSource; } diff --git a/x-pack/plugins/maps/public/layers/vector_layer.js b/x-pack/plugins/maps/public/layers/vector_layer.js index d606420909281..c5947a63587ea 100644 --- a/x-pack/plugins/maps/public/layers/vector_layer.js +++ b/x-pack/plugins/maps/public/layers/vector_layer.js @@ -641,7 +641,7 @@ export class VectorLayer extends AbstractLayer { } } - _setMbPointsProperties(mbMap) { + _setMbPointsProperties(mbMap, mvtSourceLayer) { const pointLayerId = this._getMbPointLayerId(); const symbolLayerId = this._getMbSymbolLayerId(); const pointLayer = mbMap.getLayer(pointLayerId); @@ -658,7 +658,7 @@ export class VectorLayer extends AbstractLayer { if (symbolLayer) { mbMap.setLayoutProperty(symbolLayerId, 'visibility', 'none'); } - this._setMbCircleProperties(mbMap); + this._setMbCircleProperties(mbMap, mvtSourceLayer); } else { markerLayerId = symbolLayerId; textLayerId = symbolLayerId; @@ -666,7 +666,7 @@ export class VectorLayer extends AbstractLayer { mbMap.setLayoutProperty(pointLayerId, 'visibility', 'none'); mbMap.setLayoutProperty(this._getMbTextLayerId(), 'visibility', 'none'); } - this._setMbSymbolProperties(mbMap); + this._setMbSymbolProperties(mbMap, mvtSourceLayer); } this.syncVisibilityWithMb(mbMap, markerLayerId); @@ -677,27 +677,36 @@ export class VectorLayer extends AbstractLayer { } } - _setMbCircleProperties(mbMap) { + _setMbCircleProperties(mbMap, mvtSourceLayer) { const sourceId = this.getId(); const pointLayerId = this._getMbPointLayerId(); const pointLayer = mbMap.getLayer(pointLayerId); if (!pointLayer) { - mbMap.addLayer({ + const mbLayer = { id: pointLayerId, type: 'circle', source: sourceId, paint: {}, - }); + }; + + if (mvtSourceLayer) { + mbLayer['source-layer'] = mvtSourceLayer; + } + mbMap.addLayer(mbLayer); } const textLayerId = this._getMbTextLayerId(); const textLayer = mbMap.getLayer(textLayerId); if (!textLayer) { - mbMap.addLayer({ + const mbLayer = { id: textLayerId, type: 'symbol', source: sourceId, - }); + }; + if (mvtSourceLayer) { + mbLayer['source-layer'] = mvtSourceLayer; + } + mbMap.addLayer(mbLayer); } const filterExpr = getPointFilterExpression(this._hasJoins()); @@ -719,17 +728,21 @@ export class VectorLayer extends AbstractLayer { }); } - _setMbSymbolProperties(mbMap) { + _setMbSymbolProperties(mbMap, mvtSourceLayer) { const sourceId = this.getId(); const symbolLayerId = this._getMbSymbolLayerId(); const symbolLayer = mbMap.getLayer(symbolLayerId); if (!symbolLayer) { - mbMap.addLayer({ + const mbLayer = { id: symbolLayerId, type: 'symbol', source: sourceId, - }); + }; + if (mvtSourceLayer) { + mbLayer['source-layer'] = mvtSourceLayer; + } + mbMap.addLayer(mbLayer); } const filterExpr = getPointFilterExpression(this._hasJoins()); @@ -750,26 +763,34 @@ export class VectorLayer extends AbstractLayer { }); } - _setMbLinePolygonProperties(mbMap) { + _setMbLinePolygonProperties(mbMap, mvtSourceLayer) { const sourceId = this.getId(); const fillLayerId = this._getMbPolygonLayerId(); const lineLayerId = this._getMbLineLayerId(); const hasJoins = this._hasJoins(); if (!mbMap.getLayer(fillLayerId)) { - mbMap.addLayer({ + const mbLayer = { id: fillLayerId, type: 'fill', source: sourceId, paint: {}, - }); + }; + if (mvtSourceLayer) { + mbLayer['source-layer'] = mvtSourceLayer; + } + mbMap.addLayer(mbLayer); } if (!mbMap.getLayer(lineLayerId)) { - mbMap.addLayer({ + const mbLayer = { id: lineLayerId, type: 'line', source: sourceId, paint: {}, - }); + }; + if (mvtSourceLayer) { + mbLayer['source-layer'] = mvtSourceLayer; + } + mbMap.addLayer(mbLayer); } this.getCurrentStyle().setMBPaintProperties({ alpha: this.getAlpha(), diff --git a/x-pack/plugins/maps/public/layers/vector_tile_layer.js b/x-pack/plugins/maps/public/layers/vector_tile_layer.js index 44987fd3e78f0..c620ec6c56dc3 100644 --- a/x-pack/plugins/maps/public/layers/vector_tile_layer.js +++ b/x-pack/plugins/maps/public/layers/vector_tile_layer.js @@ -161,19 +161,7 @@ export class VectorTileLayer extends TileLayer { return; } - if (this._requiresPrevSourceCleanup(mbMap)) { - const mbStyle = mbMap.getStyle(); - mbStyle.layers.forEach(mbLayer => { - if (this.ownsMbLayerId(mbLayer.id)) { - mbMap.removeLayer(mbLayer.id); - } - }); - Object.keys(mbStyle.sources).some(mbSourceId => { - if (this.ownsMbSourceId(mbSourceId)) { - mbMap.removeSource(mbSourceId); - } - }); - } + this._removeStaleMbSourcesAndLayers(mbMap); let initialBootstrapCompleted = false; const sourceIds = Object.keys(vectorStyle.sources); diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 9437c2512ded4..d3b9626dc8366 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -10,6 +10,12 @@ import { Setup as InspectorSetupContract } from 'src/plugins/inspector/public'; import { MapView } from './inspector/views/map_view'; import { setAutocompleteService, + setCore, + setCoreChrome, + setCoreI18n, + setCoreOverlays, + setData, + setDocLinks, setFileUpload, setHttp, setIndexPatternSelect, @@ -17,11 +23,17 @@ import { setInjectedVarFunc, setInspector, setLicenseId, + setMapsCapabilities, + setNavigation, + setSavedObjectsClient, setTimeFilter, setToasts, + setUiActions, setUiSettings, + setVisualizations, // @ts-ignore } from './kibana_services'; +import { registerLayerWizards } from './layers/load_layer_wizards'; export interface MapsPluginSetupDependencies { inspector: InspectorSetupContract; @@ -31,25 +43,37 @@ export interface MapsPluginStartDependencies {} export const bindSetupCoreAndPlugins = (core: CoreSetup, plugins: any) => { const { licensing } = plugins; - const { injectedMetadata, http } = core; + const { injectedMetadata, uiSettings, http, notifications } = core; if (licensing) { licensing.license$.subscribe(({ uid }: { uid: string }) => setLicenseId(uid)); } setInjectedVarFunc(injectedMetadata.getInjectedVar); setHttp(http); - setUiSettings(core.uiSettings); - setInjectedVarFunc(core.injectedMetadata.getInjectedVar); - setToasts(core.notifications.toasts); + setToasts(notifications.toasts); + setInjectedVarFunc(injectedMetadata.getInjectedVar); + setVisualizations(plugins.visualizations); + setUiSettings(uiSettings); }; export const bindStartCoreAndPlugins = (core: CoreStart, plugins: any) => { - const { file_upload, data, inspector } = plugins; + const { fileUpload, data, inspector } = plugins; setInspector(inspector); - setFileUpload(file_upload); + setFileUpload(fileUpload); setIndexPatternSelect(data.ui.IndexPatternSelect); setTimeFilter(data.query.timefilter.timefilter); setIndexPatternService(data.indexPatterns); setAutocompleteService(data.autocomplete); + setCore(core); + setSavedObjectsClient(core.savedObjects.client); + setCoreChrome(core.chrome); + setCoreOverlays(core.overlays); + setMapsCapabilities(core.application.capabilities.maps); + setDocLinks(core.docLinks); + setData(plugins.data); + setUiActions(plugins.uiActions); + setNavigation(plugins.navigation); + setCoreI18n(core.i18n); + registerLayerWizards(); }; /** diff --git a/x-pack/plugins/maps/public/reducers/map.js b/x-pack/plugins/maps/public/reducers/map.js index 1e20df89c8fad..251a2304538ed 100644 --- a/x-pack/plugins/maps/public/reducers/map.js +++ b/x-pack/plugins/maps/public/reducers/map.js @@ -57,8 +57,13 @@ const updateLayerInList = (state, layerId, attribute, newValue) => { if (!layerId) { return state; } + const { layerList } = state; const layerIdx = getLayerIndex(layerList, layerId); + if (layerIdx === -1) { + return state; + } + const updatedLayer = { ...layerList[layerIdx], // Update layer w/ new value. If no value provided, toggle boolean value @@ -74,7 +79,7 @@ const updateLayerInList = (state, layerId, attribute, newValue) => { return { ...state, layerList: updatedList }; }; -const updateLayerSourceDescriptorProp = (state, layerId, propName, value, newLayerType) => { +const updateLayerSourceDescriptorProp = (state, layerId, propName, value) => { const { layerList } = state; const layerIdx = getLayerIndex(layerList, layerId); const updatedLayer = { @@ -84,9 +89,6 @@ const updateLayerSourceDescriptorProp = (state, layerId, propName, value, newLay [propName]: value, }, }; - if (newLayerType) { - updatedLayer.type = newLayerType; - } const updatedList = [ ...layerList.slice(0, layerIdx), updatedLayer, @@ -261,13 +263,7 @@ export function map(state = INITIAL_STATE, action) { case UPDATE_LAYER_PROP: return updateLayerInList(state, action.id, action.propName, action.newValue); case UPDATE_SOURCE_PROP: - return updateLayerSourceDescriptorProp( - state, - action.layerId, - action.propName, - action.value, - action.newLayerType - ); + return updateLayerSourceDescriptorProp(state, action.layerId, action.propName, action.value); case SET_JOINS: const layerDescriptor = state.layerList.find( descriptor => descriptor.id === action.layer.getId() diff --git a/x-pack/plugins/ml/common/constants/file_datavisualizer.ts b/x-pack/plugins/ml/common/constants/file_datavisualizer.ts index 81d51bfa25816..7e18b36fd7bab 100644 --- a/x-pack/plugins/ml/common/constants/file_datavisualizer.ts +++ b/x-pack/plugins/ml/common/constants/file_datavisualizer.ts @@ -4,8 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -export const MAX_BYTES = 104857600; -export const ABSOLUTE_MAX_BYTES = MAX_BYTES * 5; +export const MAX_FILE_SIZE = '100MB'; +export const MAX_FILE_SIZE_BYTES = 104857600; // 100MB + +export const ABSOLUTE_MAX_FILE_SIZE_BYTES = 1073741274; // 1GB export const FILE_SIZE_DISPLAY_FORMAT = '0,0.[0] b'; // Value to use in the Elasticsearch index mapping meta data to identify the diff --git a/x-pack/plugins/ml/common/types/file_datavisualizer.ts b/x-pack/plugins/ml/common/types/file_datavisualizer.ts index f771547b97811..c997a4e24f868 100644 --- a/x-pack/plugins/ml/common/types/file_datavisualizer.ts +++ b/x-pack/plugins/ml/common/types/file_datavisualizer.ts @@ -67,13 +67,15 @@ export interface ImportResponse { export interface ImportFailure { item: number; reason: string; - doc: Doc; + doc: ImportDoc; } export interface Doc { message: string; } +export type ImportDoc = Doc | string; + export interface Settings { pipeline?: string; index: string; diff --git a/x-pack/plugins/ml/common/types/ml_config.ts b/x-pack/plugins/ml/common/types/ml_config.ts index 8fd9fd22bad8a..f2ddadccb2170 100644 --- a/x-pack/plugins/ml/common/types/ml_config.ts +++ b/x-pack/plugins/ml/common/types/ml_config.ts @@ -5,11 +5,11 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; -import { MAX_BYTES } from '../constants/file_datavisualizer'; +import { MAX_FILE_SIZE } from '../constants/file_datavisualizer'; export const configSchema = schema.object({ file_data_visualizer: schema.object({ - max_file_size_bytes: schema.number({ defaultValue: MAX_BYTES }), + max_file_size: schema.string({ defaultValue: MAX_FILE_SIZE }), }), }); diff --git a/x-pack/plugins/ml/common/util/__tests__/anomaly_utils.js b/x-pack/plugins/ml/common/util/__tests__/anomaly_utils.js deleted file mode 100644 index 515304d222c8c..0000000000000 --- a/x-pack/plugins/ml/common/util/__tests__/anomaly_utils.js +++ /dev/null @@ -1,443 +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 expect from '@kbn/expect'; -import { - getSeverity, - getSeverityWithLow, - getSeverityColor, - getMultiBucketImpactLabel, - getEntityFieldName, - getEntityFieldValue, - getEntityFieldList, - showActualForFunction, - showTypicalForFunction, - isRuleSupported, - aggregationTypeTransform, -} from '../anomaly_utils'; - -describe('ML - anomaly utils', () => { - const partitionEntityRecord = { - job_id: 'farequote', - result_type: 'record', - probability: 0.012818, - record_score: 0.0162059, - bucket_span: 300, - detector_index: 0, - timestamp: 1455047400000, - partition_field_name: 'airline', - partition_field_value: 'AAL', - function: 'mean', - function_description: 'mean', - field_name: 'responsetime', - }; - - const byEntityRecord = { - job_id: 'farequote', - result_type: 'record', - probability: 0.012818, - record_score: 0.0162059, - bucket_span: 300, - detector_index: 0, - timestamp: 1455047400000, - by_field_name: 'airline', - by_field_value: 'JZA', - function: 'mean', - function_description: 'mean', - field_name: 'responsetime', - }; - - const overEntityRecord = { - job_id: 'gallery', - result_type: 'record', - probability: 2.81806e-9, - record_score: 59.055, - bucket_span: 3600, - detector_index: 4, - timestamp: 1420552800000, - function: 'sum', - function_description: 'sum', - field_name: 'bytes', - by_field_name: 'method', - over_field_name: 'clientip', - over_field_value: '37.157.32.164', - }; - - const noEntityRecord = { - job_id: 'farequote_no_by', - result_type: 'record', - probability: 0.0191711, - record_score: 4.38431, - initial_record_score: 19.654, - bucket_span: 300, - detector_index: 0, - timestamp: 1454890500000, - function: 'mean', - function_description: 'mean', - field_name: 'responsetime', - }; - - const metricNoEntityRecord = { - job_id: 'farequote_metric', - result_type: 'record', - probability: 0.030133495093182184, - record_score: 0.024881740359975164, - initial_record_score: 0.024881740359975164, - bucket_span: 900, - detector_index: 0, - is_interim: false, - timestamp: 1486845000000, - function: 'metric', - function_description: 'mean', - typical: [545.7764658569108], - actual: [758.8220213274412], - field_name: 'responsetime', - influencers: [ - { - influencer_field_name: 'airline', - influencer_field_values: ['NKS'], - }, - ], - airline: ['NKS'], - }; - - const rareEntityRecord = { - job_id: 'gallery', - result_type: 'record', - probability: 0.02277014211908481, - record_score: 4.545378107075983, - initial_record_score: 4.545378107075983, - bucket_span: 3600, - detector_index: 0, - is_interim: false, - timestamp: 1495879200000, - by_field_name: 'status', - function: 'rare', - function_description: 'rare', - over_field_name: 'clientip', - over_field_value: '173.252.74.112', - causes: [ - { - probability: 0.02277014211908481, - by_field_name: 'status', - by_field_value: '206', - function: 'rare', - function_description: 'rare', - typical: [0.00014832458182211878], - actual: [1], - over_field_name: 'clientip', - over_field_value: '173.252.74.112', - }, - ], - influencers: [ - { - influencer_field_name: 'uri', - influencer_field_values: [ - '/wp-content/uploads/2013/06/dune_house_oil_on_canvas_24x20-298x298.jpg', - '/wp-content/uploads/2013/10/Case-dAste-1-11-298x298.png', - ], - }, - { - influencer_field_name: 'status', - influencer_field_values: ['206'], - }, - { - influencer_field_name: 'clientip', - influencer_field_values: ['173.252.74.112'], - }, - ], - clientip: ['173.252.74.112'], - uri: [ - '/wp-content/uploads/2013/06/dune_house_oil_on_canvas_24x20-298x298.jpg', - '/wp-content/uploads/2013/10/Case-dAste-1-11-298x298.png', - ], - status: ['206'], - }; - - describe('getSeverity', () => { - it('returns warning for 0 <= score < 25', () => { - expect(getSeverity(0).id).to.be('warning'); - expect(getSeverity(0.001).id).to.be('warning'); - expect(getSeverity(24.99).id).to.be('warning'); - }); - - it('returns minor for 25 <= score < 50', () => { - expect(getSeverity(25).id).to.be('minor'); - expect(getSeverity(49.99).id).to.be('minor'); - }); - - it('returns minor for 50 <= score < 75', () => { - expect(getSeverity(50).id).to.be('major'); - expect(getSeverity(74.99).id).to.be('major'); - }); - - it('returns critical for score >= 75', () => { - expect(getSeverity(75).id).to.be('critical'); - expect(getSeverity(100).id).to.be('critical'); - expect(getSeverity(1000).id).to.be('critical'); - }); - - it('returns unknown for scores less than 0 or string input', () => { - expect(getSeverity(-10).id).to.be('unknown'); - expect(getSeverity('value').id).to.be('unknown'); - }); - }); - - describe('getSeverityWithLow', () => { - it('returns low for 0 <= score < 3', () => { - expect(getSeverityWithLow(0).id).to.be('low'); - expect(getSeverityWithLow(0.001).id).to.be('low'); - expect(getSeverityWithLow(2.99).id).to.be('low'); - }); - - it('returns warning for 3 <= score < 25', () => { - expect(getSeverityWithLow(3).id).to.be('warning'); - expect(getSeverityWithLow(24.99).id).to.be('warning'); - }); - - it('returns minor for 25 <= score < 50', () => { - expect(getSeverityWithLow(25).id).to.be('minor'); - expect(getSeverityWithLow(49.99).id).to.be('minor'); - }); - - it('returns minor for 50 <= score < 75', () => { - expect(getSeverityWithLow(50).id).to.be('major'); - expect(getSeverityWithLow(74.99).id).to.be('major'); - }); - - it('returns critical for score >= 75', () => { - expect(getSeverityWithLow(75).id).to.be('critical'); - expect(getSeverityWithLow(100).id).to.be('critical'); - expect(getSeverityWithLow(1000).id).to.be('critical'); - }); - - it('returns unknown for scores less than 0 or string input', () => { - expect(getSeverityWithLow(-10).id).to.be('unknown'); - expect(getSeverityWithLow('value').id).to.be('unknown'); - }); - }); - - describe('getSeverityColor', () => { - it('returns correct hex code for low for 0 <= score < 3', () => { - expect(getSeverityColor(0)).to.be('#d2e9f7'); - expect(getSeverityColor(0.001)).to.be('#d2e9f7'); - expect(getSeverityColor(2.99)).to.be('#d2e9f7'); - }); - - it('returns correct hex code for warning for 3 <= score < 25', () => { - expect(getSeverityColor(3)).to.be('#8bc8fb'); - expect(getSeverityColor(24.99)).to.be('#8bc8fb'); - }); - - it('returns correct hex code for minor for 25 <= score < 50', () => { - expect(getSeverityColor(25)).to.be('#fdec25'); - expect(getSeverityColor(49.99)).to.be('#fdec25'); - }); - - it('returns correct hex code for major for 50 <= score < 75', () => { - expect(getSeverityColor(50)).to.be('#fba740'); - expect(getSeverityColor(74.99)).to.be('#fba740'); - }); - - it('returns correct hex code for critical for score >= 75', () => { - expect(getSeverityColor(75)).to.be('#fe5050'); - expect(getSeverityColor(100)).to.be('#fe5050'); - expect(getSeverityColor(1000)).to.be('#fe5050'); - }); - - it('returns correct hex code for unknown for scores less than 0 or string input', () => { - expect(getSeverityColor(-10)).to.be('#ffffff'); - expect(getSeverityColor('value')).to.be('#ffffff'); - }); - }); - - describe('getMultiBucketImpactLabel', () => { - it('returns high for 3 <= score <= 5', () => { - expect(getMultiBucketImpactLabel(3)).to.be('high'); - expect(getMultiBucketImpactLabel(5)).to.be('high'); - }); - - it('returns medium for 2 <= score < 3', () => { - expect(getMultiBucketImpactLabel(2)).to.be('medium'); - expect(getMultiBucketImpactLabel(2.99)).to.be('medium'); - }); - - it('returns low for 1 <= score < 2', () => { - expect(getMultiBucketImpactLabel(1)).to.be('low'); - expect(getMultiBucketImpactLabel(1.99)).to.be('low'); - }); - - it('returns none for -5 <= score < 1', () => { - expect(getMultiBucketImpactLabel(-5)).to.be('none'); - expect(getMultiBucketImpactLabel(0.99)).to.be('none'); - }); - - it('returns expected label when impact outside normal bounds', () => { - expect(getMultiBucketImpactLabel(10)).to.be('high'); - expect(getMultiBucketImpactLabel(-10)).to.be('none'); - }); - }); - - describe('getEntityFieldName', () => { - it('returns the by field name', () => { - expect(getEntityFieldName(byEntityRecord)).to.be('airline'); - }); - - it('returns the partition field name', () => { - expect(getEntityFieldName(partitionEntityRecord)).to.be('airline'); - }); - - it('returns the over field name', () => { - expect(getEntityFieldName(overEntityRecord)).to.be('clientip'); - }); - - it('returns undefined if no by, over or partition fields', () => { - expect(getEntityFieldName(noEntityRecord)).to.be(undefined); - }); - }); - - describe('getEntityFieldValue', () => { - it('returns the by field value', () => { - expect(getEntityFieldValue(byEntityRecord)).to.be('JZA'); - }); - - it('returns the partition field value', () => { - expect(getEntityFieldValue(partitionEntityRecord)).to.be('AAL'); - }); - - it('returns the over field value', () => { - expect(getEntityFieldValue(overEntityRecord)).to.be('37.157.32.164'); - }); - - it('returns undefined if no by, over or partition fields', () => { - expect(getEntityFieldValue(noEntityRecord)).to.be(undefined); - }); - }); - - describe('getEntityFieldList', () => { - it('returns an empty list for a record with no by, over or partition fields', () => { - expect(getEntityFieldList(noEntityRecord)).to.be.empty(); - }); - - it('returns correct list for a record with a by field', () => { - expect(getEntityFieldList(byEntityRecord)).to.eql([ - { - fieldName: 'airline', - fieldValue: 'JZA', - fieldType: 'by', - }, - ]); - }); - - it('returns correct list for a record with a partition field', () => { - expect(getEntityFieldList(partitionEntityRecord)).to.eql([ - { - fieldName: 'airline', - fieldValue: 'AAL', - fieldType: 'partition', - }, - ]); - }); - - it('returns correct list for a record with an over field', () => { - expect(getEntityFieldList(overEntityRecord)).to.eql([ - { - fieldName: 'clientip', - fieldValue: '37.157.32.164', - fieldType: 'over', - }, - ]); - }); - - it('returns correct list for a record with a by and over field', () => { - expect(getEntityFieldList(rareEntityRecord)).to.eql([ - { - fieldName: 'clientip', - fieldValue: '173.252.74.112', - fieldType: 'over', - }, - ]); - }); - }); - - describe('showActualForFunction', () => { - it('returns true for expected function descriptions', () => { - expect(showActualForFunction('count')).to.be(true); - expect(showActualForFunction('distinct_count')).to.be(true); - expect(showActualForFunction('lat_long')).to.be(true); - expect(showActualForFunction('mean')).to.be(true); - expect(showActualForFunction('max')).to.be(true); - expect(showActualForFunction('min')).to.be(true); - expect(showActualForFunction('sum')).to.be(true); - expect(showActualForFunction('median')).to.be(true); - expect(showActualForFunction('varp')).to.be(true); - expect(showActualForFunction('info_content')).to.be(true); - expect(showActualForFunction('time')).to.be(true); - }); - - it('returns false for expected function descriptions', () => { - expect(showActualForFunction('rare')).to.be(false); - }); - }); - - describe('showTypicalForFunction', () => { - it('returns true for expected function descriptions', () => { - expect(showTypicalForFunction('count')).to.be(true); - expect(showTypicalForFunction('distinct_count')).to.be(true); - expect(showTypicalForFunction('lat_long')).to.be(true); - expect(showTypicalForFunction('mean')).to.be(true); - expect(showTypicalForFunction('max')).to.be(true); - expect(showTypicalForFunction('min')).to.be(true); - expect(showTypicalForFunction('sum')).to.be(true); - expect(showTypicalForFunction('median')).to.be(true); - expect(showTypicalForFunction('varp')).to.be(true); - expect(showTypicalForFunction('info_content')).to.be(true); - expect(showTypicalForFunction('time')).to.be(true); - }); - - it('returns false for expected function descriptions', () => { - expect(showTypicalForFunction('rare')).to.be(false); - }); - }); - - describe('isRuleSupported', () => { - it('returns true for anomalies supporting rules', () => { - expect(isRuleSupported(partitionEntityRecord)).to.be(true); - expect(isRuleSupported(byEntityRecord)).to.be(true); - expect(isRuleSupported(overEntityRecord)).to.be(true); - expect(isRuleSupported(rareEntityRecord)).to.be(true); - expect(isRuleSupported(noEntityRecord)).to.be(true); - }); - - it('returns false for anomaly not supporting rules', () => { - expect(isRuleSupported(metricNoEntityRecord)).to.be(false); - }); - }); - - describe('aggregationTypeTransform', () => { - it('returns correct ES aggregation type for ML function description', () => { - expect(aggregationTypeTransform.toES('count')).to.be('count'); - expect(aggregationTypeTransform.toES('distinct_count')).to.be('cardinality'); - expect(aggregationTypeTransform.toES('distinct_count')).to.not.be('distinct_count'); - expect(aggregationTypeTransform.toES('mean')).to.be('avg'); - expect(aggregationTypeTransform.toES('mean')).to.not.be('mean'); - expect(aggregationTypeTransform.toES('max')).to.be('max'); - expect(aggregationTypeTransform.toES('min')).to.be('min'); - expect(aggregationTypeTransform.toES('sum')).to.be('sum'); - }); - - it('returns correct ML function description for ES aggregation type', () => { - expect(aggregationTypeTransform.toML('count')).to.be('count'); - expect(aggregationTypeTransform.toML('cardinality')).to.be('distinct_count'); - expect(aggregationTypeTransform.toML('cardinality')).to.not.be('cardinality'); - expect(aggregationTypeTransform.toML('avg')).to.be('mean'); - expect(aggregationTypeTransform.toML('avg')).to.not.be('avg'); - expect(aggregationTypeTransform.toML('max')).to.be('max'); - expect(aggregationTypeTransform.toML('min')).to.be('min'); - expect(aggregationTypeTransform.toML('sum')).to.be('sum'); - }); - }); -}); diff --git a/x-pack/plugins/ml/common/util/anomaly_utils.d.ts b/x-pack/plugins/ml/common/util/anomaly_utils.d.ts deleted file mode 100644 index adeb6dc7dd5b9..0000000000000 --- a/x-pack/plugins/ml/common/util/anomaly_utils.d.ts +++ /dev/null @@ -1,11 +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 { ANOMALY_SEVERITY } from '../constants/anomalies'; - -export function getSeverity(normalizedScore: number): string; -export function getSeverityType(normalizedScore: number): ANOMALY_SEVERITY; -export function getSeverityColor(normalizedScore: number): string; diff --git a/x-pack/plugins/ml/common/util/anomaly_utils.js b/x-pack/plugins/ml/common/util/anomaly_utils.js deleted file mode 100644 index 16c27b6af869d..0000000000000 --- a/x-pack/plugins/ml/common/util/anomaly_utils.js +++ /dev/null @@ -1,332 +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. - */ - -/* - * Contains functions for operations commonly performed on anomaly data - * to extract information for display in dashboards. - */ - -import { i18n } from '@kbn/i18n'; -import { CONDITIONS_NOT_SUPPORTED_FUNCTIONS } from '../constants/detector_rule'; -import { MULTI_BUCKET_IMPACT } from '../constants/multi_bucket_impact'; -import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from '../constants/anomalies'; - -// List of function descriptions for which actual values from record level results should be displayed. -const DISPLAY_ACTUAL_FUNCTIONS = [ - 'count', - 'distinct_count', - 'lat_long', - 'mean', - 'max', - 'min', - 'sum', - 'median', - 'varp', - 'info_content', - 'time', -]; - -// List of function descriptions for which typical values from record level results should be displayed. -const DISPLAY_TYPICAL_FUNCTIONS = [ - 'count', - 'distinct_count', - 'lat_long', - 'mean', - 'max', - 'min', - 'sum', - 'median', - 'varp', - 'info_content', - 'time', -]; - -let severityTypes; - -function getSeverityTypes() { - if (severityTypes) { - return severityTypes; - } - - return (severityTypes = { - critical: { - id: ANOMALY_SEVERITY.CRITICAL, - label: i18n.translate('xpack.ml.anomalyUtils.severity.criticalLabel', { - defaultMessage: 'critical', - }), - }, - major: { - id: ANOMALY_SEVERITY.MAJOR, - label: i18n.translate('xpack.ml.anomalyUtils.severity.majorLabel', { - defaultMessage: 'major', - }), - }, - minor: { - id: ANOMALY_SEVERITY.MINOR, - label: i18n.translate('xpack.ml.anomalyUtils.severity.minorLabel', { - defaultMessage: 'minor', - }), - }, - warning: { - id: ANOMALY_SEVERITY.WARNING, - label: i18n.translate('xpack.ml.anomalyUtils.severity.warningLabel', { - defaultMessage: 'warning', - }), - }, - unknown: { - id: ANOMALY_SEVERITY.UNKNOWN, - label: i18n.translate('xpack.ml.anomalyUtils.severity.unknownLabel', { - defaultMessage: 'unknown', - }), - }, - low: { - id: ANOMALY_SEVERITY.LOW, - label: i18n.translate('xpack.ml.anomalyUtils.severityWithLow.lowLabel', { - defaultMessage: 'low', - }), - }, - }); -} - -// Returns a severity label (one of critical, major, minor, warning or unknown) -// for the supplied normalized anomaly score (a value between 0 and 100). -export function getSeverity(normalizedScore) { - const severityTypesList = getSeverityTypes(); - - if (normalizedScore >= ANOMALY_THRESHOLD.CRITICAL) { - return severityTypesList.critical; - } else if (normalizedScore >= ANOMALY_THRESHOLD.MAJOR) { - return severityTypesList.major; - } else if (normalizedScore >= ANOMALY_THRESHOLD.MINOR) { - return severityTypesList.minor; - } else if (normalizedScore >= ANOMALY_THRESHOLD.LOW) { - return severityTypesList.warning; - } else { - return severityTypesList.unknown; - } -} - -export function getSeverityType(normalizedScore) { - if (normalizedScore >= 75) { - return ANOMALY_SEVERITY.CRITICAL; - } else if (normalizedScore >= 50) { - return ANOMALY_SEVERITY.MAJOR; - } else if (normalizedScore >= 25) { - return ANOMALY_SEVERITY.MINOR; - } else if (normalizedScore >= 3) { - return ANOMALY_SEVERITY.WARNING; - } else if (normalizedScore >= 0) { - return ANOMALY_SEVERITY.LOW; - } else { - return ANOMALY_SEVERITY.UNKNOWN; - } -} - -// Returns a severity label (one of critical, major, minor, warning, low or unknown) -// for the supplied normalized anomaly score (a value between 0 and 100), where scores -// less than 3 are assigned a severity of 'low'. -export function getSeverityWithLow(normalizedScore) { - const severityTypesList = getSeverityTypes(); - - if (normalizedScore >= ANOMALY_THRESHOLD.CRITICAL) { - return severityTypesList.critical; - } else if (normalizedScore >= ANOMALY_THRESHOLD.MAJOR) { - return severityTypesList.major; - } else if (normalizedScore >= ANOMALY_THRESHOLD.MINOR) { - return severityTypesList.minor; - } else if (normalizedScore >= ANOMALY_THRESHOLD.WARNING) { - return severityTypesList.warning; - } else if (normalizedScore >= ANOMALY_THRESHOLD.LOW) { - return severityTypesList.low; - } else { - return severityTypesList.unknown; - } -} - -// Returns a severity RGB color (one of critical, major, minor, warning, low_warning or unknown) -// for the supplied normalized anomaly score (a value between 0 and 100). -export function getSeverityColor(normalizedScore) { - if (normalizedScore >= ANOMALY_THRESHOLD.CRITICAL) { - return '#fe5050'; - } else if (normalizedScore >= ANOMALY_THRESHOLD.MAJOR) { - return '#fba740'; - } else if (normalizedScore >= ANOMALY_THRESHOLD.MINOR) { - return '#fdec25'; - } else if (normalizedScore >= ANOMALY_THRESHOLD.WARNING) { - return '#8bc8fb'; - } else if (normalizedScore >= ANOMALY_THRESHOLD.LOW) { - return '#d2e9f7'; - } else { - return '#ffffff'; - } -} - -// Returns a label to use for the multi-bucket impact of an anomaly -// according to the value of the multi_bucket_impact field of a record, -// which ranges from -5 to +5. -export function getMultiBucketImpactLabel(multiBucketImpact) { - if (multiBucketImpact >= MULTI_BUCKET_IMPACT.HIGH) { - return i18n.translate('xpack.ml.anomalyUtils.multiBucketImpact.highLabel', { - defaultMessage: 'high', - }); - } else if (multiBucketImpact >= MULTI_BUCKET_IMPACT.MEDIUM) { - return i18n.translate('xpack.ml.anomalyUtils.multiBucketImpact.mediumLabel', { - defaultMessage: 'medium', - }); - } else if (multiBucketImpact >= MULTI_BUCKET_IMPACT.LOW) { - return i18n.translate('xpack.ml.anomalyUtils.multiBucketImpact.lowLabel', { - defaultMessage: 'low', - }); - } else { - return i18n.translate('xpack.ml.anomalyUtils.multiBucketImpact.noneLabel', { - defaultMessage: 'none', - }); - } -} - -// Returns the name of the field to use as the entity name from the source record -// obtained from Elasticsearch. The function looks first for a by_field, then over_field, -// then partition_field, returning undefined if none of these fields are present. -export function getEntityFieldName(record) { - // Analyses with by and over fields, will have a top-level by_field_name, but - // the by_field_value(s) will be in the nested causes array. - if (record.by_field_name !== undefined && record.by_field_value !== undefined) { - return record.by_field_name; - } - - if (record.over_field_name !== undefined) { - return record.over_field_name; - } - - if (record.partition_field_name !== undefined) { - return record.partition_field_name; - } - - return undefined; -} - -// Returns the value of the field to use as the entity value from the source record -// obtained from Elasticsearch. The function looks first for a by_field, then over_field, -// then partition_field, returning undefined if none of these fields are present. -export function getEntityFieldValue(record) { - if (record.by_field_value !== undefined) { - return record.by_field_value; - } - - if (record.over_field_value !== undefined) { - return record.over_field_value; - } - - if (record.partition_field_value !== undefined) { - return record.partition_field_value; - } - - return undefined; -} - -// Returns the list of partitioning entity fields for the source record as a list -// of objects in the form { fieldName: airline, fieldValue: AAL, fieldType: partition } -export function getEntityFieldList(record) { - const entityFields = []; - if (record.partition_field_name !== undefined) { - entityFields.push({ - fieldName: record.partition_field_name, - fieldValue: record.partition_field_value, - fieldType: 'partition', - }); - } - - if (record.over_field_name !== undefined) { - entityFields.push({ - fieldName: record.over_field_name, - fieldValue: record.over_field_value, - fieldType: 'over', - }); - } - - // For jobs with by and over fields, don't add the 'by' field as this - // field will only be added to the top-level fields for record type results - // if it also an influencer over the bucket. - if (record.by_field_name !== undefined && record.over_field_name === undefined) { - entityFields.push({ - fieldName: record.by_field_name, - fieldValue: record.by_field_value, - fieldType: 'by', - }); - } - - return entityFields; -} - -// Returns whether actual values should be displayed for a record with the specified function description. -// Note that the 'function' field in a record contains what the user entered e.g. 'high_count', -// whereas the 'function_description' field holds a ML-built display hint for function e.g. 'count'. -export function showActualForFunction(functionDescription) { - return DISPLAY_ACTUAL_FUNCTIONS.indexOf(functionDescription) > -1; -} - -// Returns whether typical values should be displayed for a record with the specified function description. -// Note that the 'function' field in a record contains what the user entered e.g. 'high_count', -// whereas the 'function_description' field holds a ML-built display hint for function e.g. 'count'. -export function showTypicalForFunction(functionDescription) { - return DISPLAY_TYPICAL_FUNCTIONS.indexOf(functionDescription) > -1; -} - -// Returns whether a rule can be configured against the specified anomaly. -export function isRuleSupported(record) { - // A rule can be configured with a numeric condition if the function supports it, - // and/or with scope if there is a partitioning fields. - return ( - CONDITIONS_NOT_SUPPORTED_FUNCTIONS.indexOf(record.function) === -1 || - getEntityFieldName(record) !== undefined - ); -} - -// Two functions for converting aggregation type names. -// ML and ES use different names for the same function. -// Possible values for ML aggregation type are (defined in lib/model/CAnomalyDetector.cc): -// count -// distinct_count -// rare -// info_content -// mean -// median -// min -// max -// varp -// sum -// lat_long -// time -// The input to toES and the output from toML correspond to the value of the -// function_description field of anomaly records. -export const aggregationTypeTransform = { - toES: function(oldAggType) { - let newAggType = oldAggType; - - if (newAggType === 'mean') { - newAggType = 'avg'; - } else if (newAggType === 'distinct_count') { - newAggType = 'cardinality'; - } else if (newAggType === 'median') { - newAggType = 'percentiles'; - } - - return newAggType; - }, - toML: function(oldAggType) { - let newAggType = oldAggType; - - if (newAggType === 'avg') { - newAggType = 'mean'; - } else if (newAggType === 'cardinality') { - newAggType = 'distinct_count'; - } else if (newAggType === 'percentiles') { - newAggType = 'median'; - } - - return newAggType; - }, -}; diff --git a/x-pack/plugins/ml/common/util/anomaly_utils.test.ts b/x-pack/plugins/ml/common/util/anomaly_utils.test.ts new file mode 100644 index 0000000000000..1343e4611c215 --- /dev/null +++ b/x-pack/plugins/ml/common/util/anomaly_utils.test.ts @@ -0,0 +1,444 @@ +/* + * 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 { AnomalyRecordDoc } from '../types/anomalies'; + +import { + aggregationTypeTransform, + getEntityFieldList, + getEntityFieldName, + getEntityFieldValue, + getMultiBucketImpactLabel, + getSeverity, + getSeverityWithLow, + getSeverityColor, + isRuleSupported, + showActualForFunction, + showTypicalForFunction, +} from './anomaly_utils'; + +describe('ML - anomaly utils', () => { + const partitionEntityRecord: AnomalyRecordDoc = { + job_id: 'farequote', + result_type: 'record', + probability: 0.012818, + record_score: 0.0162059, + initial_record_score: 0.0162059, + bucket_span: 300, + detector_index: 0, + is_interim: false, + timestamp: 1455047400000, + partition_field_name: 'airline', + partition_field_value: 'AAL', + function: 'mean', + function_description: 'mean', + field_name: 'responsetime', + }; + + const byEntityRecord: AnomalyRecordDoc = { + job_id: 'farequote', + result_type: 'record', + probability: 0.012818, + record_score: 0.0162059, + initial_record_score: 0.0162059, + bucket_span: 300, + detector_index: 0, + is_interim: false, + timestamp: 1455047400000, + by_field_name: 'airline', + by_field_value: 'JZA', + function: 'mean', + function_description: 'mean', + field_name: 'responsetime', + }; + + const overEntityRecord: AnomalyRecordDoc = { + job_id: 'gallery', + result_type: 'record', + probability: 2.81806e-9, + record_score: 59.055, + initial_record_score: 59.055, + bucket_span: 3600, + detector_index: 4, + is_interim: false, + timestamp: 1420552800000, + function: 'sum', + function_description: 'sum', + field_name: 'bytes', + by_field_name: 'method', + over_field_name: 'clientip', + over_field_value: '37.157.32.164', + }; + + const noEntityRecord: AnomalyRecordDoc = { + job_id: 'farequote_no_by', + result_type: 'record', + probability: 0.0191711, + record_score: 4.38431, + initial_record_score: 19.654, + bucket_span: 300, + detector_index: 0, + is_interim: false, + timestamp: 1454890500000, + function: 'mean', + function_description: 'mean', + field_name: 'responsetime', + }; + + const metricNoEntityRecord: AnomalyRecordDoc = { + job_id: 'farequote_metric', + result_type: 'record', + probability: 0.030133495093182184, + record_score: 0.024881740359975164, + initial_record_score: 0.024881740359975164, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1486845000000, + function: 'metric', + function_description: 'mean', + typical: [545.7764658569108], + actual: [758.8220213274412], + field_name: 'responsetime', + influencers: [ + { + influencer_field_name: 'airline', + influencer_field_values: ['NKS'], + }, + ], + airline: ['NKS'], + }; + + const rareEntityRecord: AnomalyRecordDoc = { + job_id: 'gallery', + result_type: 'record', + probability: 0.02277014211908481, + record_score: 4.545378107075983, + initial_record_score: 4.545378107075983, + bucket_span: 3600, + detector_index: 0, + is_interim: false, + timestamp: 1495879200000, + by_field_name: 'status', + function: 'rare', + function_description: 'rare', + over_field_name: 'clientip', + over_field_value: '173.252.74.112', + causes: [ + { + probability: 0.02277014211908481, + by_field_name: 'status', + by_field_value: '206', + function: 'rare', + function_description: 'rare', + typical: [0.00014832458182211878], + actual: [1], + over_field_name: 'clientip', + over_field_value: '173.252.74.112', + }, + ], + influencers: [ + { + influencer_field_name: 'uri', + influencer_field_values: [ + '/wp-content/uploads/2013/06/dune_house_oil_on_canvas_24x20-298x298.jpg', + '/wp-content/uploads/2013/10/Case-dAste-1-11-298x298.png', + ], + }, + { + influencer_field_name: 'status', + influencer_field_values: ['206'], + }, + { + influencer_field_name: 'clientip', + influencer_field_values: ['173.252.74.112'], + }, + ], + clientip: ['173.252.74.112'], + uri: [ + '/wp-content/uploads/2013/06/dune_house_oil_on_canvas_24x20-298x298.jpg', + '/wp-content/uploads/2013/10/Case-dAste-1-11-298x298.png', + ], + status: ['206'], + }; + + describe('getSeverity', () => { + test('returns warning for 0 <= score < 25', () => { + expect(getSeverity(0).id).toBe('warning'); + expect(getSeverity(0.001).id).toBe('warning'); + expect(getSeverity(24.99).id).toBe('warning'); + }); + + test('returns minor for 25 <= score < 50', () => { + expect(getSeverity(25).id).toBe('minor'); + expect(getSeverity(49.99).id).toBe('minor'); + }); + + test('returns minor for 50 <= score < 75', () => { + expect(getSeverity(50).id).toBe('major'); + expect(getSeverity(74.99).id).toBe('major'); + }); + + test('returns critical for score >= 75', () => { + expect(getSeverity(75).id).toBe('critical'); + expect(getSeverity(100).id).toBe('critical'); + expect(getSeverity(1000).id).toBe('critical'); + }); + + test('returns unknown for scores less than 0', () => { + expect(getSeverity(-10).id).toBe('unknown'); + }); + }); + + describe('getSeverityWithLow', () => { + test('returns low for 0 <= score < 3', () => { + expect(getSeverityWithLow(0).id).toBe('low'); + expect(getSeverityWithLow(0.001).id).toBe('low'); + expect(getSeverityWithLow(2.99).id).toBe('low'); + }); + + test('returns warning for 3 <= score < 25', () => { + expect(getSeverityWithLow(3).id).toBe('warning'); + expect(getSeverityWithLow(24.99).id).toBe('warning'); + }); + + test('returns minor for 25 <= score < 50', () => { + expect(getSeverityWithLow(25).id).toBe('minor'); + expect(getSeverityWithLow(49.99).id).toBe('minor'); + }); + + test('returns minor for 50 <= score < 75', () => { + expect(getSeverityWithLow(50).id).toBe('major'); + expect(getSeverityWithLow(74.99).id).toBe('major'); + }); + + test('returns critical for score >= 75', () => { + expect(getSeverityWithLow(75).id).toBe('critical'); + expect(getSeverityWithLow(100).id).toBe('critical'); + expect(getSeverityWithLow(1000).id).toBe('critical'); + }); + + test('returns unknown for scores less than 0 ', () => { + expect(getSeverityWithLow(-10).id).toBe('unknown'); + }); + }); + + describe('getSeverityColor', () => { + test('returns correct hex code for low for 0 <= score < 3', () => { + expect(getSeverityColor(0)).toBe('#d2e9f7'); + expect(getSeverityColor(0.001)).toBe('#d2e9f7'); + expect(getSeverityColor(2.99)).toBe('#d2e9f7'); + }); + + test('returns correct hex code for warning for 3 <= score < 25', () => { + expect(getSeverityColor(3)).toBe('#8bc8fb'); + expect(getSeverityColor(24.99)).toBe('#8bc8fb'); + }); + + test('returns correct hex code for minor for 25 <= score < 50', () => { + expect(getSeverityColor(25)).toBe('#fdec25'); + expect(getSeverityColor(49.99)).toBe('#fdec25'); + }); + + test('returns correct hex code for major for 50 <= score < 75', () => { + expect(getSeverityColor(50)).toBe('#fba740'); + expect(getSeverityColor(74.99)).toBe('#fba740'); + }); + + test('returns correct hex code for critical for score >= 75', () => { + expect(getSeverityColor(75)).toBe('#fe5050'); + expect(getSeverityColor(100)).toBe('#fe5050'); + expect(getSeverityColor(1000)).toBe('#fe5050'); + }); + + test('returns correct hex code for unknown for scores less than 0', () => { + expect(getSeverityColor(-10)).toBe('#ffffff'); + }); + }); + + describe('getMultiBucketImpactLabel', () => { + test('returns high for 3 <= score <= 5', () => { + expect(getMultiBucketImpactLabel(3)).toBe('high'); + expect(getMultiBucketImpactLabel(5)).toBe('high'); + }); + + test('returns medium for 2 <= score < 3', () => { + expect(getMultiBucketImpactLabel(2)).toBe('medium'); + expect(getMultiBucketImpactLabel(2.99)).toBe('medium'); + }); + + test('returns low for 1 <= score < 2', () => { + expect(getMultiBucketImpactLabel(1)).toBe('low'); + expect(getMultiBucketImpactLabel(1.99)).toBe('low'); + }); + + test('returns none for -5 <= score < 1', () => { + expect(getMultiBucketImpactLabel(-5)).toBe('none'); + expect(getMultiBucketImpactLabel(0.99)).toBe('none'); + }); + + test('returns expected label when impact outside normal bounds', () => { + expect(getMultiBucketImpactLabel(10)).toBe('high'); + expect(getMultiBucketImpactLabel(-10)).toBe('none'); + }); + }); + + describe('getEntityFieldName', () => { + it('returns the by field name', () => { + expect(getEntityFieldName(byEntityRecord)).toBe('airline'); + }); + + it('returns the partition field name', () => { + expect(getEntityFieldName(partitionEntityRecord)).toBe('airline'); + }); + + it('returns the over field name', () => { + expect(getEntityFieldName(overEntityRecord)).toBe('clientip'); + }); + + it('returns undefined if no by, over or partition fields', () => { + expect(getEntityFieldName(noEntityRecord)).toBe(undefined); + }); + }); + + describe('getEntityFieldValue', () => { + test('returns the by field value', () => { + expect(getEntityFieldValue(byEntityRecord)).toBe('JZA'); + }); + + test('returns the partition field value', () => { + expect(getEntityFieldValue(partitionEntityRecord)).toBe('AAL'); + }); + + test('returns the over field value', () => { + expect(getEntityFieldValue(overEntityRecord)).toBe('37.157.32.164'); + }); + + test('returns undefined if no by, over or partition fields', () => { + expect(getEntityFieldValue(noEntityRecord)).toBe(undefined); + }); + }); + + describe('getEntityFieldList', () => { + test('returns an empty list for a record with no by, over or partition fields', () => { + expect(getEntityFieldList(noEntityRecord)).toHaveLength(0); + }); + + test('returns correct list for a record with a by field', () => { + expect(getEntityFieldList(byEntityRecord)).toEqual([ + { + fieldName: 'airline', + fieldValue: 'JZA', + fieldType: 'by', + }, + ]); + }); + + test('returns correct list for a record with a partition field', () => { + expect(getEntityFieldList(partitionEntityRecord)).toEqual([ + { + fieldName: 'airline', + fieldValue: 'AAL', + fieldType: 'partition', + }, + ]); + }); + + test('returns correct list for a record with an over field', () => { + expect(getEntityFieldList(overEntityRecord)).toEqual([ + { + fieldName: 'clientip', + fieldValue: '37.157.32.164', + fieldType: 'over', + }, + ]); + }); + + test('returns correct list for a record with a by and over field', () => { + expect(getEntityFieldList(rareEntityRecord)).toEqual([ + { + fieldName: 'clientip', + fieldValue: '173.252.74.112', + fieldType: 'over', + }, + ]); + }); + }); + + describe('showActualForFunction', () => { + test('returns true for expected function descriptions', () => { + expect(showActualForFunction('count')).toBe(true); + expect(showActualForFunction('distinct_count')).toBe(true); + expect(showActualForFunction('lat_long')).toBe(true); + expect(showActualForFunction('mean')).toBe(true); + expect(showActualForFunction('max')).toBe(true); + expect(showActualForFunction('min')).toBe(true); + expect(showActualForFunction('sum')).toBe(true); + expect(showActualForFunction('median')).toBe(true); + expect(showActualForFunction('varp')).toBe(true); + expect(showActualForFunction('info_content')).toBe(true); + expect(showActualForFunction('time')).toBe(true); + }); + + test('returns false for expected function descriptions', () => { + expect(showActualForFunction('rare')).toBe(false); + }); + }); + + describe('showTypicalForFunction', () => { + test('returns true for expected function descriptions', () => { + expect(showTypicalForFunction('count')).toBe(true); + expect(showTypicalForFunction('distinct_count')).toBe(true); + expect(showTypicalForFunction('lat_long')).toBe(true); + expect(showTypicalForFunction('mean')).toBe(true); + expect(showTypicalForFunction('max')).toBe(true); + expect(showTypicalForFunction('min')).toBe(true); + expect(showTypicalForFunction('sum')).toBe(true); + expect(showTypicalForFunction('median')).toBe(true); + expect(showTypicalForFunction('varp')).toBe(true); + expect(showTypicalForFunction('info_content')).toBe(true); + expect(showTypicalForFunction('time')).toBe(true); + }); + + test('returns false for expected function descriptions', () => { + expect(showTypicalForFunction('rare')).toBe(false); + }); + }); + + describe('isRuleSupported', () => { + test('returns true for anomalies supporting rules', () => { + expect(isRuleSupported(partitionEntityRecord)).toBe(true); + expect(isRuleSupported(byEntityRecord)).toBe(true); + expect(isRuleSupported(overEntityRecord)).toBe(true); + expect(isRuleSupported(rareEntityRecord)).toBe(true); + expect(isRuleSupported(noEntityRecord)).toBe(true); + }); + + it('returns false for anomaly not supporting rules', () => { + expect(isRuleSupported(metricNoEntityRecord)).toBe(false); + }); + }); + + describe('aggregationTypeTransform', () => { + test('returns correct ES aggregation type for ML function description', () => { + expect(aggregationTypeTransform.toES('count')).toBe('count'); + expect(aggregationTypeTransform.toES('distinct_count')).toBe('cardinality'); + expect(aggregationTypeTransform.toES('mean')).toBe('avg'); + expect(aggregationTypeTransform.toES('max')).toBe('max'); + expect(aggregationTypeTransform.toES('min')).toBe('min'); + expect(aggregationTypeTransform.toES('sum')).toBe('sum'); + }); + + test('returns correct ML function description for ES aggregation type', () => { + expect(aggregationTypeTransform.toML('count')).toBe('count'); + expect(aggregationTypeTransform.toML('cardinality')).toBe('distinct_count'); + expect(aggregationTypeTransform.toML('avg')).toBe('mean'); + expect(aggregationTypeTransform.toML('max')).toBe('max'); + expect(aggregationTypeTransform.toML('min')).toBe('min'); + expect(aggregationTypeTransform.toML('sum')).toBe('sum'); + }); + }); +}); diff --git a/x-pack/plugins/ml/common/util/anomaly_utils.ts b/x-pack/plugins/ml/common/util/anomaly_utils.ts new file mode 100644 index 0000000000000..36b91f5580b39 --- /dev/null +++ b/x-pack/plugins/ml/common/util/anomaly_utils.ts @@ -0,0 +1,350 @@ +/* + * 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. + */ + +/* + * Contains functions for operations commonly performed on anomaly data + * to extract information for display in dashboards. + */ + +import { i18n } from '@kbn/i18n'; +import { CONDITIONS_NOT_SUPPORTED_FUNCTIONS } from '../constants/detector_rule'; +import { MULTI_BUCKET_IMPACT } from '../constants/multi_bucket_impact'; +import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from '../constants/anomalies'; +import { AnomalyRecordDoc } from '../types/anomalies'; + +export interface SeverityType { + id: ANOMALY_SEVERITY; + label: string; +} + +export enum ENTITY_FIELD_TYPE { + BY = 'by', + OVER = 'over', + PARTITON = 'partition', +} + +export interface EntityField { + fieldName: string; + fieldValue: string | number | undefined; + fieldType: ENTITY_FIELD_TYPE; +} + +// List of function descriptions for which actual values from record level results should be displayed. +const DISPLAY_ACTUAL_FUNCTIONS = [ + 'count', + 'distinct_count', + 'lat_long', + 'mean', + 'max', + 'min', + 'sum', + 'median', + 'varp', + 'info_content', + 'time', +]; + +// List of function descriptions for which typical values from record level results should be displayed. +const DISPLAY_TYPICAL_FUNCTIONS = [ + 'count', + 'distinct_count', + 'lat_long', + 'mean', + 'max', + 'min', + 'sum', + 'median', + 'varp', + 'info_content', + 'time', +]; + +let severityTypes: Record<string, SeverityType>; + +function getSeverityTypes() { + if (severityTypes) { + return severityTypes; + } + + return (severityTypes = { + critical: { + id: ANOMALY_SEVERITY.CRITICAL, + label: i18n.translate('xpack.ml.anomalyUtils.severity.criticalLabel', { + defaultMessage: 'critical', + }), + }, + major: { + id: ANOMALY_SEVERITY.MAJOR, + label: i18n.translate('xpack.ml.anomalyUtils.severity.majorLabel', { + defaultMessage: 'major', + }), + }, + minor: { + id: ANOMALY_SEVERITY.MINOR, + label: i18n.translate('xpack.ml.anomalyUtils.severity.minorLabel', { + defaultMessage: 'minor', + }), + }, + warning: { + id: ANOMALY_SEVERITY.WARNING, + label: i18n.translate('xpack.ml.anomalyUtils.severity.warningLabel', { + defaultMessage: 'warning', + }), + }, + unknown: { + id: ANOMALY_SEVERITY.UNKNOWN, + label: i18n.translate('xpack.ml.anomalyUtils.severity.unknownLabel', { + defaultMessage: 'unknown', + }), + }, + low: { + id: ANOMALY_SEVERITY.LOW, + label: i18n.translate('xpack.ml.anomalyUtils.severityWithLow.lowLabel', { + defaultMessage: 'low', + }), + }, + }); +} + +// Returns a severity label (one of critical, major, minor, warning or unknown) +// for the supplied normalized anomaly score (a value between 0 and 100). +export function getSeverity(normalizedScore: number): SeverityType { + const severityTypesList = getSeverityTypes(); + + if (normalizedScore >= ANOMALY_THRESHOLD.CRITICAL) { + return severityTypesList.critical; + } else if (normalizedScore >= ANOMALY_THRESHOLD.MAJOR) { + return severityTypesList.major; + } else if (normalizedScore >= ANOMALY_THRESHOLD.MINOR) { + return severityTypesList.minor; + } else if (normalizedScore >= ANOMALY_THRESHOLD.LOW) { + return severityTypesList.warning; + } else { + return severityTypesList.unknown; + } +} + +export function getSeverityType(normalizedScore: number): ANOMALY_SEVERITY { + if (normalizedScore >= 75) { + return ANOMALY_SEVERITY.CRITICAL; + } else if (normalizedScore >= 50) { + return ANOMALY_SEVERITY.MAJOR; + } else if (normalizedScore >= 25) { + return ANOMALY_SEVERITY.MINOR; + } else if (normalizedScore >= 3) { + return ANOMALY_SEVERITY.WARNING; + } else if (normalizedScore >= 0) { + return ANOMALY_SEVERITY.LOW; + } else { + return ANOMALY_SEVERITY.UNKNOWN; + } +} + +// Returns a severity label (one of critical, major, minor, warning, low or unknown) +// for the supplied normalized anomaly score (a value between 0 and 100), where scores +// less than 3 are assigned a severity of 'low'. +export function getSeverityWithLow(normalizedScore: number): SeverityType { + const severityTypesList = getSeverityTypes(); + + if (normalizedScore >= ANOMALY_THRESHOLD.CRITICAL) { + return severityTypesList.critical; + } else if (normalizedScore >= ANOMALY_THRESHOLD.MAJOR) { + return severityTypesList.major; + } else if (normalizedScore >= ANOMALY_THRESHOLD.MINOR) { + return severityTypesList.minor; + } else if (normalizedScore >= ANOMALY_THRESHOLD.WARNING) { + return severityTypesList.warning; + } else if (normalizedScore >= ANOMALY_THRESHOLD.LOW) { + return severityTypesList.low; + } else { + return severityTypesList.unknown; + } +} + +// Returns a severity RGB color (one of critical, major, minor, warning, low_warning or unknown) +// for the supplied normalized anomaly score (a value between 0 and 100). +export function getSeverityColor(normalizedScore: number): string { + if (normalizedScore >= ANOMALY_THRESHOLD.CRITICAL) { + return '#fe5050'; + } else if (normalizedScore >= ANOMALY_THRESHOLD.MAJOR) { + return '#fba740'; + } else if (normalizedScore >= ANOMALY_THRESHOLD.MINOR) { + return '#fdec25'; + } else if (normalizedScore >= ANOMALY_THRESHOLD.WARNING) { + return '#8bc8fb'; + } else if (normalizedScore >= ANOMALY_THRESHOLD.LOW) { + return '#d2e9f7'; + } else { + return '#ffffff'; + } +} + +// Returns a label to use for the multi-bucket impact of an anomaly +// according to the value of the multi_bucket_impact field of a record, +// which ranges from -5 to +5. +export function getMultiBucketImpactLabel(multiBucketImpact: number): string { + if (multiBucketImpact >= MULTI_BUCKET_IMPACT.HIGH) { + return i18n.translate('xpack.ml.anomalyUtils.multiBucketImpact.highLabel', { + defaultMessage: 'high', + }); + } else if (multiBucketImpact >= MULTI_BUCKET_IMPACT.MEDIUM) { + return i18n.translate('xpack.ml.anomalyUtils.multiBucketImpact.mediumLabel', { + defaultMessage: 'medium', + }); + } else if (multiBucketImpact >= MULTI_BUCKET_IMPACT.LOW) { + return i18n.translate('xpack.ml.anomalyUtils.multiBucketImpact.lowLabel', { + defaultMessage: 'low', + }); + } else { + return i18n.translate('xpack.ml.anomalyUtils.multiBucketImpact.noneLabel', { + defaultMessage: 'none', + }); + } +} + +// Returns the name of the field to use as the entity name from the source record +// obtained from Elasticsearch. The function looks first for a by_field, then over_field, +// then partition_field, returning undefined if none of these fields are present. +export function getEntityFieldName(record: AnomalyRecordDoc): string | undefined { + // Analyses with by and over fields, will have a top-level by_field_name, but + // the by_field_value(s) will be in the nested causes array. + if (record.by_field_name !== undefined && record.by_field_value !== undefined) { + return record.by_field_name; + } + + if (record.over_field_name !== undefined) { + return record.over_field_name; + } + + if (record.partition_field_name !== undefined) { + return record.partition_field_name; + } + + return undefined; +} + +// Returns the value of the field to use as the entity value from the source record +// obtained from Elasticsearch. The function looks first for a by_field, then over_field, +// then partition_field, returning undefined if none of these fields are present. +export function getEntityFieldValue(record: AnomalyRecordDoc): string | number | undefined { + if (record.by_field_value !== undefined) { + return record.by_field_value; + } + + if (record.over_field_value !== undefined) { + return record.over_field_value; + } + + if (record.partition_field_value !== undefined) { + return record.partition_field_value; + } + + return undefined; +} + +// Returns the list of partitioning entity fields for the source record as a list +// of objects in the form { fieldName: airline, fieldValue: AAL, fieldType: partition } +export function getEntityFieldList(record: AnomalyRecordDoc): EntityField[] { + const entityFields: EntityField[] = []; + if (record.partition_field_name !== undefined) { + entityFields.push({ + fieldName: record.partition_field_name, + fieldValue: record.partition_field_value, + fieldType: ENTITY_FIELD_TYPE.PARTITON, + }); + } + + if (record.over_field_name !== undefined) { + entityFields.push({ + fieldName: record.over_field_name, + fieldValue: record.over_field_value, + fieldType: ENTITY_FIELD_TYPE.OVER, + }); + } + + // For jobs with by and over fields, don't add the 'by' field as this + // field will only be added to the top-level fields for record type results + // if it also an influencer over the bucket. + if (record.by_field_name !== undefined && record.over_field_name === undefined) { + entityFields.push({ + fieldName: record.by_field_name, + fieldValue: record.by_field_value, + fieldType: ENTITY_FIELD_TYPE.BY, + }); + } + + return entityFields; +} + +// Returns whether actual values should be displayed for a record with the specified function description. +// Note that the 'function' field in a record contains what the user entered e.g. 'high_count', +// whereas the 'function_description' field holds a ML-built display hint for function e.g. 'count'. +export function showActualForFunction(functionDescription: string): boolean { + return DISPLAY_ACTUAL_FUNCTIONS.indexOf(functionDescription) > -1; +} + +// Returns whether typical values should be displayed for a record with the specified function description. +// Note that the 'function' field in a record contains what the user entered e.g. 'high_count', +// whereas the 'function_description' field holds a ML-built display hint for function e.g. 'count'. +export function showTypicalForFunction(functionDescription: string): boolean { + return DISPLAY_TYPICAL_FUNCTIONS.indexOf(functionDescription) > -1; +} + +// Returns whether a rule can be configured against the specified anomaly. +export function isRuleSupported(record: AnomalyRecordDoc): boolean { + // A rule can be configured with a numeric condition if the function supports it, + // and/or with scope if there is a partitioning fields. + return ( + CONDITIONS_NOT_SUPPORTED_FUNCTIONS.indexOf(record.function) === -1 || + getEntityFieldName(record) !== undefined + ); +} + +// Two functions for converting aggregation type names. +// ML and ES use different names for the same function. +// Possible values for ML aggregation type are (defined in lib/model/CAnomalyDetector.cc): +// count +// distinct_count +// rare +// info_content +// mean +// median +// min +// max +// varp +// sum +// lat_long +// time +// The input to toES and the output from toML correspond to the value of the +// function_description field of anomaly records. +export const aggregationTypeTransform = { + toES(oldAggType: string): string { + let newAggType = oldAggType; + + if (newAggType === 'mean') { + newAggType = 'avg'; + } else if (newAggType === 'distinct_count') { + newAggType = 'cardinality'; + } else if (newAggType === 'median') { + newAggType = 'percentiles'; + } + + return newAggType; + }, + toML(oldAggType: string): string { + let newAggType = oldAggType; + + if (newAggType === 'avg') { + newAggType = 'mean'; + } else if (newAggType === 'cardinality') { + newAggType = 'distinct_count'; + } else if (newAggType === 'percentiles') { + newAggType = 'median'; + } + + return newAggType; + }, +}; diff --git a/x-pack/plugins/ml/package.json b/x-pack/plugins/ml/package.json new file mode 100644 index 0000000000000..739dd806fcbb9 --- /dev/null +++ b/x-pack/plugins/ml/package.json @@ -0,0 +1,15 @@ +{ + "author": "Elastic", + "name": "ml", + "version": "0.0.0", + "private": true, + "license": "Elastic-License", + "scripts": { + "build:apiDocScripts": "cd server/routes/apidoc_scripts && tsc", + "apiDocs": "yarn build:apiDocScripts && cd ./server/routes/ && apidoc --parse-workers apischema=./apidoc_scripts/target/schema_worker.js --parse-parsers apischema=./apidoc_scripts/target/schema_parser.js --parse-filters apiversion=./apidoc_scripts/target/version_filter.js -i . -o ../routes_doc && apidoc-markdown -p ../routes_doc -o ../routes_doc/ML_API.md" + }, + "devDependencies": { + "apidoc": "^0.20.1", + "apidoc-markdown": "^5.0.0" + } +} diff --git a/x-pack/plugins/ml/public/application/components/custom_hooks/index.ts b/x-pack/plugins/ml/public/application/components/custom_hooks/index.ts index dfd74d8970cb4..ffead802bd6f9 100644 --- a/x-pack/plugins/ml/public/application/components/custom_hooks/index.ts +++ b/x-pack/plugins/ml/public/application/components/custom_hooks/index.ts @@ -5,4 +5,3 @@ */ export { usePartialState } from './use_partial_state'; -export { useXJsonMode, xJsonMode } from './use_x_json_mode'; diff --git a/x-pack/plugins/ml/public/application/components/custom_hooks/use_x_json_mode.ts b/x-pack/plugins/ml/public/application/components/custom_hooks/use_x_json_mode.ts deleted file mode 100644 index c979632db54d6..0000000000000 --- a/x-pack/plugins/ml/public/application/components/custom_hooks/use_x_json_mode.ts +++ /dev/null @@ -1,26 +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 { useState } from 'react'; -import { - collapseLiteralStrings, - expandLiteralStrings, - XJsonMode, -} from '../../../../shared_imports'; - -// @ts-ignore -export const xJsonMode = new XJsonMode(); - -export const useXJsonMode = (json: string) => { - const [xJson, setXJson] = useState(expandLiteralStrings(json)); - - return { - xJson, - setXJson, - xJsonMode, - convertToJson: collapseLiteralStrings, - }; -}; diff --git a/x-pack/plugins/ml/public/application/components/rule_editor/__tests__/utils.js b/x-pack/plugins/ml/public/application/components/rule_editor/__tests__/utils.js deleted file mode 100644 index b5f9bdeaa12aa..0000000000000 --- a/x-pack/plugins/ml/public/application/components/rule_editor/__tests__/utils.js +++ /dev/null @@ -1,126 +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 expect from '@kbn/expect'; -import { isValidRule, buildRuleDescription, getAppliesToValueFromAnomaly } from '../utils'; -import { - ACTION, - APPLIES_TO, - OPERATOR, - FILTER_TYPE, -} from '../../../../../common/constants/detector_rule'; - -describe('ML - rule editor utils', () => { - const ruleWithCondition = { - actions: [ACTION.SKIP_RESULT], - conditions: [ - { - applies_to: APPLIES_TO.ACTUAL, - operator: OPERATOR.GREATER_THAN, - value: 10, - }, - ], - }; - - const ruleWithScope = { - actions: [ACTION.SKIP_RESULT], - scope: { - instance: { - filter_id: 'test_aws_instances', - filter_type: FILTER_TYPE.INCLUDE, - enabled: true, - }, - }, - }; - - const ruleWithConditionAndScope = { - actions: [ACTION.SKIP_RESULT], - conditions: [ - { - applies_to: APPLIES_TO.TYPICAL, - operator: OPERATOR.LESS_THAN, - value: 100, - }, - ], - scope: { - instance: { - filter_id: 'test_aws_instances', - filter_type: FILTER_TYPE.EXCLUDE, - enabled: true, - }, - }, - }; - - describe('isValidRule', () => { - it('returns true for a rule with an action and a condition', () => { - expect(isValidRule(ruleWithCondition)).to.be(true); - }); - - it('returns true for a rule with an action and scope', () => { - expect(isValidRule(ruleWithScope)).to.be(true); - }); - - it('returns true for a rule with an action, scope and condition', () => { - expect(isValidRule(ruleWithConditionAndScope)).to.be(true); - }); - - it('returns false for a rule with no action', () => { - const ruleWithNoAction = { - actions: [], - conditions: [ - { - applies_to: APPLIES_TO.TYPICAL, - operator: OPERATOR.LESS_THAN, - value: 100, - }, - ], - }; - - expect(isValidRule(ruleWithNoAction)).to.be(false); - }); - - it('returns false for a rule with no scope or conditions', () => { - const ruleWithNoScopeOrCondition = { - actions: [ACTION.SKIP_RESULT], - }; - - expect(isValidRule(ruleWithNoScopeOrCondition)).to.be(false); - }); - }); - - describe('buildRuleDescription', () => { - it('returns expected rule descriptions', () => { - expect(buildRuleDescription(ruleWithCondition)).to.be( - 'skip result when actual is greater than 10' - ); - expect(buildRuleDescription(ruleWithScope)).to.be( - 'skip result when instance is in test_aws_instances' - ); - expect(buildRuleDescription(ruleWithConditionAndScope)).to.be( - 'skip result when typical is less than 100 AND instance is not in test_aws_instances' - ); - }); - }); - - describe('getAppliesToValueFromAnomaly', () => { - const anomaly = { - actual: [210], - typical: [1.23], - }; - - it('returns expected actual value from an anomaly', () => { - expect(getAppliesToValueFromAnomaly(anomaly, APPLIES_TO.ACTUAL)).to.be(210); - }); - - it('returns expected typical value from an anomaly', () => { - expect(getAppliesToValueFromAnomaly(anomaly, APPLIES_TO.TYPICAL)).to.be(1.23); - }); - - it('returns expected diff from typical value from an anomaly', () => { - expect(getAppliesToValueFromAnomaly(anomaly, APPLIES_TO.DIFF_FROM_TYPICAL)).to.be(208.77); - }); - }); -}); diff --git a/x-pack/plugins/ml/public/application/components/rule_editor/utils.test.js b/x-pack/plugins/ml/public/application/components/rule_editor/utils.test.js new file mode 100644 index 0000000000000..18e382f8fe5e8 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/rule_editor/utils.test.js @@ -0,0 +1,125 @@ +/* + * 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 { isValidRule, buildRuleDescription, getAppliesToValueFromAnomaly } from './utils'; +import { + ACTION, + APPLIES_TO, + OPERATOR, + FILTER_TYPE, +} from '../../../../common/constants/detector_rule'; + +describe('ML - rule editor utils', () => { + const ruleWithCondition = { + actions: [ACTION.SKIP_RESULT], + conditions: [ + { + applies_to: APPLIES_TO.ACTUAL, + operator: OPERATOR.GREATER_THAN, + value: 10, + }, + ], + }; + + const ruleWithScope = { + actions: [ACTION.SKIP_RESULT], + scope: { + instance: { + filter_id: 'test_aws_instances', + filter_type: FILTER_TYPE.INCLUDE, + enabled: true, + }, + }, + }; + + const ruleWithConditionAndScope = { + actions: [ACTION.SKIP_RESULT], + conditions: [ + { + applies_to: APPLIES_TO.TYPICAL, + operator: OPERATOR.LESS_THAN, + value: 100, + }, + ], + scope: { + instance: { + filter_id: 'test_aws_instances', + filter_type: FILTER_TYPE.EXCLUDE, + enabled: true, + }, + }, + }; + + describe('isValidRule', () => { + test('returns true for a rule with an action and a condition', () => { + expect(isValidRule(ruleWithCondition)).toBe(true); + }); + + test('returns true for a rule with an action and scope', () => { + expect(isValidRule(ruleWithScope)).toBe(true); + }); + + test('returns true for a rule with an action, scope and condition', () => { + expect(isValidRule(ruleWithConditionAndScope)).toBe(true); + }); + + test('returns false for a rule with no action', () => { + const ruleWithNoAction = { + actions: [], + conditions: [ + { + applies_to: APPLIES_TO.TYPICAL, + operator: OPERATOR.LESS_THAN, + value: 100, + }, + ], + }; + + expect(isValidRule(ruleWithNoAction)).toBe(false); + }); + + test('returns false for a rule with no scope or conditions', () => { + const ruleWithNoScopeOrCondition = { + actions: [ACTION.SKIP_RESULT], + }; + + expect(isValidRule(ruleWithNoScopeOrCondition)).toBe(false); + }); + }); + + describe('buildRuleDescription', () => { + test('returns expected rule descriptions', () => { + expect(buildRuleDescription(ruleWithCondition)).toBe( + 'skip result when actual is greater than 10' + ); + expect(buildRuleDescription(ruleWithScope)).toBe( + 'skip result when instance is in test_aws_instances' + ); + expect(buildRuleDescription(ruleWithConditionAndScope)).toBe( + 'skip result when typical is less than 100 AND instance is not in test_aws_instances' + ); + }); + }); + + describe('getAppliesToValueFromAnomaly', () => { + const anomaly = { + actual: [210], + typical: [1.23], + }; + + test('returns expected actual value from an anomaly', () => { + expect(getAppliesToValueFromAnomaly(anomaly, APPLIES_TO.ACTUAL)).toBe(210); + }); + + test('returns expected typical value from an anomaly', () => { + expect(getAppliesToValueFromAnomaly(anomaly, APPLIES_TO.TYPICAL)).toBe(1.23); + }); + + test('returns expected diff from typical value from an anomaly', () => { + expect(getAppliesToValueFromAnomaly(anomaly, APPLIES_TO.DIFF_FROM_TYPICAL)).toBe(208.77); + }); + }); +}); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 511ebb7e1647a..3c959b827bb1c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -13,7 +13,6 @@ import { ml } from '../../services/ml_api_service'; import { Dictionary } from '../../../../common/types/common'; import { getErrorMessage } from '../../../../common/util/errors'; import { SavedSearchQuery } from '../../contexts/ml'; -import { SortDirection } from '../../components/ml_in_memory_table'; export type IndexName = string; export type IndexPattern = string; @@ -53,13 +52,9 @@ export interface ClassificationAnalysis { classification: Classification; } -export interface LoadExploreDataArg { - field: string; - direction: SortDirection; +export interface LoadRegressionExploreDataArg { + filterByIsTraining?: boolean; searchQuery: SavedSearchQuery; - requiresKeyword?: boolean; - pageIndex?: number; - pageSize?: number; } export const SEARCH_SIZE = 1000; @@ -272,6 +267,11 @@ export const isResultsSearchBoolQuery = (arg: any): arg is ResultsSearchBoolQuer return keys.length === 1 && keys[0] === 'bool'; }; +export const isQueryStringQuery = (arg: any): arg is QueryStringQuery => { + const keys = Object.keys(arg); + return keys.length === 1 && keys[0] === 'query_string'; +}; + export const isRegressionEvaluateResponse = (arg: any): arg is RegressionEvaluateResponse => { const keys = Object.keys(arg); return ( @@ -396,6 +396,10 @@ interface ResultsSearchTermQuery { term: Dictionary<any>; } +interface QueryStringQuery { + query_string: Dictionary<any>; +} + export type ResultsSearchQuery = ResultsSearchBoolQuery | ResultsSearchTermQuery | SavedSearchQuery; export function getEvalQueryBody({ @@ -409,16 +413,34 @@ export function getEvalQueryBody({ searchQuery?: ResultsSearchQuery; ignoreDefaultQuery?: boolean; }) { - let query: ResultsSearchQuery = { + let query; + + const trainingQuery: ResultsSearchQuery = { term: { [`${resultsField}.is_training`]: { value: isTraining } }, }; - if (searchQuery !== undefined && ignoreDefaultQuery === true) { - query = searchQuery; - } else if (searchQuery !== undefined && isResultsSearchBoolQuery(searchQuery)) { - const searchQueryClone = cloneDeep(searchQuery); - searchQueryClone.bool.must.push(query); + const searchQueryClone = cloneDeep(searchQuery); + + if (isResultsSearchBoolQuery(searchQueryClone)) { + if (searchQueryClone.bool.must === undefined) { + searchQueryClone.bool.must = []; + } + + searchQueryClone.bool.must.push(trainingQuery); query = searchQueryClone; + } else if (isQueryStringQuery(searchQueryClone)) { + query = { + bool: { + must: [searchQueryClone, trainingQuery], + }, + }; + } else { + // Not a bool or string query so we need to create it so can add the trainingQuery + query = { + bool: { + must: [trainingQuery], + }, + }; } return query; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts index 92d8731959895..f165669bdd674 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts @@ -246,8 +246,15 @@ export const getDefaultFieldsFromJobCaps = ( fields: Field[], jobConfig: DataFrameAnalyticsConfig, needsDestIndexFields: boolean -): { selectedFields: Field[]; docFields: Field[]; depVarType?: ES_FIELD_TYPES } => { - const fieldsObj = { selectedFields: [], docFields: [] }; +): { + selectedFields: Field[]; + docFields: Field[]; + depVarType?: ES_FIELD_TYPES; +} => { + const fieldsObj = { + selectedFields: [], + docFields: [], + }; if (fields.length === 0) { return fieldsObj; } @@ -267,38 +274,37 @@ export const getDefaultFieldsFromJobCaps = ( const featureImportanceFields = []; if ((numTopFeatureImportanceValues ?? 0) > 0) { - featureImportanceFields.push( - ...fields.map(d => ({ - id: `${resultsField}.feature_importance.${d.id}`, - name: `${resultsField}.feature_importance.${d.name}`, - type: KBN_FIELD_TYPES.NUMBER, - })) - ); + featureImportanceFields.push({ + id: `${resultsField}.feature_importance`, + name: `${resultsField}.feature_importance`, + type: KBN_FIELD_TYPES.NUMBER, + }); } + let allFields: any = []; // Only need to add these fields if we didn't use dest index pattern to get the fields - const allFields: any = - needsDestIndexFields === true - ? [ - { - id: `${resultsField}.is_training`, - name: `${resultsField}.is_training`, - type: ES_FIELD_TYPES.BOOLEAN, - }, - { id: predictedField, name: predictedField, type }, - ...featureImportanceFields, - ] - : []; - - allFields.push(...fields); + if (needsDestIndexFields === true) { + allFields.push( + { + id: `${resultsField}.is_training`, + name: `${resultsField}.is_training`, + type: ES_FIELD_TYPES.BOOLEAN, + }, + { id: predictedField, name: predictedField, type } + ); + } + + allFields.push(...fields, ...featureImportanceFields); allFields.sort(({ name: a }: { name: string }, { name: b }: { name: string }) => sortRegressionResultsFields(a, b, jobConfig) ); + // Remove feature_importance fields provided by dest index since feature_importance is an array the path is not valid + if (needsDestIndexFields === false) { + allFields = allFields.filter((field: any) => !field.name.includes('.feature_importance.')); + } let selectedFields = allFields.filter( - (field: any) => - field.name === predictedField || - (!field.name.includes('.keyword') && !field.name.includes('.feature_importance.')) + (field: any) => field.name === predictedField || !field.name.includes('.keyword') ); if (selectedFields.length > DEFAULT_REGRESSION_COLUMNS) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_explore_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_explore_data.ts index 6038def592e5c..9527a9adb98ce 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_explore_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_explore_data.ts @@ -22,7 +22,6 @@ import { newJobCapsService } from '../../../../../services/new_job_capabilities_ import { Field } from '../../../../../../../common/types/fields'; import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; import { - LoadExploreDataArg, defaultSearchQuery, ResultsSearchQuery, isResultsSearchBoolQuery, @@ -37,12 +36,23 @@ import { SEARCH_SIZE, SearchQuery, } from '../../../../common'; +import { SavedSearchQuery } from '../../../../../contexts/ml'; + +interface LoadClassificationExploreDataArg { + direction: SortDirection; + filterByIsTraining?: boolean; + field: string; + searchQuery: SavedSearchQuery; + requiresKeyword?: boolean; + pageIndex?: number; + pageSize?: number; +} export type TableItem = Record<string, any>; export interface UseExploreDataReturnType { errorMessage: string; - loadExploreData: (arg: LoadExploreDataArg) => void; + loadExploreData: (arg: LoadClassificationExploreDataArg) => void; sortField: EsFieldName; sortDirection: SortDirection; status: INDEX_STATUS; @@ -84,7 +94,7 @@ export const useExploreData = ( direction, searchQuery, requiresKeyword, - }: LoadExploreDataArg) => { + }: LoadClassificationExploreDataArg) => { if (jobConfig !== undefined) { setErrorMessage(''); setStatus(INDEX_STATUS.LOADING); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx index 9f235ae6c45c0..6ef6666be5ec6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment, useEffect, useState } from 'react'; +import React, { FC, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -17,7 +17,7 @@ import { EuiTitle, } from '@elastic/eui'; import { useMlKibana } from '../../../../../contexts/kibana'; -import { ErrorCallout } from '../error_callout'; +import { SavedSearchQuery } from '../../../../../contexts/ml'; import { getValuesFromResponse, getDependentVar, @@ -33,14 +33,13 @@ import { EvaluateStat } from './evaluate_stat'; import { isResultsSearchBoolQuery, isRegressionEvaluateResponse, - ResultsSearchQuery, ANALYSIS_CONFIG_TYPE, } from '../../../../common/analytics'; interface Props { jobConfig: DataFrameAnalyticsConfig; jobStatus?: DATA_FRAME_TASK_STATE; - searchQuery: ResultsSearchQuery; + searchQuery: SavedSearchQuery; } const defaultEval: Eval = { meanSquaredError: '', rSquared: '', error: null }; @@ -54,6 +53,7 @@ export const EvaluatePanel: FC<Props> = ({ jobConfig, jobStatus, searchQuery }) const [generalizationEval, setGeneralizationEval] = useState<Eval>(defaultEval); const [isLoadingTraining, setIsLoadingTraining] = useState<boolean>(false); const [isLoadingGeneralization, setIsLoadingGeneralization] = useState<boolean>(false); + const [isTrainingFilter, setIsTrainingFilter] = useState<boolean | undefined>(undefined); const [trainingDocsCount, setTrainingDocsCount] = useState<null | number>(null); const [generalizationDocsCount, setGeneralizationDocsCount] = useState<null | number>(null); @@ -92,8 +92,8 @@ export const EvaluatePanel: FC<Props> = ({ jobConfig, jobStatus, searchQuery }) } else { setIsLoadingGeneralization(false); setGeneralizationEval({ - meanSquaredError: '', - rSquared: '', + meanSquaredError: '--', + rSquared: '--', error: genErrorEval.error, }); } @@ -128,108 +128,78 @@ export const EvaluatePanel: FC<Props> = ({ jobConfig, jobStatus, searchQuery }) } else { setIsLoadingTraining(false); setTrainingEval({ - meanSquaredError: '', - rSquared: '', + meanSquaredError: '--', + rSquared: '--', error: trainingErrorEval.error, }); } }; - const loadData = async ({ - isTrainingClause, - }: { - isTrainingClause?: { query: string; operator: string }; - }) => { - // searchBar query is filtering for testing data - if (isTrainingClause !== undefined && isTrainingClause.query === 'false') { - loadGeneralizationData(); - - const docsCountResp = await loadDocsCount({ - isTraining: false, - searchQuery, - resultsField, - destIndex: jobConfig.dest.index, - }); - - if (docsCountResp.success === true) { - setGeneralizationDocsCount(docsCountResp.docsCount); - } else { - setGeneralizationDocsCount(null); - } - - setTrainingDocsCount(0); - setTrainingEval({ - meanSquaredError: '--', - rSquared: '--', - error: null, - }); - } else if (isTrainingClause !== undefined && isTrainingClause.query === 'true') { - // searchBar query is filtering for training data - loadTrainingData(); - - const docsCountResp = await loadDocsCount({ - isTraining: true, - searchQuery, - resultsField, - destIndex: jobConfig.dest.index, - }); - - if (docsCountResp.success === true) { - setTrainingDocsCount(docsCountResp.docsCount); - } else { - setTrainingDocsCount(null); - } - - setGeneralizationDocsCount(0); - setGeneralizationEval({ - meanSquaredError: '--', - rSquared: '--', - error: null, - }); + const loadData = async () => { + loadGeneralizationData(false); + const genDocsCountResp = await loadDocsCount({ + ignoreDefaultQuery: false, + isTraining: false, + searchQuery, + resultsField, + destIndex: jobConfig.dest.index, + }); + if (genDocsCountResp.success === true) { + setGeneralizationDocsCount(genDocsCountResp.docsCount); } else { - // No is_training clause/filter from search bar so load both - loadGeneralizationData(false); - const genDocsCountResp = await loadDocsCount({ - ignoreDefaultQuery: false, - isTraining: false, - searchQuery, - resultsField, - destIndex: jobConfig.dest.index, - }); - if (genDocsCountResp.success === true) { - setGeneralizationDocsCount(genDocsCountResp.docsCount); - } else { - setGeneralizationDocsCount(null); - } + setGeneralizationDocsCount(null); + } - loadTrainingData(false); - const trainDocsCountResp = await loadDocsCount({ - ignoreDefaultQuery: false, - isTraining: true, - searchQuery, - resultsField, - destIndex: jobConfig.dest.index, - }); - if (trainDocsCountResp.success === true) { - setTrainingDocsCount(trainDocsCountResp.docsCount); - } else { - setTrainingDocsCount(null); - } + loadTrainingData(false); + const trainDocsCountResp = await loadDocsCount({ + ignoreDefaultQuery: false, + isTraining: true, + searchQuery, + resultsField, + destIndex: jobConfig.dest.index, + }); + if (trainDocsCountResp.success === true) { + setTrainingDocsCount(trainDocsCountResp.docsCount); + } else { + setTrainingDocsCount(null); } }; useEffect(() => { - const hasIsTrainingClause = - isResultsSearchBoolQuery(searchQuery) && - searchQuery.bool.must.filter( - (clause: any) => clause.match && clause.match[`${resultsField}.is_training`] !== undefined - ); - const isTrainingClause = - hasIsTrainingClause && - hasIsTrainingClause[0] && - hasIsTrainingClause[0].match[`${resultsField}.is_training`]; + let isTraining: boolean | undefined; + const query = + isResultsSearchBoolQuery(searchQuery) && (searchQuery.bool.should || searchQuery.bool.filter); + + if (query !== undefined && query !== false) { + for (let i = 0; i < query.length; i++) { + const clause = query[i]; - loadData({ isTrainingClause }); + if (clause.match && clause.match[`${resultsField}.is_training`] !== undefined) { + isTraining = clause.match[`${resultsField}.is_training`]; + break; + } else if ( + clause.bool && + (clause.bool.should !== undefined || clause.bool.filter !== undefined) + ) { + const innerQuery = clause.bool.should || clause.bool.filter; + if (innerQuery !== undefined) { + for (let j = 0; j < innerQuery.length; j++) { + const innerClause = innerQuery[j]; + if ( + innerClause.match && + innerClause.match[`${resultsField}.is_training`] !== undefined + ) { + isTraining = innerClause.match[`${resultsField}.is_training`]; + break; + } + } + } + } + } + } + + setIsTrainingFilter(isTraining); + loadData(); }, [JSON.stringify(searchQuery)]); return ( @@ -293,13 +263,18 @@ export const EvaluatePanel: FC<Props> = ({ jobConfig, jobStatus, searchQuery }) defaultMessage="{docsCount, plural, one {# doc} other {# docs}} evaluated" values={{ docsCount: generalizationDocsCount }} /> + {isTrainingFilter === true && generalizationDocsCount === 0 && ( + <FormattedMessage + id="xpack.ml.dataframe.analytics.regressionExploration.generalizationFilterText" + defaultMessage=". Filtering for training data." + /> + )} </EuiText> )} <EuiSpacer /> - <EuiFlexGroup> - {generalizationEval.error !== null && <ErrorCallout error={generalizationEval.error} />} - {generalizationEval.error === null && ( - <Fragment> + <EuiFlexGroup direction="column" gutterSize="none"> + <EuiFlexItem> + <EuiFlexGroup> <EuiFlexItem> <EvaluateStat dataTestSubj={'mlDFAnalyticsRegressionGenMSEstat'} @@ -316,7 +291,14 @@ export const EvaluatePanel: FC<Props> = ({ jobConfig, jobStatus, searchQuery }) isMSE={false} /> </EuiFlexItem> - </Fragment> + </EuiFlexGroup> + </EuiFlexItem> + {generalizationEval.error !== null && ( + <EuiFlexItem grow={false}> + <EuiText size="xs" color="danger"> + {generalizationEval.error} + </EuiText> + </EuiFlexItem> )} </EuiFlexGroup> </EuiFlexItem> @@ -338,13 +320,18 @@ export const EvaluatePanel: FC<Props> = ({ jobConfig, jobStatus, searchQuery }) defaultMessage="{docsCount, plural, one {# doc} other {# docs}} evaluated" values={{ docsCount: trainingDocsCount }} /> + {isTrainingFilter === false && trainingDocsCount === 0 && ( + <FormattedMessage + id="xpack.ml.dataframe.analytics.regressionExploration.trainingFilterText" + defaultMessage=". Filtering for testing data." + /> + )} </EuiText> )} <EuiSpacer /> - <EuiFlexGroup> - {trainingEval.error !== null && <ErrorCallout error={trainingEval.error} />} - {trainingEval.error === null && ( - <Fragment> + <EuiFlexGroup direction="column" gutterSize="none"> + <EuiFlexItem> + <EuiFlexGroup> <EuiFlexItem> <EvaluateStat dataTestSubj={'mlDFAnalyticsRegressionTrainingMSEstat'} @@ -361,7 +348,14 @@ export const EvaluatePanel: FC<Props> = ({ jobConfig, jobStatus, searchQuery }) isMSE={false} /> </EuiFlexItem> - </Fragment> + </EuiFlexGroup> + </EuiFlexItem> + {trainingEval.error !== null && ( + <EuiFlexItem grow={false}> + <EuiText size="xs" color="danger"> + {trainingEval.error} + </EuiText> + </EuiFlexItem> )} </EuiFlexGroup> </EuiFlexItem> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/regression_exploration_data_grid.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/regression_exploration_data_grid.tsx new file mode 100644 index 0000000000000..0fcb1ed600719 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/regression_exploration_data_grid.tsx @@ -0,0 +1,135 @@ +/* + * 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, { Dispatch, FC, SetStateAction, useCallback, useMemo } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { EuiDataGrid, EuiDataGridPaginationProps, EuiDataGridSorting } from '@elastic/eui'; + +import { euiDataGridStyle, euiDataGridToolbarSettings } from '../../../../common'; + +import { mlFieldFormatService } from '../../../../../services/field_format_service'; + +import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; + +const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]; + +type Pagination = Pick<EuiDataGridPaginationProps, 'pageIndex' | 'pageSize'>; +type TableItem = Record<string, any>; + +interface ExplorationDataGridProps { + colorRange?: (d: number) => string; + columns: any[]; + indexPattern: IndexPattern; + pagination: Pagination; + resultsField: string; + rowCount: number; + selectedFields: string[]; + setPagination: Dispatch<SetStateAction<Pagination>>; + setSelectedFields: Dispatch<SetStateAction<string[]>>; + setSortingColumns: Dispatch<SetStateAction<EuiDataGridSorting['columns']>>; + sortingColumns: EuiDataGridSorting['columns']; + tableItems: TableItem[]; +} + +export const RegressionExplorationDataGrid: FC<ExplorationDataGridProps> = ({ + columns, + indexPattern, + pagination, + resultsField, + rowCount, + selectedFields, + setPagination, + setSelectedFields, + setSortingColumns, + sortingColumns, + tableItems, +}) => { + const renderCellValue = useMemo(() => { + return ({ rowIndex, columnId }: { rowIndex: number; columnId: string; setCellProps: any }) => { + const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize; + + const fullItem = tableItems[adjustedRowIndex]; + + if (fullItem === undefined) { + return null; + } + + let format: any; + + if (indexPattern !== undefined) { + format = mlFieldFormatService.getFieldFormatFromIndexPattern(indexPattern, columnId, ''); + } + + const cellValue = + fullItem.hasOwnProperty(columnId) && fullItem[columnId] !== undefined + ? fullItem[columnId] + : null; + + if (format !== undefined) { + return format.convert(cellValue, 'text'); + } + + if (typeof cellValue === 'string' || cellValue === null) { + return cellValue; + } + + if (typeof cellValue === 'boolean') { + return cellValue ? 'true' : 'false'; + } + + if (typeof cellValue === 'object' && cellValue !== null) { + return JSON.stringify(cellValue); + } + + return cellValue; + }; + }, [resultsField, rowCount, tableItems, pagination.pageIndex, pagination.pageSize]); + + const onChangeItemsPerPage = useCallback( + pageSize => { + setPagination(p => { + const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize); + return { pageIndex, pageSize }; + }); + }, + [setPagination] + ); + + const onChangePage = useCallback(pageIndex => setPagination(p => ({ ...p, pageIndex })), [ + setPagination, + ]); + + const onSort = useCallback(sc => setSortingColumns(sc), [setSortingColumns]); + + return ( + <EuiDataGrid + aria-label={i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.dataGridAriaLabel', + { + defaultMessage: 'Regression results table', + } + )} + columns={columns} + columnVisibility={{ + visibleColumns: selectedFields, + setVisibleColumns: setSelectedFields, + }} + gridStyle={euiDataGridStyle} + rowCount={rowCount} + renderCellValue={renderCellValue} + sorting={{ columns: sortingColumns, onSort }} + toolbarVisibility={euiDataGridToolbarSettings} + pagination={{ + ...pagination, + pageSizeOptions: PAGE_SIZE_OPTIONS, + onChangeItemsPerPage, + onChangePage, + }} + /> + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/results_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/results_table.tsx index a35be5400f46b..43fa50b2e4df5 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/results_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/results_table.tsx @@ -4,72 +4,41 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, FC, useEffect, useState } from 'react'; -import moment from 'moment-timezone'; +import React, { Fragment, FC, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { - EuiBadge, - EuiButtonIcon, EuiCallOut, - EuiCheckbox, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiPanel, - EuiPopover, - EuiPopoverTitle, EuiProgress, EuiSpacer, EuiText, - EuiToolTip, - Query, } from '@elastic/eui'; -import { Query as QueryType } from '../../../analytics_management/components/analytics_list/common'; -import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; -import { mlFieldFormatService } from '../../../../../services/field_format_service'; - -import { - ColumnType, - mlInMemoryTableBasicFactory, - OnTableChangeArg, - SortingPropType, - SORT_DIRECTION, -} from '../../../../../components/ml_in_memory_table'; - -import { formatHumanReadableDateTimeSeconds } from '../../../../../util/date_utils'; -import { Field } from '../../../../../../../common/types/fields'; -import { SavedSearchQuery } from '../../../../../contexts/ml'; import { BASIC_NUMERICAL_TYPES, EXTENDED_NUMERICAL_TYPES, - toggleSelectedField, - isKeywordAndTextType, sortRegressionResultsFields, } from '../../../../common/fields'; import { DataFrameAnalyticsConfig, - EsFieldName, - EsDoc, MAX_COLUMNS, - getPredictedFieldName, INDEX_STATUS, SEARCH_SIZE, defaultSearchQuery, - getDependentVar, } from '../../../../common'; import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/columns'; import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; -import { useExploreData, TableItem } from './use_explore_data'; +import { useExploreData } from './use_explore_data'; import { ExplorationTitle } from './regression_exploration'; - -const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]; - -const MlInMemoryTableBasic = mlInMemoryTableBasicFactory<TableItem>(); +import { RegressionExplorationDataGrid } from './regression_exploration_data_grid'; +import { ExplorationQueryBar } from '../exploration_query_bar'; const showingDocs = i18n.translate( 'xpack.ml.dataframe.analytics.regressionExploration.documentsShownHelpText', @@ -95,308 +64,65 @@ interface Props { export const ResultsTable: FC<Props> = React.memo( ({ indexPattern, jobConfig, jobStatus, setEvaluateSearchQuery }) => { - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(25); - const [selectedFields, setSelectedFields] = useState([] as Field[]); - const [docFields, setDocFields] = useState([] as Field[]); - const [depVarType, setDepVarType] = useState<ES_FIELD_TYPES | undefined>(undefined); - const [isColumnsPopoverVisible, setColumnsPopoverVisible] = useState(false); - const [searchQuery, setSearchQuery] = useState<SavedSearchQuery>(defaultSearchQuery); - const [searchError, setSearchError] = useState<any>(undefined); - const [searchString, setSearchString] = useState<string | undefined>(undefined); - - const predictedFieldName = getPredictedFieldName( - jobConfig.dest.results_field, - jobConfig.analysis - ); - - const dependentVariable = getDependentVar(jobConfig.analysis); - - function toggleColumnsPopover() { - setColumnsPopoverVisible(!isColumnsPopoverVisible); - } - - function closeColumnsPopover() { - setColumnsPopoverVisible(false); - } - - function toggleColumn(column: EsFieldName) { - if (tableItems.length > 0 && jobConfig !== undefined) { - // spread to a new array otherwise the component wouldn't re-render - setSelectedFields([ - ...toggleSelectedField(selectedFields, column, jobConfig.dest.results_field, depVarType), - ]); - } - } - const needsDestIndexFields = indexPattern && indexPattern.title === jobConfig.source.index[0]; - + const resultsField = jobConfig.dest.results_field; const { errorMessage, - loadExploreData, - sortField, - sortDirection, - status, - tableItems, - } = useExploreData( - jobConfig, - needsDestIndexFields, + fieldTypes, + pagination, + searchQuery, selectedFields, + rowCount, + setPagination, + setSearchQuery, setSelectedFields, - setDocFields, - setDepVarType - ); - - const columns: Array<ColumnType<TableItem>> = selectedFields - .sort(({ name: a }, { name: b }) => sortRegressionResultsFields(a, b, jobConfig)) - .map(field => { - const { type } = field; - let format: any; + setSortingColumns, + sortingColumns, + status, + tableFields, + tableItems, + } = useExploreData(jobConfig, needsDestIndexFields); - if (indexPattern !== undefined) { - format = mlFieldFormatService.getFieldFormatFromIndexPattern(indexPattern, field.id, ''); - } + useEffect(() => { + setEvaluateSearchQuery(searchQuery); + }, [JSON.stringify(searchQuery)]); + const columns = tableFields + .sort((a: any, b: any) => sortRegressionResultsFields(a, b, jobConfig)) + .map((field: any) => { + // Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json'] + // To fall back to the default string schema it needs to be undefined. + let schema; + let isSortable = true; + const type = fieldTypes[field]; const isNumber = type !== undefined && (BASIC_NUMERICAL_TYPES.has(type) || EXTENDED_NUMERICAL_TYPES.has(type)); - const column: ColumnType<TableItem> = { - field: field.name, - name: field.name, - sortable: true, - truncateText: true, - }; - - const render = (d: any, fullItem: EsDoc) => { - if (format !== undefined) { - d = format.convert(d, 'text'); - return d; - } - - if (Array.isArray(d) && d.every(item => typeof item === 'string')) { - // If the cells data is an array of strings, return as a comma separated list. - // The list will get limited to 5 items with `…` at the end if there's more in the original array. - return `${d.slice(0, 5).join(', ')}${d.length > 5 ? ', …' : ''}`; - } else if (Array.isArray(d)) { - // If the cells data is an array of e.g. objects, display a 'array' badge with a - // tooltip that explains that this type of field is not supported in this table. - return ( - <EuiToolTip - content={i18n.translate( - 'xpack.ml.dataframe.analytics.regressionExploration.indexArrayToolTipContent', - { - defaultMessage: - 'The full content of this array based column cannot be displayed.', - } - )} - > - <EuiBadge> - {i18n.translate( - 'xpack.ml.dataframe.analytics.regressionExploration.indexArrayBadgeContent', - { - defaultMessage: 'array', - } - )} - </EuiBadge> - </EuiToolTip> - ); - } - - return d; - }; - if (isNumber) { - column.dataType = 'number'; - column.render = render; - } else if (typeof type !== 'undefined') { - switch (type) { - case ES_FIELD_TYPES.BOOLEAN: - column.dataType = ES_FIELD_TYPES.BOOLEAN; - column.render = d => (d ? 'true' : 'false'); - break; - case ES_FIELD_TYPES.DATE: - column.align = 'right'; - if (format !== undefined) { - column.render = render; - } else { - column.render = (d: any) => { - if (d !== undefined) { - return formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000); - } - return d; - }; - } - break; - default: - column.render = render; - break; - } - } else { - column.render = render; + schema = 'numeric'; } - return column; - }); - - const docFieldsCount = docFields.length; - - useEffect(() => { - if ( - jobConfig !== undefined && - columns.length > 0 && - selectedFields.length > 0 && - sortField !== undefined && - sortDirection !== undefined && - selectedFields.some(field => field.name === sortField) - ) { - let field = sortField; - // If sorting by predictedField use dependentVar type - if (predictedFieldName === sortField) { - field = dependentVariable; + switch (type) { + case 'date': + schema = 'datetime'; + break; + case 'geo_point': + schema = 'json'; + break; + case 'boolean': + schema = 'boolean'; + break; } - const requiresKeyword = isKeywordAndTextType(field); - - loadExploreData({ - field: sortField, - direction: sortDirection, - searchQuery, - requiresKeyword, - }); - } - }, [JSON.stringify(searchQuery)]); - - useEffect(() => { - // By default set sorting to descending on the prediction field (`<dependent_varible or prediction_field_name>_prediction`). - // if that's not available sort ascending on the first column. Check if the current sorting field is still available. - if ( - jobConfig !== undefined && - columns.length > 0 && - selectedFields.length > 0 && - !selectedFields.some(field => field.name === sortField) - ) { - const predictedFieldSelected = selectedFields.some( - field => field.name === predictedFieldName - ); - - // CHECK IF keyword suffix is needed (if predicted field is selected we have to check the dependent variable type) - let sortByField = predictedFieldSelected ? dependentVariable : selectedFields[0].name; - - const requiresKeyword = isKeywordAndTextType(sortByField); - - sortByField = predictedFieldSelected ? predictedFieldName : sortByField; - - const direction = predictedFieldSelected ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC; - loadExploreData({ field: sortByField, direction, searchQuery, requiresKeyword }); - } - }, [ - jobConfig, - columns.length, - selectedFields.length, - sortField, - sortDirection, - tableItems.length, - ]); - - let sorting: SortingPropType = false; - let onTableChange; - - if (columns.length > 0 && sortField !== '' && sortField !== undefined) { - sorting = { - sort: { - field: sortField, - direction: sortDirection, - }, - }; - - onTableChange = ({ - page = { index: 0, size: 10 }, - sort = { field: sortField, direction: sortDirection }, - }: OnTableChangeArg) => { - const { index, size } = page; - setPageIndex(index); - setPageSize(size); - - if (sort.field !== sortField || sort.direction !== sortDirection) { - let field = sort.field; - // If sorting by predictedField use depVar for type check - if (predictedFieldName === sort.field) { - field = dependentVariable; - } - loadExploreData({ - ...sort, - searchQuery, - requiresKeyword: isKeywordAndTextType(field), - }); + if (field === `${resultsField}.feature_importance`) { + isSortable = false; } - }; - } - - const pagination = { - initialPageIndex: pageIndex, - initialPageSize: pageSize, - totalItemCount: tableItems.length, - pageSizeOptions: PAGE_SIZE_OPTIONS, - hidePerPageOptions: false, - }; - const onQueryChange = ({ query, error }: { query: QueryType; error: any }) => { - if (error) { - setSearchError(error.message); - } else { - try { - const esQueryDsl = Query.toESQuery(query); - setSearchQuery(esQueryDsl); - setSearchString(query.text); - setSearchError(undefined); - // set query for use in evaluate panel - setEvaluateSearchQuery(esQueryDsl); - } catch (e) { - setSearchError(e.toString()); - } - } - }; + return { id: field, schema, isSortable }; + }); - const search = { - onChange: onQueryChange, - defaultQuery: searchString, - box: { - incremental: false, - placeholder: i18n.translate( - 'xpack.ml.dataframe.analytics.regressionExploration.searchBoxPlaceholder', - { - defaultMessage: 'E.g. avg>0.5', - } - ), - }, - filters: [ - { - type: 'field_value_toggle_group', - field: `${jobConfig.dest.results_field}.is_training`, - items: [ - { - value: false, - name: i18n.translate( - 'xpack.ml.dataframe.analytics.regressionExploration.isTestingLabel', - { - defaultMessage: 'Testing', - } - ), - }, - { - value: true, - name: i18n.translate( - 'xpack.ml.dataframe.analytics.regressionExploration.isTrainingLabel', - { - defaultMessage: 'Training', - } - ), - }, - ], - }, - ], - }; + const docFieldsCount = tableFields.length; if (jobConfig === undefined) { return null; @@ -428,11 +154,6 @@ export const ResultsTable: FC<Props> = React.memo( ); } - const tableError = - status === INDEX_STATUS.ERROR && errorMessage.includes('parsing_exception') - ? errorMessage - : searchError; - return ( <EuiPanel grow={false} data-test-subj="mlDFAnalyticsRegressionExplorationTablePanel"> <EuiFlexGroup alignItems="center" justifyContent="spaceBetween" responsive={false}> @@ -464,52 +185,6 @@ export const ResultsTable: FC<Props> = React.memo( </EuiText> )} </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiText size="s"> - <EuiPopover - id="popover" - button={ - <EuiButtonIcon - iconType="gear" - onClick={toggleColumnsPopover} - aria-label={i18n.translate( - 'xpack.ml.dataframe.analytics.regressionExploration.selectColumnsAriaLabel', - { - defaultMessage: 'Select columns', - } - )} - /> - } - isOpen={isColumnsPopoverVisible} - closePopover={closeColumnsPopover} - ownFocus - > - <EuiPopoverTitle> - {i18n.translate( - 'xpack.ml.dataframe.analytics.regressionExploration.selectFieldsPopoverTitle', - { - defaultMessage: 'Select fields', - } - )} - </EuiPopoverTitle> - <div style={{ maxHeight: '400px', overflowY: 'scroll' }}> - {docFields.map(({ name }) => ( - <EuiCheckbox - id={name} - key={name} - label={name} - checked={selectedFields.some(field => field.name === name)} - onChange={() => toggleColumn(name)} - disabled={ - selectedFields.some(field => field.name === name) && - selectedFields.length === 1 - } - /> - ))} - </div> - </EuiPopover> - </EuiText> - </EuiFlexItem> </EuiFlexGroup> </EuiFlexItem> </EuiFlexGroup> @@ -518,29 +193,39 @@ export const ResultsTable: FC<Props> = React.memo( <EuiProgress size="xs" color="accent" max={1} value={0} /> )} {(columns.length > 0 || searchQuery !== defaultSearchQuery) && ( - <Fragment> - <EuiFormRow - helpText={tableItems.length === SEARCH_SIZE ? showingFirstDocs : showingDocs} - > - <Fragment /> - </EuiFormRow> - - <EuiSpacer /> - <MlInMemoryTableBasic - allowNeutralSort={false} - columns={columns} - compressed - hasActions={false} - isSelectable={false} - items={tableItems} - onTableChange={onTableChange} - pagination={pagination} - responsive={false} - search={search} - error={tableError} - sorting={sorting} - /> - </Fragment> + <EuiFlexGroup direction="column"> + <EuiFlexItem grow={false}> + <EuiSpacer size="s" /> + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexItem> + <ExplorationQueryBar + indexPattern={indexPattern} + setSearchQuery={setSearchQuery} + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiFormRow + helpText={tableItems.length === SEARCH_SIZE ? showingFirstDocs : showingDocs} + > + <Fragment /> + </EuiFormRow> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <RegressionExplorationDataGrid + columns={columns} + indexPattern={indexPattern} + pagination={pagination} + resultsField={jobConfig.dest.results_field} + rowCount={rowCount} + selectedFields={selectedFields} + setPagination={setPagination} + setSelectedFields={setSelectedFields} + setSortingColumns={setSortingColumns} + sortingColumns={sortingColumns} + tableItems={tableItems} + /> + </EuiFlexItem> + </EuiFlexGroup> )} </EuiPanel> ); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/use_explore_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/use_explore_data.ts index e158e952c1c18..c68fe5b2cbee8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/use_explore_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/use_explore_data.ts @@ -4,97 +4,156 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState, Dispatch, SetStateAction } from 'react'; +import { EuiDataGridPaginationProps, EuiDataGridSorting } from '@elastic/eui'; import { SearchResponse } from 'elasticsearch'; import { cloneDeep } from 'lodash'; -import { SortDirection, SORT_DIRECTION } from '../../../../../components/ml_in_memory_table'; - import { ml } from '../../../../../services/ml_api_service'; import { getNestedProperty } from '../../../../../util/object_utils'; import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; +import { SORT_DIRECTION } from '../../../../../components/ml_in_memory_table'; +import { SavedSearchQuery } from '../../../../../contexts/ml'; import { getDefaultFieldsFromJobCaps, + getDependentVar, getFlattenedFields, + getPredictedFieldName, DataFrameAnalyticsConfig, EsFieldName, INDEX_STATUS, - SEARCH_SIZE, - SearchQuery, } from '../../../../common'; -import { Field } from '../../../../../../../common/types/fields'; +import { Dictionary } from '../../../../../../../common/types/common'; +import { isKeywordAndTextType } from '../../../../common/fields'; import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; import { - LoadExploreDataArg, + LoadRegressionExploreDataArg, defaultSearchQuery, ResultsSearchQuery, isResultsSearchBoolQuery, } from '../../../../common/analytics'; export type TableItem = Record<string, any>; +type Pagination = Pick<EuiDataGridPaginationProps, 'pageIndex' | 'pageSize'>; export interface UseExploreDataReturnType { errorMessage: string; - loadExploreData: (arg: LoadExploreDataArg) => void; - sortField: EsFieldName; - sortDirection: SortDirection; + fieldTypes: { [key: string]: ES_FIELD_TYPES }; + pagination: Pagination; + rowCount: number; + searchQuery: SavedSearchQuery; + selectedFields: EsFieldName[]; + setFilterByIsTraining: Dispatch<SetStateAction<undefined | boolean>>; + setPagination: Dispatch<SetStateAction<Pagination>>; + setSearchQuery: Dispatch<SetStateAction<SavedSearchQuery>>; + setSelectedFields: Dispatch<SetStateAction<EsFieldName[]>>; + setSortingColumns: Dispatch<SetStateAction<EuiDataGridSorting['columns']>>; + sortingColumns: EuiDataGridSorting['columns']; status: INDEX_STATUS; + tableFields: string[]; tableItems: TableItem[]; } +type EsSorting = Dictionary<{ + order: 'asc' | 'desc'; +}>; + +// The types specified in `@types/elasticsearch` are out of date and still have `total: number`. +interface SearchResponse7 extends SearchResponse<any> { + hits: SearchResponse<any>['hits'] & { + total: { + value: number; + relation: string; + }; + }; +} + export const useExploreData = ( - jobConfig: DataFrameAnalyticsConfig | undefined, - needsDestIndexFields: boolean, - selectedFields: Field[], - setSelectedFields: React.Dispatch<React.SetStateAction<Field[]>>, - setDocFields: React.Dispatch<React.SetStateAction<Field[]>>, - setDepVarType: React.Dispatch<React.SetStateAction<ES_FIELD_TYPES | undefined>> + jobConfig: DataFrameAnalyticsConfig, + needsDestIndexFields: boolean ): UseExploreDataReturnType => { const [errorMessage, setErrorMessage] = useState(''); const [status, setStatus] = useState(INDEX_STATUS.UNUSED); + + const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]); + const [tableFields, setTableFields] = useState<string[]>([]); const [tableItems, setTableItems] = useState<TableItem[]>([]); - const [sortField, setSortField] = useState<string>(''); - const [sortDirection, setSortDirection] = useState<SortDirection>(SORT_DIRECTION.ASC); + const [fieldTypes, setFieldTypes] = useState<{ [key: string]: ES_FIELD_TYPES }>({}); + const [rowCount, setRowCount] = useState(0); + + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); + const [searchQuery, setSearchQuery] = useState<SavedSearchQuery>(defaultSearchQuery); + const [filterByIsTraining, setFilterByIsTraining] = useState<undefined | boolean>(undefined); + const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]); + + const predictedFieldName = getPredictedFieldName( + jobConfig.dest.results_field, + jobConfig.analysis + ); + const dependentVariable = getDependentVar(jobConfig.analysis); const getDefaultSelectedFields = () => { const { fields } = newJobCapsService; - if (selectedFields.length === 0 && jobConfig !== undefined) { - const { - selectedFields: defaultSelected, - docFields, - depVarType, - } = getDefaultFieldsFromJobCaps(fields, jobConfig, needsDestIndexFields); - - setDepVarType(depVarType); - setSelectedFields(defaultSelected); - setDocFields(docFields); + const { selectedFields: defaultSelected, docFields } = getDefaultFieldsFromJobCaps( + fields, + jobConfig, + needsDestIndexFields + ); + + const types: { [key: string]: ES_FIELD_TYPES } = {}; + const allFields: string[] = []; + + docFields.forEach(field => { + types[field.id] = field.type; + allFields.push(field.id); + }); + + setFieldTypes(types); + setSelectedFields(defaultSelected.map(field => field.id)); + setTableFields(allFields); } }; const loadExploreData = async ({ - field, - direction, - searchQuery, - requiresKeyword, - }: LoadExploreDataArg) => { + filterByIsTraining: isTraining, + searchQuery: incomingQuery, + }: LoadRegressionExploreDataArg) => { if (jobConfig !== undefined) { setErrorMessage(''); setStatus(INDEX_STATUS.LOADING); try { const resultsField = jobConfig.dest.results_field; - const searchQueryClone: ResultsSearchQuery = cloneDeep(searchQuery); + const searchQueryClone: ResultsSearchQuery = cloneDeep(incomingQuery); let query: ResultsSearchQuery; + const { pageIndex, pageSize } = pagination; + // If filterByIsTraining is defined - add that in to the final query + const trainingQuery = + isTraining !== undefined + ? { + term: { [`${resultsField}.is_training`]: { value: isTraining } }, + } + : undefined; - if (JSON.stringify(searchQuery) === JSON.stringify(defaultSearchQuery)) { - query = { + if (JSON.stringify(incomingQuery) === JSON.stringify(defaultSearchQuery)) { + const existsQuery = { exists: { field: resultsField, }, }; + + query = { + bool: { + must: [existsQuery], + }, + }; + + if (trainingQuery !== undefined && isResultsSearchBoolQuery(query)) { + query.bool.must.push(trainingQuery); + } } else if (isResultsSearchBoolQuery(searchQueryClone)) { if (searchQueryClone.bool.must === undefined) { searchQueryClone.bool.must = []; @@ -106,32 +165,37 @@ export const useExploreData = ( }, }); + if (trainingQuery !== undefined) { + searchQueryClone.bool.must.push(trainingQuery); + } + query = searchQueryClone; } else { query = searchQueryClone; } - const body: SearchQuery = { - query, - }; - - if (field !== undefined) { - body.sort = [ - { - [`${field}${requiresKeyword ? '.keyword' : ''}`]: { - order: direction, - }, - }, - ]; - } - const resp: SearchResponse<any> = await ml.esSearch({ + const sort: EsSorting = sortingColumns + .map(column => { + const { id } = column; + column.id = isKeywordAndTextType(id) ? `${id}.keyword` : id; + return column; + }) + .reduce((s, column) => { + s[column.id] = { order: column.direction }; + return s; + }, {} as EsSorting); + + const resp: SearchResponse7 = await ml.esSearch({ index: jobConfig.dest.index, - size: SEARCH_SIZE, - body, + body: { + query, + from: pageIndex * pageSize, + size: pageSize, + ...(Object.keys(sort).length > 0 ? { sort } : {}), + }, }); - setSortField(field); - setSortDirection(direction); + setRowCount(resp.hits.total.value); const docs = resp.hits.hits; @@ -183,10 +247,45 @@ export const useExploreData = ( }; useEffect(() => { - if (jobConfig !== undefined) { - getDefaultSelectedFields(); - } + getDefaultSelectedFields(); }, [jobConfig && jobConfig.id]); - return { errorMessage, loadExploreData, sortField, sortDirection, status, tableItems }; + // By default set sorting to descending on the prediction field (`<dependent_varible or prediction_field_name>_prediction`). + useEffect(() => { + const sortByField = isKeywordAndTextType(dependentVariable) + ? `${predictedFieldName}.keyword` + : predictedFieldName; + const direction = SORT_DIRECTION.DESC; + + setSortingColumns([{ id: sortByField, direction }]); + }, [jobConfig && jobConfig.id]); + + useEffect(() => { + loadExploreData({ filterByIsTraining, searchQuery }); + }, [ + filterByIsTraining, + jobConfig && jobConfig.id, + pagination, + searchQuery, + selectedFields, + sortingColumns, + ]); + + return { + errorMessage, + fieldTypes, + pagination, + searchQuery, + selectedFields, + rowCount, + setFilterByIsTraining, + setPagination, + setSelectedFields, + setSortingColumns, + setSearchQuery, + sortingColumns, + status, + tableItems, + tableFields, + }; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx index a3e5da5e2d039..cef03cc0d0c76 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx @@ -18,8 +18,11 @@ import { import { i18n } from '@kbn/i18n'; +import { XJsonMode } from '../../../../../../../shared_imports'; + +const xJsonMode = new XJsonMode(); + import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; -import { xJsonMode } from '../../../../../components/custom_hooks'; export const CreateAnalyticsAdvancedEditor: FC<CreateAnalyticsFormProps> = ({ actions, state }) => { const { setAdvancedEditorRawString, setFormState } = actions; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index ded6e50947035..d55eb14a20e29 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -11,7 +11,7 @@ import numeral from '@elastic/numeral'; import { isEmpty } from 'lodash'; import { isValidIndexName } from '../../../../../../../common/util/es_utils'; -import { collapseLiteralStrings } from '../../../../../../../../../../src/plugins/es_ui_shared/console_lang/lib/json_xjson_translation_tools'; +import { collapseLiteralStrings } from '../../../../../../../../../../src/plugins/es_ui_shared/public'; import { Action, ACTION } from './actions'; import { getInitialState, getJobConfigFromFormState, State } from './state'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/file_datavisualizer_view.js b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/file_datavisualizer_view.js index d1b615a878b2b..c73ab4b9e11c7 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/file_datavisualizer_view.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/file_datavisualizer_view.js @@ -24,14 +24,11 @@ import { readFile, createUrlOverrides, processResults, - reduceData, hasImportPermission, } from '../utils'; import { MODE } from './constants'; -const UPLOAD_SIZE_MB = 5; - export class FileDataVisualizerView extends Component { constructor(props) { super(props); @@ -40,6 +37,7 @@ export class FileDataVisualizerView extends Component { files: {}, fileName: '', fileContents: '', + data: [], fileSize: 0, fileTooLarge: false, fileCouldNotBeRead: false, @@ -79,6 +77,7 @@ export class FileDataVisualizerView extends Component { loaded: false, fileName: '', fileContents: '', + data: [], fileSize: 0, fileTooLarge: false, fileCouldNotBeRead: false, @@ -97,15 +96,15 @@ export class FileDataVisualizerView extends Component { async loadFile(file) { if (file.size <= this.maxFileUploadBytes) { try { - const fileContents = await readFile(file); - const data = fileContents.data; + const { data, fileContents } = await readFile(file); this.setState({ - fileContents: data, + data, + fileContents, fileName: file.name, fileSize: file.size, }); - await this.loadSettings(data); + await this.analyzeFile(fileContents); } catch (error) { this.setState({ loaded: false, @@ -124,14 +123,9 @@ export class FileDataVisualizerView extends Component { } } - async loadSettings(data, overrides, isRetry = false) { + async analyzeFile(fileContents, overrides, isRetry = false) { try { - // reduce the amount of data being sent to the endpoint - // 5MB should be enough to contain 1000 lines - const lessData = reduceData(data, UPLOAD_SIZE_MB); - console.log('overrides', overrides); - const { analyzeFile } = ml.fileDatavisualizer; - const resp = await analyzeFile(lessData, overrides); + const resp = await ml.fileDatavisualizer.analyzeFile(fileContents, overrides); const serverSettings = processResults(resp); const serverOverrides = resp.overrides; @@ -198,7 +192,7 @@ export class FileDataVisualizerView extends Component { loading: true, loaded: false, }); - this.loadSettings(data, this.previousOverrides, true); + this.analyzeFile(fileContents, this.previousOverrides, true); } } } @@ -240,7 +234,7 @@ export class FileDataVisualizerView extends Component { }, () => { const formattedOverrides = createUrlOverrides(overrides, this.originalSettings); - this.loadSettings(this.state.fileContents, formattedOverrides); + this.analyzeFile(this.state.fileContents, formattedOverrides); } ); }; @@ -261,6 +255,7 @@ export class FileDataVisualizerView extends Component { results, explanation, fileContents, + data, fileName, fileSize, fileTooLarge, @@ -339,6 +334,7 @@ export class FileDataVisualizerView extends Component { results={results} fileName={fileName} fileContents={fileContents} + data={data} indexPatterns={this.props.indexPatterns} kibanaConfig={this.props.kibanaConfig} showBottomBar={this.showBottomBar} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js index 4c9579bfd4b46..2bf7bbeb641d0 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js @@ -94,7 +94,7 @@ export class ImportView extends Component { // TODO - sort this function out. it's a mess async import() { - const { fileContents, results, indexPatterns, kibanaConfig, showBottomBar } = this.props; + const { data, results, indexPatterns, kibanaConfig, showBottomBar } = this.props; const { format } = results; let { timeFieldName } = this.state; @@ -217,7 +217,7 @@ export class ImportView extends Component { if (success) { const importer = importerFactory(format, results, indexCreationSettings); if (importer !== undefined) { - const readResp = importer.read(fileContents, this.setReadProgress); + const readResp = importer.read(data, this.setReadProgress); success = readResp.success; this.setState({ readStatus: success ? IMPORT_STATUS.COMPLETE : IMPORT_STATUS.FAILED, diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/importer/importer.ts b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/importer/importer.ts index c97f1c147c454..718587ad15ad5 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/importer/importer.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/importer/importer.ts @@ -9,7 +9,7 @@ import moment from 'moment'; import { i18n } from '@kbn/i18n'; import { ml } from '../../../../../services/ml_api_service'; import { - Doc, + ImportDoc, ImportFailure, ImportResponse, Mappings, @@ -20,6 +20,7 @@ import { const CHUNK_SIZE = 5000; const MAX_CHUNK_CHAR_COUNT = 1000000; const IMPORT_RETRIES = 5; +const STRING_CHUNKS_MB = 100; export interface ImportConfig { settings: Settings; @@ -34,12 +35,19 @@ export interface ImportResults { error?: any; } -export class Importer { +export interface CreateDocsResponse { + success: boolean; + remainder: number; + docs: ImportDoc[]; + error?: any; +} + +export abstract class Importer { private _settings: Settings; private _mappings: Mappings; private _pipeline: IngestPipeline; - protected _docArray: Doc[] = []; + protected _docArray: ImportDoc[] = []; constructor({ settings, mappings, pipeline }: ImportConfig) { this._settings = settings; @@ -47,7 +55,33 @@ export class Importer { this._pipeline = pipeline; } - async initializeImport(index: string) { + public read(data: ArrayBuffer) { + const decoder = new TextDecoder(); + const size = STRING_CHUNKS_MB * Math.pow(2, 20); + + // chop the data up into 100MB chunks for processing. + // if the chop produces a partial line at the end, a character "remainder" count + // is returned which is used to roll the next chunk back that many chars so + // it is included in the next chunk. + const parts = Math.ceil(data.byteLength / size); + let remainder = 0; + for (let i = 0; i < parts; i++) { + const byteArray = decoder.decode(data.slice(i * size - remainder, (i + 1) * size)); + const { success, docs, remainder: tempRemainder } = this._createDocs(byteArray); + if (success) { + this._docArray = this._docArray.concat(docs); + remainder = tempRemainder; + } else { + return { success: false }; + } + } + + return { success: true }; + } + + protected abstract _createDocs(t: string): CreateDocsResponse; + + public async initializeImport(index: string) { const settings = this._settings; const mappings = this._mappings; const pipeline = this._pipeline; @@ -75,7 +109,7 @@ export class Importer { return createIndexResp; } - async import( + public async import( id: string, index: string, pipelineId: string, @@ -201,8 +235,8 @@ function updatePipelineTimezone(ingestPipeline: IngestPipeline) { } } -function createDocumentChunks(docArray: Doc[]) { - const chunks: Doc[][] = []; +function createDocumentChunks(docArray: ImportDoc[]) { + const chunks: ImportDoc[][] = []; // chop docArray into 5000 doc chunks const tempChunks = chunk(docArray, CHUNK_SIZE); diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/importer/message_importer.ts b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/importer/message_importer.ts index 7ccc5a8d673f4..65be24d9e7be4 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/importer/message_importer.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/importer/message_importer.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Importer, ImportConfig } from './importer'; +import { Importer, ImportConfig, CreateDocsResponse } from './importer'; import { Doc, FindFileStructureResponse, @@ -33,54 +33,54 @@ export class MessageImporter extends Importer { // multiline_start_pattern regex // if it does, it is a legitimate end of line and can be pushed into the list, // if not, it must be a newline char inside a field value, so keep looking. - read(text: string) { + protected _createDocs(text: string): CreateDocsResponse { + let remainder = 0; try { - const data: Doc[] = []; + const docs: Doc[] = []; let message = ''; let line = ''; for (let i = 0; i < text.length; i++) { const char = text[i]; if (char === '\n') { - message = this.processLine(data, message, line); + message = this._processLine(docs, message, line); line = ''; } else { line += char; } } - // the last line may have been missing a newline ending - if (line !== '') { - message = this.processLine(data, message, line); - } + remainder = line.length; - // add the last message to the list if not already done + // // add the last message to the list if not already done if (message !== '') { - this.addMessage(data, message); + this._addMessage(docs, message); } // remove first line if it is blank - if (data[0] && data[0].message === '') { - data.shift(); + if (docs[0] && docs[0].message === '') { + docs.shift(); } - this._docArray = data; - return { success: true, + docs, + remainder, }; } catch (error) { return { success: false, + docs: [], + remainder, error, }; } } - processLine(data: Doc[], message: string, line: string) { + private _processLine(data: Doc[], message: string, line: string) { if (this._excludeLinesRegex === null || line.match(this._excludeLinesRegex) === null) { if (this._multilineStartRegex === null || line.match(this._multilineStartRegex) !== null) { - this.addMessage(data, message); + this._addMessage(data, message); message = ''; } else if (data.length === 0) { // discard everything before the first line that is considered the first line of a message @@ -95,7 +95,7 @@ export class MessageImporter extends Importer { return message; } - addMessage(data: Doc[], message: string) { + private _addMessage(data: Doc[], message: string) { // if the message ended \r\n (Windows line endings) // then omit the \r as well as the \n for consistency message = message.replace(/\r$/, ''); diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/importer/ndjson_importer.ts b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/importer/ndjson_importer.ts index 7f5f37abc5246..17c9de8ef4558 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/importer/ndjson_importer.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/importer/ndjson_importer.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Importer, ImportConfig } from './importer'; +import { Importer, ImportConfig, CreateDocsResponse } from './importer'; import { FindFileStructureResponse } from '../../../../../../../common/types/file_datavisualizer'; export class NdjsonImporter extends Importer { @@ -12,27 +12,42 @@ export class NdjsonImporter extends Importer { super(settings); } - read(json: string) { + protected _createDocs(json: string): CreateDocsResponse { + let remainder = 0; try { const splitJson = json.split(/}\s*\n/); + const incompleteLastLine = json.match(/}\s*\n?$/) === null; - const ndjson: any[] = []; - for (let i = 0; i < splitJson.length; i++) { - if (splitJson[i] !== '') { - // note the extra } at the end of the line, adding back - // the one that was eaten in the split - ndjson.push(`${splitJson[i]}}`); + const docs: string[] = []; + if (splitJson.length) { + for (let i = 0; i < splitJson.length - 1; i++) { + if (splitJson[i] !== '') { + // note the extra } at the end of the line, adding back + // the one that was eaten in the split + docs.push(`${splitJson[i]}}`); + } } - } - this._docArray = ndjson; + const lastDoc = splitJson[splitJson.length - 1]; + if (lastDoc) { + if (incompleteLastLine === true) { + remainder = lastDoc.length; + } else { + docs.push(`${lastDoc}}`); + } + } + } return { success: true, + docs, + remainder, }; } catch (error) { return { success: false, + docs: [], + remainder, error, }; } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/index.ts b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/index.ts index 0f0036a7c4616..492a797f7a2f2 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/index.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/index.ts @@ -9,7 +9,6 @@ export { hasImportPermission, processResults, readFile, - reduceData, getMaxBytes, getMaxBytesFormatted, } from './utils'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.ts b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.ts index 0d2016b71ed83..7b6464570e55c 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.ts @@ -9,12 +9,14 @@ import numeral from '@elastic/numeral'; import { ml } from '../../../../services/ml_api_service'; import { AnalysisResult, InputOverrides } from '../../../../../../common/types/file_datavisualizer'; import { - ABSOLUTE_MAX_BYTES, + MAX_FILE_SIZE_BYTES, + ABSOLUTE_MAX_FILE_SIZE_BYTES, FILE_SIZE_DISPLAY_FORMAT, } from '../../../../../../common/constants/file_datavisualizer'; import { getMlConfig } from '../../../../util/dependency_cache'; const DEFAULT_LINES_TO_SAMPLE = 1000; +const UPLOAD_SIZE_MB = 5; const overrideDefaults = { timestampFormat: undefined, @@ -34,15 +36,22 @@ export function readFile(file: File) { return new Promise((resolve, reject) => { if (file && file.size) { const reader = new FileReader(); - reader.readAsText(file); + reader.readAsArrayBuffer(file); reader.onload = (() => { return () => { + const decoder = new TextDecoder(); const data = reader.result; - if (data === '') { + if (data === null || typeof data === 'string') { + return reject(); + } + const size = UPLOAD_SIZE_MB * Math.pow(2, 20); + const fileContents = decoder.decode(data.slice(0, size)); + + if (fileContents === '') { reject(); } else { - resolve({ data }); + resolve({ fileContents, data }); } }; })(); @@ -52,17 +61,14 @@ export function readFile(file: File) { }); } -export function reduceData(data: string, mb: number) { - // assuming ascii characters in the file where 1 char is 1 byte - // TODO - change this when other non UTF-8 formats are - // supported for the read data - const size = mb * Math.pow(2, 20); - return data.length >= size ? data.slice(0, size) : data; -} - export function getMaxBytes() { - const maxBytes = getMlConfig().file_data_visualizer.max_file_size_bytes; - return maxBytes < ABSOLUTE_MAX_BYTES ? maxBytes : ABSOLUTE_MAX_BYTES; + const maxFileSize = getMlConfig().file_data_visualizer.max_file_size; + // @ts-ignore + const maxBytes = numeral(maxFileSize.toUpperCase()).value(); + if (maxBytes < MAX_FILE_SIZE_BYTES) { + return MAX_FILE_SIZE_BYTES; + } + return maxBytes < ABSOLUTE_MAX_FILE_SIZE_BYTES ? maxBytes : ABSOLUTE_MAX_FILE_SIZE_BYTES; } export function getMaxBytesFormatted() { diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/ml_job_editor/ml_job_editor.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/ml_job_editor/ml_job_editor.tsx index 0633c62f754e0..c10212ee31564 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/ml_job_editor/ml_job_editor.tsx +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/ml_job_editor/ml_job_editor.tsx @@ -7,10 +7,9 @@ import React, { FC } from 'react'; import { EuiCodeEditor, EuiCodeEditorProps } from '@elastic/eui'; -import { expandLiteralStrings } from '../../../../../../shared_imports'; -import { xJsonMode } from '../../../../components/custom_hooks'; +import { expandLiteralStrings, XJsonMode } from '../../../../../../shared_imports'; -export const ML_EDITOR_MODE = { TEXT: 'text', JSON: 'json', XJSON: xJsonMode }; +export const ML_EDITOR_MODE = { TEXT: 'text', JSON: 'json', XJSON: new XJsonMode() }; interface MlJobEditorProps { value: string; diff --git a/x-pack/plugins/ml/public/application/util/__tests__/calc_auto_interval.js b/x-pack/plugins/ml/public/application/util/__tests__/calc_auto_interval.js deleted file mode 100644 index 0553cec5cd7d4..0000000000000 --- a/x-pack/plugins/ml/public/application/util/__tests__/calc_auto_interval.js +++ /dev/null @@ -1,140 +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 expect from '@kbn/expect'; -import moment from 'moment'; - -import { timeBucketsCalcAutoIntervalProvider } from '../calc_auto_interval'; - -describe('ML - calc auto intervals', () => { - const calcAuto = timeBucketsCalcAutoIntervalProvider(); - - describe('near interval', () => { - it('returns 0ms buckets for undefined / 0 bars', () => { - const interval = calcAuto.near(0, undefined); - expect(interval.asMilliseconds()).to.be(0); - }); - - it('returns 1000ms buckets for 60s / 100 bars', () => { - const interval = calcAuto.near(100, moment.duration(60, 's')); - expect(interval.asMilliseconds()).to.be(1000); - }); - - it('returns 5m buckets for 8h / 100 bars', () => { - const interval = calcAuto.near(100, moment.duration(8, 'h')); - expect(interval.asMinutes()).to.be(5); - }); - - it('returns 15m buckets for 1d / 100 bars', () => { - const interval = calcAuto.near(100, moment.duration(1, 'd')); - expect(interval.asMinutes()).to.be(15); - }); - - it('returns 1h buckets for 20d / 500 bars', () => { - const interval = calcAuto.near(500, moment.duration(20, 'd')); - expect(interval.asHours()).to.be(1); - }); - - it('returns 6h buckets for 100d / 500 bars', () => { - const interval = calcAuto.near(500, moment.duration(100, 'd')); - expect(interval.asHours()).to.be(6); - }); - - it('returns 24h buckets for 1y / 500 bars', () => { - const interval = calcAuto.near(500, moment.duration(1, 'y')); - expect(interval.asHours()).to.be(24); - }); - - it('returns 12h buckets for 1y / 1000 bars', () => { - const interval = calcAuto.near(1000, moment.duration(1, 'y')); - expect(interval.asHours()).to.be(12); - }); - }); - - describe('lessThan interval', () => { - it('returns 0ms buckets for undefined / 0 bars', () => { - const interval = calcAuto.lessThan(0, undefined); - expect(interval.asMilliseconds()).to.be(0); - }); - - it('returns 500ms buckets for 60s / 100 bars', () => { - const interval = calcAuto.lessThan(100, moment.duration(60, 's')); - expect(interval.asMilliseconds()).to.be(500); - }); - - it('returns 5m buckets for 8h / 100 bars', () => { - const interval = calcAuto.lessThan(100, moment.duration(8, 'h')); - expect(interval.asMinutes()).to.be(5); - }); - - it('returns 30m buckets for 1d / 100 bars', () => { - const interval = calcAuto.lessThan(100, moment.duration(1, 'd')); - expect(interval.asMinutes()).to.be(30); - }); - - it('returns 1h buckets for 20d / 500 bars', () => { - const interval = calcAuto.lessThan(500, moment.duration(20, 'd')); - expect(interval.asHours()).to.be(1); - }); - - it('returns 6h buckets for 100d / 500 bars', () => { - const interval = calcAuto.lessThan(500, moment.duration(100, 'd')); - expect(interval.asHours()).to.be(6); - }); - - it('returns 24h buckets for 1y / 500 bars', () => { - const interval = calcAuto.lessThan(500, moment.duration(1, 'y')); - expect(interval.asHours()).to.be(24); - }); - - it('returns 12h buckets for 1y / 1000 bars', () => { - const interval = calcAuto.lessThan(1000, moment.duration(1, 'y')); - expect(interval.asHours()).to.be(12); - }); - }); - - describe('atLeast interval', () => { - it('returns 0ms buckets for undefined / 0 bars', () => { - const interval = calcAuto.atLeast(0, undefined); - expect(interval.asMilliseconds()).to.be(0); - }); - - it('returns 100ms buckets for 60s / 100 bars', () => { - const interval = calcAuto.atLeast(100, moment.duration(60, 's')); - expect(interval.asMilliseconds()).to.be(100); - }); - - it('returns 1m buckets for 8h / 100 bars', () => { - const interval = calcAuto.atLeast(100, moment.duration(8, 'h')); - expect(interval.asMinutes()).to.be(1); - }); - - it('returns 10m buckets for 1d / 100 bars', () => { - const interval = calcAuto.atLeast(100, moment.duration(1, 'd')); - expect(interval.asMinutes()).to.be(10); - }); - - it('returns 30m buckets for 20d / 500 bars', () => { - const interval = calcAuto.atLeast(500, moment.duration(20, 'd')); - expect(interval.asMinutes()).to.be(30); - }); - - it('returns 4h buckets for 100d / 500 bars', () => { - const interval = calcAuto.atLeast(500, moment.duration(100, 'd')); - expect(interval.asHours()).to.be(4); - }); - - it('returns 12h buckets for 1y / 500 bars', () => { - const interval = calcAuto.atLeast(500, moment.duration(1, 'y')); - expect(interval.asHours()).to.be(12); - }); - - it('returns 8h buckets for 1y / 1000 bars', () => { - const interval = calcAuto.atLeast(1000, moment.duration(1, 'y')); - expect(interval.asHours()).to.be(8); - }); - }); -}); diff --git a/x-pack/plugins/ml/public/application/util/__tests__/chart_utils.js b/x-pack/plugins/ml/public/application/util/__tests__/chart_utils.js deleted file mode 100644 index 89df5946abe76..0000000000000 --- a/x-pack/plugins/ml/public/application/util/__tests__/chart_utils.js +++ /dev/null @@ -1,297 +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 $ from 'jquery'; -import d3 from 'd3'; -import expect from '@kbn/expect'; -import { - chartLimits, - filterAxisLabels, - getChartType, - numTicks, - showMultiBucketAnomalyMarker, - showMultiBucketAnomalyTooltip, -} from '../chart_utils'; -import { MULTI_BUCKET_IMPACT } from '../../../../common/constants/multi_bucket_impact'; -import { CHART_TYPE } from '../../explorer/explorer_constants'; - -describe('ML - chart utils', () => { - describe('chartLimits', () => { - it('returns NaN when called without data', () => { - const limits = chartLimits(); - expect(limits.min).to.be.NaN; - expect(limits.max).to.be.NaN; - }); - - it('returns {max: 625736376, min: 201039318} for some test data', () => { - const data = [ - { - date: new Date('2017-02-23T08:00:00.000Z'), - value: 228243469, - anomalyScore: 63.32916, - numberOfCauses: 1, - actual: [228243469], - typical: [133107.7703441773], - }, - { date: new Date('2017-02-23T09:00:00.000Z'), value: null }, - { date: new Date('2017-02-23T10:00:00.000Z'), value: null }, - { date: new Date('2017-02-23T11:00:00.000Z'), value: null }, - { - date: new Date('2017-02-23T12:00:00.000Z'), - value: 625736376, - anomalyScore: 97.32085, - numberOfCauses: 1, - actual: [625736376], - typical: [132830.424736973], - }, - { - date: new Date('2017-02-23T13:00:00.000Z'), - value: 201039318, - anomalyScore: 59.83488, - numberOfCauses: 1, - actual: [201039318], - typical: [132739.5267403542], - }, - ]; - - const limits = chartLimits(data); - - // {max: 625736376, min: 201039318} - expect(limits.min).to.be(201039318); - expect(limits.max).to.be(625736376); - }); - - it("adds 5% padding when min/max are the same, e.g. when there's only one data point", () => { - const data = [ - { - date: new Date('2017-02-23T08:00:00.000Z'), - value: 100, - anomalyScore: 50, - numberOfCauses: 1, - actual: [100], - typical: [100], - }, - ]; - - const limits = chartLimits(data); - expect(limits.min).to.be(95); - expect(limits.max).to.be(105); - }); - - it('returns minimum of 0 when data includes an anomaly for missing data', () => { - const data = [ - { date: new Date('2017-02-23T09:00:00.000Z'), value: 22.2 }, - { date: new Date('2017-02-23T10:00:00.000Z'), value: 23.3 }, - { date: new Date('2017-02-23T11:00:00.000Z'), value: 24.4 }, - { - date: new Date('2017-02-23T12:00:00.000Z'), - value: null, - anomalyScore: 97.32085, - actual: [0], - typical: [22.2], - }, - { date: new Date('2017-02-23T13:00:00.000Z'), value: 21.3 }, - { date: new Date('2017-02-23T14:00:00.000Z'), value: 21.2 }, - { date: new Date('2017-02-23T15:00:00.000Z'), value: 21.1 }, - ]; - - const limits = chartLimits(data); - expect(limits.min).to.be(0); - expect(limits.max).to.be(24.4); - }); - }); - - describe('filterAxisLabels', () => { - it('throws an error when called without arguments', () => { - expect(() => filterAxisLabels()).to.throwError(); - }); - - it('filters axis labels', () => { - // this provides a dummy structure of axis labels. - // the first one should always be filtered because it overflows on the - // left side of the axis. the last one should be filtered based on the - // given width parameter when doing the test calls. - $('body').append(` - <svg id="filterAxisLabels"> - <g class="x axis"> - <g class="tick" transform="translate(5,0)"> - <text dy=".71em" y="10" x="0" style="text-anchor: middle;">06:00</text> - </g> - <g class="tick" transform="translate(187.24137931034485,0)"> - <text dy=".71em" y="10" x="0" style="text-anchor: middle;">12:00</text> - </g> - <g class="tick" transform="translate(486.82758620689657,0)"> - <text dy=".71em" y="10" x="0" style="text-anchor: middle;">18:00</text> - </g> - <g class="tick" transform="translate(786.4137931034483,0)"> - <text dy=".71em" y="10" x="0" style="text-anchor: middle;">00:00</text> - </g> - </g> - </svg> - `); - - const selector = '#filterAxisLabels .x.axis'; - - // given this width, the last tick should not be removed - filterAxisLabels(d3.selectAll(selector), 1000); - expect(d3.selectAll(selector + ' .tick text').size()).to.be(3); - - // given this width, the last tick should be removed - filterAxisLabels(d3.selectAll(selector), 790); - expect(d3.selectAll(selector + ' .tick text').size()).to.be(2); - - // clean up - $('#filterAxisLabels').remove(); - }); - }); - - describe('getChartType', () => { - const singleMetricConfig = { - metricFunction: 'avg', - functionDescription: 'mean', - fieldName: 'responsetime', - entityFields: [], - }; - - const multiMetricConfig = { - metricFunction: 'avg', - functionDescription: 'mean', - fieldName: 'responsetime', - entityFields: [ - { - fieldName: 'airline', - fieldValue: 'AAL', - fieldType: 'partition', - }, - ], - }; - - const populationConfig = { - metricFunction: 'avg', - functionDescription: 'mean', - fieldName: 'http.response.body.bytes', - entityFields: [ - { - fieldName: 'source.ip', - fieldValue: '10.11.12.13', - fieldType: 'over', - }, - ], - }; - - const rareConfig = { - metricFunction: 'count', - functionDescription: 'rare', - entityFields: [ - { - fieldName: 'http.response.status_code', - fieldValue: '404', - fieldType: 'by', - }, - ], - }; - - const varpModelPlotConfig = { - metricFunction: null, - functionDescription: 'varp', - fieldName: 'NetworkOut', - entityFields: [ - { - fieldName: 'instance', - fieldValue: 'i-ef74d410', - fieldType: 'over', - }, - ], - }; - - const overScriptFieldModelPlotConfig = { - metricFunction: 'count', - functionDescription: 'count', - fieldName: 'highest_registered_domain', - entityFields: [ - { - fieldName: 'highest_registered_domain', - fieldValue: 'elastic.co', - fieldType: 'over', - }, - ], - datafeedConfig: { - script_fields: { - highest_registered_domain: { - script: { - source: "return domainSplit(doc['query'].value, params).get(1);", - lang: 'painless', - }, - ignore_failure: false, - }, - }, - }, - }; - - it('returns single metric chart type as expected for configs', () => { - expect(getChartType(singleMetricConfig)).to.be(CHART_TYPE.SINGLE_METRIC); - expect(getChartType(multiMetricConfig)).to.be(CHART_TYPE.SINGLE_METRIC); - expect(getChartType(varpModelPlotConfig)).to.be(CHART_TYPE.SINGLE_METRIC); - expect(getChartType(overScriptFieldModelPlotConfig)).to.be(CHART_TYPE.SINGLE_METRIC); - }); - - it('returns event distribution chart type as expected for configs', () => { - expect(getChartType(rareConfig)).to.be(CHART_TYPE.EVENT_DISTRIBUTION); - }); - - it('returns population distribution chart type as expected for configs', () => { - expect(getChartType(populationConfig)).to.be(CHART_TYPE.POPULATION_DISTRIBUTION); - }); - }); - - describe('numTicks', () => { - it('returns 10 for 1000', () => { - expect(numTicks(1000)).to.be(10); - }); - }); - - describe('showMultiBucketAnomalyMarker', () => { - it('returns true for points with multiBucketImpact at or above medium impact', () => { - expect(showMultiBucketAnomalyMarker({ multiBucketImpact: MULTI_BUCKET_IMPACT.HIGH })).to.be( - true - ); - expect(showMultiBucketAnomalyMarker({ multiBucketImpact: MULTI_BUCKET_IMPACT.MEDIUM })).to.be( - true - ); - }); - - it('returns false for points with multiBucketImpact missing or below medium impact', () => { - expect(showMultiBucketAnomalyMarker({})).to.be(false); - expect(showMultiBucketAnomalyMarker({ multiBucketImpact: MULTI_BUCKET_IMPACT.LOW })).to.be( - false - ); - expect(showMultiBucketAnomalyMarker({ multiBucketImpact: MULTI_BUCKET_IMPACT.NONE })).to.be( - false - ); - }); - }); - - describe('showMultiBucketAnomalyTooltip', () => { - it('returns true for points with multiBucketImpact at or above low impact', () => { - expect(showMultiBucketAnomalyTooltip({ multiBucketImpact: MULTI_BUCKET_IMPACT.HIGH })).to.be( - true - ); - expect( - showMultiBucketAnomalyTooltip({ multiBucketImpact: MULTI_BUCKET_IMPACT.MEDIUM }) - ).to.be(true); - expect(showMultiBucketAnomalyTooltip({ multiBucketImpact: MULTI_BUCKET_IMPACT.LOW })).to.be( - true - ); - }); - - it('returns false for points with multiBucketImpact missing or below medium impact', () => { - expect(showMultiBucketAnomalyTooltip({})).to.be(false); - expect(showMultiBucketAnomalyTooltip({ multiBucketImpact: MULTI_BUCKET_IMPACT.NONE })).to.be( - false - ); - }); - }); -}); diff --git a/x-pack/plugins/ml/public/application/util/__tests__/string_utils.js b/x-pack/plugins/ml/public/application/util/__tests__/string_utils.js deleted file mode 100644 index 702e9dfd96205..0000000000000 --- a/x-pack/plugins/ml/public/application/util/__tests__/string_utils.js +++ /dev/null @@ -1,229 +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 expect from '@kbn/expect'; -import { - replaceStringTokens, - detectorToString, - sortByKey, - guessTimeFormat, - toLocaleString, - mlEscape, - escapeForElasticsearchQuery, -} from '../string_utils'; - -describe('ML - string utils', () => { - describe('replaceStringTokens', () => { - const testRecord = { - job_id: 'test_job', - result_type: 'record', - probability: 0.0191711, - record_score: 4.3, - bucket_span: 300, - detector_index: 0, - timestamp: 1454890500000, - function: 'mean', - function_description: 'mean', - field_name: 'responsetime', - user: "Des O'Connor", - testfield1: 'test$tring=[+-?]', - testfield2: '{<()>}', - testfield3: 'host=\\\\test@uk.dev', - }; - - it('returns correct values without URI encoding', () => { - const result = replaceStringTokens('user=$user$,time=$timestamp$', testRecord, false); - expect(result).to.be("user=Des O'Connor,time=1454890500000"); - }); - - it('returns correct values for missing token without URI encoding', () => { - const result = replaceStringTokens('user=$username$,time=$timestamp$', testRecord, false); - expect(result).to.be('user=$username$,time=1454890500000'); - }); - - it('returns correct values with URI encoding', () => { - const testString1 = 'https://www.google.co.uk/webhp#q=$testfield1$'; - const testString2 = 'https://www.google.co.uk/webhp#q=$testfield2$'; - const testString3 = 'https://www.google.co.uk/webhp#q=$testfield3$'; - const testString4 = 'https://www.google.co.uk/webhp#q=$user$'; - - const result1 = replaceStringTokens(testString1, testRecord, true); - const result2 = replaceStringTokens(testString2, testRecord, true); - const result3 = replaceStringTokens(testString3, testRecord, true); - const result4 = replaceStringTokens(testString4, testRecord, true); - - expect(result1).to.be('https://www.google.co.uk/webhp#q=test%24tring%3D%5B%2B-%3F%5D'); - expect(result2).to.be('https://www.google.co.uk/webhp#q=%7B%3C()%3E%7D'); - expect(result3).to.be('https://www.google.co.uk/webhp#q=host%3D%5C%5Ctest%40uk.dev'); - expect(result4).to.be("https://www.google.co.uk/webhp#q=Des%20O'Connor"); - }); - - it('returns correct values for missing token with URI encoding', () => { - const testString = 'https://www.google.co.uk/webhp#q=$username$&time=$timestamp$'; - const result = replaceStringTokens(testString, testRecord, true); - expect(result).to.be('https://www.google.co.uk/webhp#q=$username$&time=1454890500000'); - }); - }); - - describe('detectorToString', () => { - it('returns the correct descriptions for detectors', () => { - const detector1 = { - function: 'count', - }; - - const detector2 = { - function: 'count', - by_field_name: 'airline', - use_null: false, - }; - - const detector3 = { - function: 'mean', - field_name: 'CPUUtilization', - partition_field_name: 'region', - by_field_name: 'host', - over_field_name: 'user', - exclude_frequent: 'all', - }; - - expect(detectorToString(detector1)).to.be('count'); - expect(detectorToString(detector2)).to.be('count by airline use_null=false'); - expect(detectorToString(detector3)).to.be( - 'mean(CPUUtilization) by host over user partition_field_name=region exclude_frequent=all' - ); - }); - }); - - describe('sortByKey', () => { - const obj = { - zebra: 'stripes', - giraffe: 'neck', - elephant: 'trunk', - }; - - const valueComparator = function(value) { - return value; - }; - - it('returns correct ordering with default comparator', () => { - const result = sortByKey(obj, false); - const keys = Object.keys(result); - expect(keys[0]).to.be('elephant'); - expect(keys[1]).to.be('giraffe'); - expect(keys[2]).to.be('zebra'); - }); - - it('returns correct ordering with default comparator and order reversed', () => { - const result = sortByKey(obj, true); - const keys = Object.keys(result); - expect(keys[0]).to.be('zebra'); - expect(keys[1]).to.be('giraffe'); - expect(keys[2]).to.be('elephant'); - }); - - it('returns correct ordering with comparator', () => { - const result = sortByKey(obj, false, valueComparator); - const keys = Object.keys(result); - expect(keys[0]).to.be('giraffe'); - expect(keys[1]).to.be('zebra'); - expect(keys[2]).to.be('elephant'); - }); - - it('returns correct ordering with comparator and order reversed', () => { - const result = sortByKey(obj, true, valueComparator); - const keys = Object.keys(result); - expect(keys[0]).to.be('elephant'); - expect(keys[1]).to.be('zebra'); - expect(keys[2]).to.be('giraffe'); - }); - }); - - describe('guessTimeFormat', () => { - it('returns correct format for various dates', () => { - expect(guessTimeFormat('2017-03-24T00:00')).to.be("yyyy-MM-dd'T'HH:mm"); - expect(guessTimeFormat('2017-03-24 00:00')).to.be('yyyy-MM-dd HH:mm'); - expect(guessTimeFormat('2017-03-24 00:00:00')).to.be('yyyy-MM-dd HH:mm:ss'); - expect(guessTimeFormat('2017-03-24 00:00:00Z')).to.be('yyyy-MM-dd HH:mm:ssX'); - expect(guessTimeFormat('2017-03-24 00:00:00.000')).to.be('yyyy-MM-dd HH:mm:ss.SSS'); - expect(guessTimeFormat('2017-03-24 00:00:00:000')).to.be('yyyy-MM-dd HH:mm:ss:SSS'); - expect(guessTimeFormat('2017-03-24 00:00:00.000+00:00:00')).to.be( - 'yyyy-MM-dd HH:mm:ss.SSSXXXXX' - ); - expect(guessTimeFormat('2017-03-24 00:00:00.000+00:00')).to.be('yyyy-MM-dd HH:mm:ss.SSSXXX'); - expect(guessTimeFormat('2017-03-24 00:00:00.000+000000')).to.be( - 'yyyy-MM-dd HH:mm:ss.SSSXXXX' - ); - expect(guessTimeFormat('2017-03-24 00:00:00.000+0000')).to.be('yyyy-MM-dd HH:mm:ss.SSSZ'); - expect(guessTimeFormat('2017-03-24 00:00:00.000+00')).to.be('yyyy-MM-dd HH:mm:ss.SSSX'); - expect(guessTimeFormat('2017-03-24 00:00:00.000Z')).to.be('yyyy-MM-dd HH:mm:ss.SSSX'); - expect(guessTimeFormat('2017-03-24 00:00:00.000 GMT')).to.be('yyyy-MM-dd HH:mm:ss.SSS zzz'); - expect(guessTimeFormat('2017-03-24 00:00:00 GMT')).to.be('yyyy-MM-dd HH:mm:ss zzz'); - expect(guessTimeFormat('2017 03 24 00:00:00.000')).to.be('yyyy MM dd HH:mm:ss.SSS'); - expect(guessTimeFormat('2017.03.24 00:00:00.000')).to.be('yyyy.MM.dd HH:mm:ss.SSS'); - expect(guessTimeFormat('2017/03/24 00:00:00.000')).to.be('yyyy/MM/dd HH:mm:ss.SSS'); - expect(guessTimeFormat('24/03/2017 00:00:00.000')).to.be('dd/MM/yyyy HH:mm:ss.SSS'); - expect(guessTimeFormat('03 24 2017 00:00:00.000')).to.be('MM dd yyyy HH:mm:ss.SSS'); - expect(guessTimeFormat('03/24/2017 00:00:00.000')).to.be('MM/dd/yyyy HH:mm:ss.SSS'); - expect(guessTimeFormat('2017 Mar 24 00:00:00.000')).to.be('yyyy MMM dd HH:mm:ss.SSS'); - expect(guessTimeFormat('Mar 24 2017 00:00:00.000')).to.be('MMM dd yyyy HH:mm:ss.SSS'); - expect(guessTimeFormat('24 Mar 2017 00:00:00.000')).to.be('dd MMM yyyy HH:mm:ss.SSS'); - expect(guessTimeFormat('1490313600')).to.be('epoch'); - expect(guessTimeFormat('1490313600000')).to.be('epoch_ms'); - }); - }); - - describe('toLocaleString', () => { - it('returns correct comma placement for large numbers', () => { - expect(toLocaleString(1)).to.be('1'); - expect(toLocaleString(10)).to.be('10'); - expect(toLocaleString(100)).to.be('100'); - expect(toLocaleString(1000)).to.be('1,000'); - expect(toLocaleString(10000)).to.be('10,000'); - expect(toLocaleString(100000)).to.be('100,000'); - expect(toLocaleString(1000000)).to.be('1,000,000'); - expect(toLocaleString(10000000)).to.be('10,000,000'); - expect(toLocaleString(100000000)).to.be('100,000,000'); - expect(toLocaleString(1000000000)).to.be('1,000,000,000'); - }); - }); - - describe('mlEscape', () => { - it('returns correct escaping of characters', () => { - expect(mlEscape('foo&bar')).to.be('foo&bar'); - expect(mlEscape('foo<bar')).to.be('foo<bar'); - expect(mlEscape('foo>bar')).to.be('foo>bar'); - expect(mlEscape('foo"bar')).to.be('foo"bar'); - expect(mlEscape("foo'bar")).to.be('foo'bar'); - expect(mlEscape('foo/bar')).to.be('foo/bar'); - }); - }); - - describe('escapeForElasticsearchQuery', () => { - it('returns correct escaping of reserved elasticsearch characters', () => { - expect(escapeForElasticsearchQuery('foo+bar')).to.be('foo\\+bar'); - expect(escapeForElasticsearchQuery('foo-bar')).to.be('foo\\-bar'); - expect(escapeForElasticsearchQuery('foo=bar')).to.be('foo\\=bar'); - expect(escapeForElasticsearchQuery('foo&&bar')).to.be('foo\\&\\&bar'); - expect(escapeForElasticsearchQuery('foo||bar')).to.be('foo\\|\\|bar'); - expect(escapeForElasticsearchQuery('foo>bar')).to.be('foo\\>bar'); - expect(escapeForElasticsearchQuery('foo<bar')).to.be('foo\\<bar'); - expect(escapeForElasticsearchQuery('foo!bar')).to.be('foo\\!bar'); - expect(escapeForElasticsearchQuery('foo(bar')).to.be('foo\\(bar'); - expect(escapeForElasticsearchQuery('foo)bar')).to.be('foo\\)bar'); - expect(escapeForElasticsearchQuery('foo{bar')).to.be('foo\\{bar'); - expect(escapeForElasticsearchQuery('foo[bar')).to.be('foo\\[bar'); - expect(escapeForElasticsearchQuery('foo]bar')).to.be('foo\\]bar'); - expect(escapeForElasticsearchQuery('foo^bar')).to.be('foo\\^bar'); - expect(escapeForElasticsearchQuery('foo"bar')).to.be('foo\\"bar'); - expect(escapeForElasticsearchQuery('foo~bar')).to.be('foo\\~bar'); - expect(escapeForElasticsearchQuery('foo*bar')).to.be('foo\\*bar'); - expect(escapeForElasticsearchQuery('foo?bar')).to.be('foo\\?bar'); - expect(escapeForElasticsearchQuery('foo:bar')).to.be('foo\\:bar'); - expect(escapeForElasticsearchQuery('foo\\bar')).to.be('foo\\\\bar'); - expect(escapeForElasticsearchQuery('foo/bar')).to.be('foo\\/bar'); - }); - }); -}); diff --git a/x-pack/plugins/ml/public/application/util/calc_auto_interval.test.js b/x-pack/plugins/ml/public/application/util/calc_auto_interval.test.js new file mode 100644 index 0000000000000..c36557a0e6951 --- /dev/null +++ b/x-pack/plugins/ml/public/application/util/calc_auto_interval.test.js @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; + +import { timeBucketsCalcAutoIntervalProvider } from './calc_auto_interval'; + +describe('ML - calc auto intervals', () => { + const calcAuto = timeBucketsCalcAutoIntervalProvider(); + + describe('near interval', () => { + test('returns 0ms buckets for undefined / 0 bars', () => { + const interval = calcAuto.near(0, undefined); + expect(interval.asMilliseconds()).toBe(0); + }); + + test('returns 1000ms buckets for 60s / 100 bars', () => { + const interval = calcAuto.near(100, moment.duration(60, 's')); + expect(interval.asMilliseconds()).toBe(1000); + }); + + test('returns 5m buckets for 8h / 100 bars', () => { + const interval = calcAuto.near(100, moment.duration(8, 'h')); + expect(interval.asMinutes()).toBe(5); + }); + + test('returns 15m buckets for 1d / 100 bars', () => { + const interval = calcAuto.near(100, moment.duration(1, 'd')); + expect(interval.asMinutes()).toBe(15); + }); + + test('returns 1h buckets for 20d / 500 bars', () => { + const interval = calcAuto.near(500, moment.duration(20, 'd')); + expect(interval.asHours()).toBe(1); + }); + + test('returns 6h buckets for 100d / 500 bars', () => { + const interval = calcAuto.near(500, moment.duration(100, 'd')); + expect(interval.asHours()).toBe(6); + }); + + test('returns 24h buckets for 1y / 500 bars', () => { + const interval = calcAuto.near(500, moment.duration(1, 'y')); + expect(interval.asHours()).toBe(24); + }); + + test('returns 12h buckets for 1y / 1000 bars', () => { + const interval = calcAuto.near(1000, moment.duration(1, 'y')); + expect(interval.asHours()).toBe(12); + }); + }); + + describe('lessThan interval', () => { + test('returns 0ms buckets for undefined / 0 bars', () => { + const interval = calcAuto.lessThan(0, undefined); + expect(interval.asMilliseconds()).toBe(0); + }); + + test('returns 500ms buckets for 60s / 100 bars', () => { + const interval = calcAuto.lessThan(100, moment.duration(60, 's')); + expect(interval.asMilliseconds()).toBe(500); + }); + + test('returns 5m buckets for 8h / 100 bars', () => { + const interval = calcAuto.lessThan(100, moment.duration(8, 'h')); + expect(interval.asMinutes()).toBe(5); + }); + + test('returns 30m buckets for 1d / 100 bars', () => { + const interval = calcAuto.lessThan(100, moment.duration(1, 'd')); + expect(interval.asMinutes()).toBe(30); + }); + + test('returns 1h buckets for 20d / 500 bars', () => { + const interval = calcAuto.lessThan(500, moment.duration(20, 'd')); + expect(interval.asHours()).toBe(1); + }); + + test('returns 6h buckets for 100d / 500 bars', () => { + const interval = calcAuto.lessThan(500, moment.duration(100, 'd')); + expect(interval.asHours()).toBe(6); + }); + + test('returns 24h buckets for 1y / 500 bars', () => { + const interval = calcAuto.lessThan(500, moment.duration(1, 'y')); + expect(interval.asHours()).toBe(24); + }); + + test('returns 12h buckets for 1y / 1000 bars', () => { + const interval = calcAuto.lessThan(1000, moment.duration(1, 'y')); + expect(interval.asHours()).toBe(12); + }); + }); + + describe('atLeast interval', () => { + test('returns 0ms buckets for undefined / 0 bars', () => { + const interval = calcAuto.atLeast(0, undefined); + expect(interval.asMilliseconds()).toBe(0); + }); + + test('returns 100ms buckets for 60s / 100 bars', () => { + const interval = calcAuto.atLeast(100, moment.duration(60, 's')); + expect(interval.asMilliseconds()).toBe(100); + }); + + test('returns 1m buckets for 8h / 100 bars', () => { + const interval = calcAuto.atLeast(100, moment.duration(8, 'h')); + expect(interval.asMinutes()).toBe(1); + }); + + test('returns 10m buckets for 1d / 100 bars', () => { + const interval = calcAuto.atLeast(100, moment.duration(1, 'd')); + expect(interval.asMinutes()).toBe(10); + }); + + test('returns 30m buckets for 20d / 500 bars', () => { + const interval = calcAuto.atLeast(500, moment.duration(20, 'd')); + expect(interval.asMinutes()).toBe(30); + }); + + test('returns 4h buckets for 100d / 500 bars', () => { + const interval = calcAuto.atLeast(500, moment.duration(100, 'd')); + expect(interval.asHours()).toBe(4); + }); + + test('returns 12h buckets for 1y / 500 bars', () => { + const interval = calcAuto.atLeast(500, moment.duration(1, 'y')); + expect(interval.asHours()).toBe(12); + }); + + test('returns 8h buckets for 1y / 1000 bars', () => { + const interval = calcAuto.atLeast(1000, moment.duration(1, 'y')); + expect(interval.asHours()).toBe(8); + }); + }); +}); diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.test.js b/x-pack/plugins/ml/public/application/util/chart_utils.test.js index 4b33cb131be7f..57aea3c0ab5aa 100644 --- a/x-pack/plugins/ml/public/application/util/chart_utils.test.js +++ b/x-pack/plugins/ml/public/application/util/chart_utils.test.js @@ -29,246 +29,488 @@ const timefilter = getTimefilter(); import d3 from 'd3'; import moment from 'moment'; -import { mount } from 'enzyme'; import React from 'react'; +import { render } from '@testing-library/react'; import { + chartLimits, + getChartType, getExploreSeriesLink, getTickValues, - isLabelLengthAboveThreshold, getXTransform, + isLabelLengthAboveThreshold, + numTicks, removeLabelOverlap, + showMultiBucketAnomalyMarker, + showMultiBucketAnomalyTooltip, } from './chart_utils'; +import { MULTI_BUCKET_IMPACT } from '../../../common/constants/multi_bucket_impact'; +import { CHART_TYPE } from '../explorer/explorer_constants'; + timefilter.setTime({ from: moment(seriesConfig.selectedEarliest).toISOString(), to: moment(seriesConfig.selectedLatest).toISOString(), }); -describe('getExploreSeriesLink', () => { - test('get timeseriesexplorer link', () => { - const link = getExploreSeriesLink(seriesConfig); - const expectedLink = - `#/timeseriesexplorer?_g=(ml:(jobIds:!(population-03)),` + - `refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2017-02-23T00:00:00.000Z',mode:absolute,` + - `to:'2017-02-23T23:59:59.999Z'))&_a=(mlTimeSeriesExplorer%3A(detectorIndex%3A0%2Centities%3A` + - `(nginx.access.remote_ip%3A'72.57.0.53')%2Czoom%3A(from%3A'2017-02-19T20%3A00%3A00.000Z'%2Cto%3A'2017-02-27T04%3A00%3A00.000Z'))` + - `%2Cquery%3A(query_string%3A(analyze_wildcard%3A!t%2Cquery%3A'*')))`; - - expect(link).toBe(expectedLink); - }); -}); +describe('ML - chart utils', () => { + describe('chartLimits', () => { + test('returns NaN when called without data', () => { + const limits = chartLimits(); + expect(limits.min).toBeNaN(); + expect(limits.max).toBeNaN(); + }); -describe('getTickValues', () => { - test('farequote sample data', () => { - const tickValues = getTickValues(1486656000000, 14400000, 1486606500000, 1486719900000); - - expect(tickValues).toEqual([ - 1486612800000, - 1486627200000, - 1486641600000, - 1486656000000, - 1486670400000, - 1486684800000, - 1486699200000, - 1486713600000, - ]); - }); + test('returns {max: 625736376, min: 201039318} for some test data', () => { + const data = [ + { + date: new Date('2017-02-23T08:00:00.000Z'), + value: 228243469, + anomalyScore: 63.32916, + numberOfCauses: 1, + actual: [228243469], + typical: [133107.7703441773], + }, + { date: new Date('2017-02-23T09:00:00.000Z'), value: null }, + { date: new Date('2017-02-23T10:00:00.000Z'), value: null }, + { date: new Date('2017-02-23T11:00:00.000Z'), value: null }, + { + date: new Date('2017-02-23T12:00:00.000Z'), + value: 625736376, + anomalyScore: 97.32085, + numberOfCauses: 1, + actual: [625736376], + typical: [132830.424736973], + }, + { + date: new Date('2017-02-23T13:00:00.000Z'), + value: 201039318, + anomalyScore: 59.83488, + numberOfCauses: 1, + actual: [201039318], + typical: [132739.5267403542], + }, + ]; + + const limits = chartLimits(data); + + // {max: 625736376, min: 201039318} + expect(limits.min).toBe(201039318); + expect(limits.max).toBe(625736376); + }); - test('filebeat sample data', () => { - const tickValues = getTickValues(1486080000000, 14400000, 1485860400000, 1486314000000); - expect(tickValues).toEqual([ - 1485864000000, - 1485878400000, - 1485892800000, - 1485907200000, - 1485921600000, - 1485936000000, - 1485950400000, - 1485964800000, - 1485979200000, - 1485993600000, - 1486008000000, - 1486022400000, - 1486036800000, - 1486051200000, - 1486065600000, - 1486080000000, - 1486094400000, - 1486108800000, - 1486123200000, - 1486137600000, - 1486152000000, - 1486166400000, - 1486180800000, - 1486195200000, - 1486209600000, - 1486224000000, - 1486238400000, - 1486252800000, - 1486267200000, - 1486281600000, - 1486296000000, - 1486310400000, - ]); + test("adds 5% padding when min/max are the same, e.g. when there's only one data point", () => { + const data = [ + { + date: new Date('2017-02-23T08:00:00.000Z'), + value: 100, + anomalyScore: 50, + numberOfCauses: 1, + actual: [100], + typical: [100], + }, + ]; + + const limits = chartLimits(data); + expect(limits.min).toBe(95); + expect(limits.max).toBe(105); + }); + + test('returns minimum of 0 when data includes an anomaly for missing data', () => { + const data = [ + { date: new Date('2017-02-23T09:00:00.000Z'), value: 22.2 }, + { date: new Date('2017-02-23T10:00:00.000Z'), value: 23.3 }, + { date: new Date('2017-02-23T11:00:00.000Z'), value: 24.4 }, + { + date: new Date('2017-02-23T12:00:00.000Z'), + value: null, + anomalyScore: 97.32085, + actual: [0], + typical: [22.2], + }, + { date: new Date('2017-02-23T13:00:00.000Z'), value: 21.3 }, + { date: new Date('2017-02-23T14:00:00.000Z'), value: 21.2 }, + { date: new Date('2017-02-23T15:00:00.000Z'), value: 21.1 }, + ]; + + const limits = chartLimits(data); + expect(limits.min).toBe(0); + expect(limits.max).toBe(24.4); + }); }); - test('gallery sample data', () => { - const tickValues = getTickValues(1518652800000, 604800000, 1518274800000, 1519635600000); - expect(tickValues).toEqual([1518652800000, 1519257600000]); + describe('getChartType', () => { + const singleMetricConfig = { + metricFunction: 'avg', + functionDescription: 'mean', + fieldName: 'responsetime', + entityFields: [], + }; + + const multiMetricConfig = { + metricFunction: 'avg', + functionDescription: 'mean', + fieldName: 'responsetime', + entityFields: [ + { + fieldName: 'airline', + fieldValue: 'AAL', + fieldType: 'partition', + }, + ], + }; + + const populationConfig = { + metricFunction: 'avg', + functionDescription: 'mean', + fieldName: 'http.response.body.bytes', + entityFields: [ + { + fieldName: 'source.ip', + fieldValue: '10.11.12.13', + fieldType: 'over', + }, + ], + }; + + const rareConfig = { + metricFunction: 'count', + functionDescription: 'rare', + entityFields: [ + { + fieldName: 'http.response.status_code', + fieldValue: '404', + fieldType: 'by', + }, + ], + }; + + const varpModelPlotConfig = { + metricFunction: null, + functionDescription: 'varp', + fieldName: 'NetworkOut', + entityFields: [ + { + fieldName: 'instance', + fieldValue: 'i-ef74d410', + fieldType: 'over', + }, + ], + }; + + const overScriptFieldModelPlotConfig = { + metricFunction: 'count', + functionDescription: 'count', + fieldName: 'highest_registered_domain', + entityFields: [ + { + fieldName: 'highest_registered_domain', + fieldValue: 'elastic.co', + fieldType: 'over', + }, + ], + datafeedConfig: { + script_fields: { + highest_registered_domain: { + script: { + source: "return domainSplit(doc['query'].value, params).get(1);", + lang: 'painless', + }, + ignore_failure: false, + }, + }, + }, + }; + + test('returns single metric chart type as expected for configs', () => { + expect(getChartType(singleMetricConfig)).toBe(CHART_TYPE.SINGLE_METRIC); + expect(getChartType(multiMetricConfig)).toBe(CHART_TYPE.SINGLE_METRIC); + expect(getChartType(varpModelPlotConfig)).toBe(CHART_TYPE.SINGLE_METRIC); + expect(getChartType(overScriptFieldModelPlotConfig)).toBe(CHART_TYPE.SINGLE_METRIC); + }); + + test('returns event distribution chart type as expected for configs', () => { + expect(getChartType(rareConfig)).toBe(CHART_TYPE.EVENT_DISTRIBUTION); + }); + + test('returns population distribution chart type as expected for configs', () => { + expect(getChartType(populationConfig)).toBe(CHART_TYPE.POPULATION_DISTRIBUTION); + }); }); - test('invalid tickIntervals trigger an error', () => { - expect(() => { - getTickValues(1518652800000, 0, 1518274800000, 1519635600000); - }).toThrow(); - expect(() => { - getTickValues(1518652800000, -604800000, 1518274800000, 1519635600000); - }).toThrow(); + describe('getExploreSeriesLink', () => { + test('get timeseriesexplorer link', () => { + const link = getExploreSeriesLink(seriesConfig); + const expectedLink = + `#/timeseriesexplorer?_g=(ml:(jobIds:!(population-03)),` + + `refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2017-02-23T00:00:00.000Z',mode:absolute,` + + `to:'2017-02-23T23:59:59.999Z'))&_a=(mlTimeSeriesExplorer%3A(detectorIndex%3A0%2Centities%3A` + + `(nginx.access.remote_ip%3A'72.57.0.53')%2Czoom%3A(from%3A'2017-02-19T20%3A00%3A00.000Z'%2Cto%3A'2017-02-27T04%3A00%3A00.000Z'))` + + `%2Cquery%3A(query_string%3A(analyze_wildcard%3A!t%2Cquery%3A'*')))`; + + expect(link).toBe(expectedLink); + }); }); -}); -describe('isLabelLengthAboveThreshold', () => { - test('short label', () => { - const isLongLabel = isLabelLengthAboveThreshold({ - detectorLabel: 'count', - entityFields: seriesConfig.entityFields, + describe('numTicks', () => { + test('returns 10 for 1000', () => { + expect(numTicks(1000)).toBe(10); }); - expect(isLongLabel).toBeFalsy(); }); - test('long label', () => { - const isLongLabel = isLabelLengthAboveThreshold(seriesConfig); - expect(isLongLabel).toBeTruthy(); + describe('showMultiBucketAnomalyMarker', () => { + test('returns true for points with multiBucketImpact at or above medium impact', () => { + expect(showMultiBucketAnomalyMarker({ multiBucketImpact: MULTI_BUCKET_IMPACT.HIGH })).toBe( + true + ); + expect(showMultiBucketAnomalyMarker({ multiBucketImpact: MULTI_BUCKET_IMPACT.MEDIUM })).toBe( + true + ); + }); + + test('returns false for points with multiBucketImpact missing or below medium impact', () => { + expect(showMultiBucketAnomalyMarker({})).toBe(false); + expect(showMultiBucketAnomalyMarker({ multiBucketImpact: MULTI_BUCKET_IMPACT.LOW })).toBe( + false + ); + expect(showMultiBucketAnomalyMarker({ multiBucketImpact: MULTI_BUCKET_IMPACT.NONE })).toBe( + false + ); + }); }); -}); -describe('getXTransform', () => { - const expectedXTransform = 0.007167499999999999; + describe('showMultiBucketAnomalyTooltip', () => { + test('returns true for points with multiBucketImpact at or above low impact', () => { + expect(showMultiBucketAnomalyTooltip({ multiBucketImpact: MULTI_BUCKET_IMPACT.HIGH })).toBe( + true + ); + expect(showMultiBucketAnomalyTooltip({ multiBucketImpact: MULTI_BUCKET_IMPACT.MEDIUM })).toBe( + true + ); + expect(showMultiBucketAnomalyTooltip({ multiBucketImpact: MULTI_BUCKET_IMPACT.LOW })).toBe( + true + ); + }); - test('Chrome/Safari/Firefox String variant.', () => { - const transformStr = 'translate(0.007167499999999999,0)'; - const xTransform = getXTransform(transformStr); - expect(xTransform).toEqual(expectedXTransform); + test('returns false for points with multiBucketImpact missing or below medium impact', () => { + expect(showMultiBucketAnomalyTooltip({})).toBe(false); + expect(showMultiBucketAnomalyTooltip({ multiBucketImpact: MULTI_BUCKET_IMPACT.NONE })).toBe( + false + ); + }); }); - test('IE11 String variant.', () => { - const transformStr = 'translate(0.007167499999999999)'; - const xTransform = getXTransform(transformStr); - expect(xTransform).toEqual(expectedXTransform); + describe('getTickValues', () => { + test('farequote sample data', () => { + const tickValues = getTickValues(1486656000000, 14400000, 1486606500000, 1486719900000); + + expect(tickValues).toEqual([ + 1486612800000, + 1486627200000, + 1486641600000, + 1486656000000, + 1486670400000, + 1486684800000, + 1486699200000, + 1486713600000, + ]); + }); + + test('filebeat sample data', () => { + const tickValues = getTickValues(1486080000000, 14400000, 1485860400000, 1486314000000); + expect(tickValues).toEqual([ + 1485864000000, + 1485878400000, + 1485892800000, + 1485907200000, + 1485921600000, + 1485936000000, + 1485950400000, + 1485964800000, + 1485979200000, + 1485993600000, + 1486008000000, + 1486022400000, + 1486036800000, + 1486051200000, + 1486065600000, + 1486080000000, + 1486094400000, + 1486108800000, + 1486123200000, + 1486137600000, + 1486152000000, + 1486166400000, + 1486180800000, + 1486195200000, + 1486209600000, + 1486224000000, + 1486238400000, + 1486252800000, + 1486267200000, + 1486281600000, + 1486296000000, + 1486310400000, + ]); + }); + + test('gallery sample data', () => { + const tickValues = getTickValues(1518652800000, 604800000, 1518274800000, 1519635600000); + expect(tickValues).toEqual([1518652800000, 1519257600000]); + }); + + test('invalid tickIntervals trigger an error', () => { + expect(() => { + getTickValues(1518652800000, 0, 1518274800000, 1519635600000); + }).toThrow(); + expect(() => { + getTickValues(1518652800000, -604800000, 1518274800000, 1519635600000); + }).toThrow(); + }); }); - test('Invalid String.', () => { - const transformStr = 'translate()'; - const xTransform = getXTransform(transformStr); - expect(xTransform).toEqual(NaN); + describe('isLabelLengthAboveThreshold', () => { + test('short label', () => { + const isLongLabel = isLabelLengthAboveThreshold({ + detectorLabel: 'count', + entityFields: seriesConfig.entityFields, + }); + expect(isLongLabel).toBeFalsy(); + }); + + test('long label', () => { + const isLongLabel = isLabelLengthAboveThreshold(seriesConfig); + expect(isLongLabel).toBeTruthy(); + }); }); -}); -describe('removeLabelOverlap', () => { - const originalGetBBox = SVGElement.prototype.getBBox; - - // This resembles how ExplorerChart renders its x axis. - // We set up this boilerplate so we can then run removeLabelOverlap() - // on some "real" structure. - function axisSetup({ interval, plotEarliest, plotLatest, startTimeMs, xAxisTickFormat }) { - const wrapper = mount(<div className="content-wrapper" />); - const node = wrapper.getDOMNode(); - - const chartHeight = 170; - const margin = { top: 10, right: 0, bottom: 30, left: 60 }; - const svgWidth = 500; - const svgHeight = chartHeight + margin.top + margin.bottom; - const vizWidth = 500; - - const chartElement = d3.select(node); - - const lineChartXScale = d3.time - .scale() - .range([0, vizWidth]) - .domain([plotEarliest, plotLatest]); - - const xAxis = d3.svg - .axis() - .scale(lineChartXScale) - .orient('bottom') - .innerTickSize(-chartHeight) - .outerTickSize(0) - .tickPadding(10) - .tickFormat(d => moment(d).format(xAxisTickFormat)); - - const tickValues = getTickValues(startTimeMs, interval, plotEarliest, plotLatest); - xAxis.tickValues(tickValues); - - const svg = chartElement - .append('svg') - .attr('width', svgWidth) - .attr('height', svgHeight); - - const axes = svg.append('g'); - - const gAxis = axes - .append('g') - .attr('class', 'x axis') - .attr('transform', 'translate(0,' + chartHeight + ')') - .call(xAxis); - - return { - gAxis, - node, - vizWidth, - }; - } + describe('getXTransform', () => { + const expectedXTransform = 0.007167499999999999; - test('farequote sample data', () => { - const mockedGetBBox = { width: 27.21875 }; - SVGElement.prototype.getBBox = () => mockedGetBBox; + test('Chrome/Safari/Firefox String variant.', () => { + const transformStr = 'translate(0.007167499999999999,0)'; + const xTransform = getXTransform(transformStr); + expect(xTransform).toEqual(expectedXTransform); + }); - const startTimeMs = 1486656000000; - const interval = 14400000; + test('IE11 String variant.', () => { + const transformStr = 'translate(0.007167499999999999)'; + const xTransform = getXTransform(transformStr); + expect(xTransform).toEqual(expectedXTransform); + }); - const { gAxis, node, vizWidth } = axisSetup({ - interval, - plotEarliest: 1486606500000, - plotLatest: 1486719900000, - startTimeMs, - xAxisTickFormat: 'HH:mm', + test('Invalid String.', () => { + const transformStr = 'translate()'; + const xTransform = getXTransform(transformStr); + expect(xTransform).toEqual(NaN); }); + }); - expect(node.getElementsByTagName('text')).toHaveLength(8); + describe('removeLabelOverlap', () => { + const originalGetBBox = SVGElement.prototype.getBBox; + + // This resembles how ExplorerChart renders its x axis. + // We set up this boilerplate so we can then run removeLabelOverlap() + // on some "real" structure. + function axisSetup({ interval, plotEarliest, plotLatest, startTimeMs, xAxisTickFormat }) { + const { container } = render(<div className="content-wrapper" />); + const node = container.querySelector('.content-wrapper'); + + const chartHeight = 170; + const margin = { top: 10, right: 0, bottom: 30, left: 60 }; + const svgWidth = 500; + const svgHeight = chartHeight + margin.top + margin.bottom; + const vizWidth = 500; + + const chartElement = d3.select(node); + + const lineChartXScale = d3.time + .scale() + .range([0, vizWidth]) + .domain([plotEarliest, plotLatest]); + + const xAxis = d3.svg + .axis() + .scale(lineChartXScale) + .orient('bottom') + .innerTickSize(-chartHeight) + .outerTickSize(0) + .tickPadding(10) + .tickFormat(d => moment(d).format(xAxisTickFormat)); + + const tickValues = getTickValues(startTimeMs, interval, plotEarliest, plotLatest); + xAxis.tickValues(tickValues); + + const svg = chartElement + .append('svg') + .attr('width', svgWidth) + .attr('height', svgHeight); + + const axes = svg.append('g'); + + const gAxis = axes + .append('g') + .attr('class', 'x axis') + .attr('transform', 'translate(0,' + chartHeight + ')') + .call(xAxis); - removeLabelOverlap(gAxis, startTimeMs, interval, vizWidth); + return { + gAxis, + node, + vizWidth, + }; + } - // at the vizWidth of 500, the most left and right tick label - // will get removed because it overflows the chart area - expect(node.getElementsByTagName('text')).toHaveLength(6); + test('farequote sample data', () => { + const mockedGetBBox = { width: 27.21875 }; + SVGElement.prototype.getBBox = () => mockedGetBBox; - SVGElement.prototype.getBBox = originalGetBBox; - }); + const startTimeMs = 1486656000000; + const interval = 14400000; + + const { gAxis, node, vizWidth } = axisSetup({ + interval, + plotEarliest: 1486606500000, + plotLatest: 1486719900000, + startTimeMs, + xAxisTickFormat: 'HH:mm', + }); - test('filebeat sample data', () => { - const mockedGetBBox = { width: 85.640625 }; - SVGElement.prototype.getBBox = () => mockedGetBBox; + expect(node.getElementsByTagName('text')).toHaveLength(8); - const startTimeMs = 1486080000000; - const interval = 14400000; + removeLabelOverlap(gAxis, startTimeMs, interval, vizWidth); - const { gAxis, node, vizWidth } = axisSetup({ - interval, - plotEarliest: 1485860400000, - plotLatest: 1486314000000, - startTimeMs, - xAxisTickFormat: 'YYYY-MM-DD HH:mm', + // at the vizWidth of 500, the most left and right tick label + // will get removed because it overflows the chart area + expect(node.getElementsByTagName('text')).toHaveLength(6); + + SVGElement.prototype.getBBox = originalGetBBox; }); - expect(node.getElementsByTagName('text')).toHaveLength(32); + test('filebeat sample data', () => { + const mockedGetBBox = { width: 85.640625 }; + SVGElement.prototype.getBBox = () => mockedGetBBox; + + const startTimeMs = 1486080000000; + const interval = 14400000; - removeLabelOverlap(gAxis, startTimeMs, interval, vizWidth); + const { gAxis, node, vizWidth } = axisSetup({ + interval, + plotEarliest: 1485860400000, + plotLatest: 1486314000000, + startTimeMs, + xAxisTickFormat: 'YYYY-MM-DD HH:mm', + }); - // In this case labels get reduced significantly because of the wider - // labels (full dates + time) and the narrow interval. - expect(node.getElementsByTagName('text')).toHaveLength(3); + expect(node.getElementsByTagName('text')).toHaveLength(32); - SVGElement.prototype.getBBox = originalGetBBox; + removeLabelOverlap(gAxis, startTimeMs, interval, vizWidth); + + // In this case labels get reduced significantly because of the wider + // labels (full dates + time) and the narrow interval. + expect(node.getElementsByTagName('text')).toHaveLength(3); + + SVGElement.prototype.getBBox = originalGetBBox; + }); }); }); diff --git a/x-pack/plugins/ml/public/application/util/string_utils.d.ts b/x-pack/plugins/ml/public/application/util/string_utils.d.ts index b5063907e1fdf..531e44e3e78c1 100644 --- a/x-pack/plugins/ml/public/application/util/string_utils.d.ts +++ b/x-pack/plugins/ml/public/application/util/string_utils.d.ts @@ -14,4 +14,8 @@ export function replaceStringTokens( export function detectorToString(dtr: any): string; +export function sortByKey(list: any, reverse: boolean, comparator?: any): any; + export function toLocaleString(x: number): string; + +export function mlEscape(str: string): string; diff --git a/x-pack/plugins/ml/public/application/util/string_utils.js b/x-pack/plugins/ml/public/application/util/string_utils.js index 172d334099b3d..66835984df5e5 100644 --- a/x-pack/plugins/ml/public/application/util/string_utils.js +++ b/x-pack/plugins/ml/public/application/util/string_utils.js @@ -99,211 +99,6 @@ export function sortByKey(list, reverse, comparator) { ); } -// guess the time format for a given time string -export function guessTimeFormat(time) { - let format = ''; - let matched = false; - if (isNaN(time)) { - let match; - - // match date format - if (!matched) { - let reg = ''; - - reg += '('; // 1 ( date - - reg += '('; // 2 ( yyyy-MM-dd - reg += '(\\d{4})'; // 3 yyyy - reg += '([-/.\\s])'; // 4 - or . or \s - reg += '('; // 5 ( month - reg += '([01]\\d)'; // 6 MM - reg += '|'; // or - reg += '(\\w{3})'; // 7 MMM - reg += ')'; // ) end month - reg += '([-/.\\s])'; // 8 - or . or \s - reg += '([0-3]\\d)'; // 9 dd 0-3 and 0-9 - reg += ')'; // ) end yyyy-MM-dd - - reg += '|'; // or - - reg += '('; // 10 ( d[d]-MM[M]-yyyy or MM[M]-d[d]-yyyy - - reg += '('; // 11 ( day or month - reg += '(\\d{1,2})'; // 12 d or M or dd or MM - reg += '|'; // or - reg += '(\\w{3})'; // 13 MMM - reg += ')'; // ) end day or month - - reg += '([-/.\\s])'; // 14 - or . or \s - - reg += '('; // 15 ( day or month - reg += '(\\d{1,2})'; // 12 d or M or dd or MM - reg += '|'; // or - reg += '(\\w{3})'; // 17 MMM - reg += ')'; // ) end day or month - - reg += '([-/.\\s])'; // 18 - or . or \s - reg += '(\\d{4})'; // 19 yyyy - reg += ')'; // ) end d[d]-MM[M]-yyyy or MM[M]-d[d]-yyyy - - reg += ')'; // ) end date - - reg += '([T\\s])?'; // 20 T or space - - reg += '([0-2]\\d)'; // 21 HH 0-2 and 0-9 - reg += '([:.])'; // 22 :. - reg += '([0-5]\\d)'; // 23 mm 0-5 and 0-9 - reg += '('; // 24 ( optional secs - reg += '([:.])'; // 25 :. - reg += '([0-5]\\d)'; // 26 ss 0-5 and 0-9 - reg += ')?'; // ) end optional secs - reg += '('; // 27 ( optional millisecs - reg += '([:.])'; // 28 :. - reg += '(\\d{3})'; // 29 3 * 0-9 - reg += ')?'; // ) end optional millisecs - reg += '('; // 30 ( optional timezone matches - reg += '([+-]\\d{2}[:.]\\d{2}[:.]\\d{2})'; // 31 +- 0-9 0-9 :. 0-9 0-9 :. 0-9 0-9 e.g. +00:00:00 - reg += '|'; // or - reg += '([+-]\\d{2}[:.]\\d{2})'; // 32 +- 0-9 0-9 :. 0-9 0-9 e.g. +00:00 - reg += '|'; // or - reg += '([+-]\\d{6})'; // 33 +- 6 * 0-9 e.g. +000000 - reg += '|'; // or - reg += '([+-]\\d{4})'; // 34 +- 4 * 0-9 e.g. +0000 - reg += '|'; // or - reg += '(Z)'; // 35 Z - reg += '|'; // or - reg += '([+-]\\d{2})'; // 36 +- 0-9 0-9 e.g. +00 - reg += '|'; // or - reg += '('; // 37 ( string timezone - reg += '(\\s)'; // 38 optional space - reg += '(\\w{1,4})'; // 39 1-4 letters e.g UTC - reg += ')'; // ) end string timezone - reg += ')?'; // ) end optional timezone - - console.log('guessTimeFormat: time format regex: ' + reg); - - match = time.match(new RegExp(reg)); - // console.log(match); - if (match) { - // add the standard data and time - if (match[2] !== undefined) { - // match yyyy-[MM MMM]-dd - format += 'yyyy'; - format += match[4]; - if (match[6] !== undefined) { - format += 'MM'; - } else if (match[7] !== undefined) { - format += 'MMM'; - } - format += match[8]; - format += 'dd'; - } else if (match[10] !== undefined) { - // match dd-MM[M]-yyyy or MM[M]-dd-yyyy - - if (match[13] !== undefined) { - // found a word as the first part - // e.g., Jan 01 2000 - format += 'MMM'; - format += match[14]; - format += 'dd'; - } else if (match[17] !== undefined) { - // found a word as the second part - // e.g., 01 Jan 2000 - format += 'dd'; - format += match[14]; - format += 'MMM'; - } else { - // check to see if the first number is greater than 12 - // e.g., 24/03/1981 - // this is a guess, but is only thing we can do - // with one line from the data set - if (match[12] !== undefined && +match[12] > 12) { - format += 'dd'; - format += match[14]; - format += 'MM'; - } else { - // default to US format. - format += 'MM'; - format += match[14]; - format += 'dd'; - } - } - - format += match[18]; - format += 'yyyy'; - } - - // optional T or space splitter - // wrap T in single quotes - format += match[20] === 'T' ? "'" + match[20] + "'" : match[20]; - format += 'HH'; - format += match[22]; - format += 'mm'; - - // add optional secs - if (match[24] !== undefined) { - format += match[25]; - format += 'ss'; - } - - // add optional millisecs - if (match[27] !== undefined) { - // .000 - format += match[28]; - format += 'SSS'; - } - - // add optional time zone - if (match[31] !== undefined) { - // +00:00:00 - format += 'XXXXX'; - } else if (match[32] !== undefined) { - // +00:00 - format += 'XXX'; - } else if (match[33] !== undefined) { - // +000000 - format += 'XXXX'; - } else if (match[34] !== undefined) { - // +0000 - format += 'Z'; - } else if (match[35] !== undefined || match[36] !== undefined) { - // Z or +00 - format += 'X'; - } else if (match[37] !== undefined) { - // UTC - if (match[38] !== undefined) { - // add optional space char - format += match[38]; - } - // add time zone name, up to 4 chars - for (let i = 0; i < match[39].length; i++) { - format += 'z'; - } - } - matched = true; - } - } - } else { - // time field is a number, so probably epoch or epoch_ms - if (time > 10000000000) { - // probably milliseconds - format = 'epoch_ms'; - } else { - // probably seconds - format = 'epoch'; - } - matched = true; - } - - if (matched) { - console.log('guessTimeFormat: guessed time format: ', format); - } else { - console.log('guessTimeFormat: time format could not be guessed from: ' + time); - } - - return format; -} - // add commas to large numbers // Number.toLocaleString is not supported on safari export function toLocaleString(x) { diff --git a/x-pack/plugins/ml/public/application/util/string_utils.test.ts b/x-pack/plugins/ml/public/application/util/string_utils.test.ts new file mode 100644 index 0000000000000..d940fce2ee1d5 --- /dev/null +++ b/x-pack/plugins/ml/public/application/util/string_utils.test.ts @@ -0,0 +1,193 @@ +/* + * 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 { + replaceStringTokens, + detectorToString, + sortByKey, + toLocaleString, + mlEscape, + escapeForElasticsearchQuery, +} from './string_utils'; + +describe('ML - string utils', () => { + describe('replaceStringTokens', () => { + const testRecord = { + job_id: 'test_job', + result_type: 'record', + probability: 0.0191711, + record_score: 4.3, + bucket_span: 300, + detector_index: 0, + timestamp: 1454890500000, + function: 'mean', + function_description: 'mean', + field_name: 'responsetime', + user: "Des O'Connor", + testfield1: 'test$tring=[+-?]', + testfield2: '{<()>}', + testfield3: 'host=\\\\test@uk.dev', + }; + + test('returns correct values without URI encoding', () => { + const result = replaceStringTokens('user=$user$,time=$timestamp$', testRecord, false); + expect(result).toBe("user=Des O'Connor,time=1454890500000"); + }); + + test('returns correct values for missing token without URI encoding', () => { + const result = replaceStringTokens('user=$username$,time=$timestamp$', testRecord, false); + expect(result).toBe('user=$username$,time=1454890500000'); + }); + + test('returns correct values with URI encoding', () => { + const testString1 = 'https://www.google.co.uk/webhp#q=$testfield1$'; + const testString2 = 'https://www.google.co.uk/webhp#q=$testfield2$'; + const testString3 = 'https://www.google.co.uk/webhp#q=$testfield3$'; + const testString4 = 'https://www.google.co.uk/webhp#q=$user$'; + + const result1 = replaceStringTokens(testString1, testRecord, true); + const result2 = replaceStringTokens(testString2, testRecord, true); + const result3 = replaceStringTokens(testString3, testRecord, true); + const result4 = replaceStringTokens(testString4, testRecord, true); + + expect(result1).toBe('https://www.google.co.uk/webhp#q=test%24tring%3D%5B%2B-%3F%5D'); + expect(result2).toBe('https://www.google.co.uk/webhp#q=%7B%3C()%3E%7D'); + expect(result3).toBe('https://www.google.co.uk/webhp#q=host%3D%5C%5Ctest%40uk.dev'); + expect(result4).toBe("https://www.google.co.uk/webhp#q=Des%20O'Connor"); + }); + + test('returns correct values for missing token with URI encoding', () => { + const testString = 'https://www.google.co.uk/webhp#q=$username$&time=$timestamp$'; + const result = replaceStringTokens(testString, testRecord, true); + expect(result).toBe('https://www.google.co.uk/webhp#q=$username$&time=1454890500000'); + }); + }); + + describe('detectorToString', () => { + test('returns the correct descriptions for detectors', () => { + const detector1 = { + function: 'count', + }; + + const detector2 = { + function: 'count', + by_field_name: 'airline', + use_null: false, + }; + + const detector3 = { + function: 'mean', + field_name: 'CPUUtilization', + partition_field_name: 'region', + by_field_name: 'host', + over_field_name: 'user', + exclude_frequent: 'all', + }; + + expect(detectorToString(detector1)).toBe('count'); + expect(detectorToString(detector2)).toBe('count by airline use_null=false'); + expect(detectorToString(detector3)).toBe( + 'mean(CPUUtilization) by host over user partition_field_name=region exclude_frequent=all' + ); + }); + }); + + describe('sortByKey', () => { + const obj = { + zebra: 'stripes', + giraffe: 'neck', + elephant: 'trunk', + }; + + const valueComparator = function(value: string) { + return value; + }; + + test('returns correct ordering with default comparator', () => { + const result = sortByKey(obj, false); + const keys = Object.keys(result); + expect(keys[0]).toBe('elephant'); + expect(keys[1]).toBe('giraffe'); + expect(keys[2]).toBe('zebra'); + }); + + test('returns correct ordering with default comparator and order reversed', () => { + const result = sortByKey(obj, true); + const keys = Object.keys(result); + expect(keys[0]).toBe('zebra'); + expect(keys[1]).toBe('giraffe'); + expect(keys[2]).toBe('elephant'); + }); + + test('returns correct ordering with comparator', () => { + const result = sortByKey(obj, false, valueComparator); + const keys = Object.keys(result); + expect(keys[0]).toBe('giraffe'); + expect(keys[1]).toBe('zebra'); + expect(keys[2]).toBe('elephant'); + }); + + test('returns correct ordering with comparator and order reversed', () => { + const result = sortByKey(obj, true, valueComparator); + const keys = Object.keys(result); + expect(keys[0]).toBe('elephant'); + expect(keys[1]).toBe('zebra'); + expect(keys[2]).toBe('giraffe'); + }); + }); + + describe('toLocaleString', () => { + test('returns correct comma placement for large numbers', () => { + expect(toLocaleString(1)).toBe('1'); + expect(toLocaleString(10)).toBe('10'); + expect(toLocaleString(100)).toBe('100'); + expect(toLocaleString(1000)).toBe('1,000'); + expect(toLocaleString(10000)).toBe('10,000'); + expect(toLocaleString(100000)).toBe('100,000'); + expect(toLocaleString(1000000)).toBe('1,000,000'); + expect(toLocaleString(10000000)).toBe('10,000,000'); + expect(toLocaleString(100000000)).toBe('100,000,000'); + expect(toLocaleString(1000000000)).toBe('1,000,000,000'); + }); + }); + + describe('mlEscape', () => { + test('returns correct escaping of characters', () => { + expect(mlEscape('foo&bar')).toBe('foo&bar'); + expect(mlEscape('foo<bar')).toBe('foo<bar'); + expect(mlEscape('foo>bar')).toBe('foo>bar'); + expect(mlEscape('foo"bar')).toBe('foo"bar'); + expect(mlEscape("foo'bar")).toBe('foo'bar'); + expect(mlEscape('foo/bar')).toBe('foo/bar'); + }); + }); + + describe('escapeForElasticsearchQuery', () => { + test('returns correct escaping of reserved elasticsearch characters', () => { + expect(escapeForElasticsearchQuery('foo+bar')).toBe('foo\\+bar'); + expect(escapeForElasticsearchQuery('foo-bar')).toBe('foo\\-bar'); + expect(escapeForElasticsearchQuery('foo=bar')).toBe('foo\\=bar'); + expect(escapeForElasticsearchQuery('foo&&bar')).toBe('foo\\&\\&bar'); + expect(escapeForElasticsearchQuery('foo||bar')).toBe('foo\\|\\|bar'); + expect(escapeForElasticsearchQuery('foo>bar')).toBe('foo\\>bar'); + expect(escapeForElasticsearchQuery('foo<bar')).toBe('foo\\<bar'); + expect(escapeForElasticsearchQuery('foo!bar')).toBe('foo\\!bar'); + expect(escapeForElasticsearchQuery('foo(bar')).toBe('foo\\(bar'); + expect(escapeForElasticsearchQuery('foo)bar')).toBe('foo\\)bar'); + expect(escapeForElasticsearchQuery('foo{bar')).toBe('foo\\{bar'); + expect(escapeForElasticsearchQuery('foo[bar')).toBe('foo\\[bar'); + expect(escapeForElasticsearchQuery('foo]bar')).toBe('foo\\]bar'); + expect(escapeForElasticsearchQuery('foo^bar')).toBe('foo\\^bar'); + expect(escapeForElasticsearchQuery('foo"bar')).toBe('foo\\"bar'); + expect(escapeForElasticsearchQuery('foo~bar')).toBe('foo\\~bar'); + expect(escapeForElasticsearchQuery('foo*bar')).toBe('foo\\*bar'); + expect(escapeForElasticsearchQuery('foo?bar')).toBe('foo\\?bar'); + expect(escapeForElasticsearchQuery('foo:bar')).toBe('foo\\:bar'); + expect(escapeForElasticsearchQuery('foo\\bar')).toBe('foo\\\\bar'); + expect(escapeForElasticsearchQuery('foo/bar')).toBe('foo\\/bar'); + }); + }); +}); diff --git a/x-pack/plugins/ml/server/lib/telemetry/mappings.ts b/x-pack/plugins/ml/server/lib/telemetry/mappings.ts index 87e2243328422..5aaf9f8c79dc0 100644 --- a/x-pack/plugins/ml/server/lib/telemetry/mappings.ts +++ b/x-pack/plugins/ml/server/lib/telemetry/mappings.ts @@ -10,7 +10,7 @@ import { TELEMETRY_DOC_ID } from './telemetry'; export const mlTelemetryMappingsType: SavedObjectsType = { name: TELEMETRY_DOC_ID, hidden: false, - namespaceAgnostic: true, + namespaceType: 'agnostic', mappings: { properties: { file_data_visualizer: { diff --git a/x-pack/plugins/ml/server/models/job_service/jobs.ts b/x-pack/plugins/ml/server/models/job_service/jobs.ts index edcabcac93c2a..6024ecf4925e6 100644 --- a/x-pack/plugins/ml/server/models/job_service/jobs.ts +++ b/x-pack/plugins/ml/server/models/job_service/jobs.ts @@ -16,6 +16,7 @@ import { DatafeedWithStats, CombinedJobWithStats, } from '../../../common/types/anomaly_detection_jobs'; +import { GLOBAL_CALENDAR } from '../../../common/constants/calendars'; import { datafeedsProvider, MlDatafeedsResponse, MlDatafeedsStatsResponse } from './datafeeds'; import { jobAuditMessagesProvider } from '../job_audit_messages'; import { resultsServiceProvider } from '../results_service'; @@ -227,6 +228,8 @@ export function jobsProvider(callAsCurrentUser: APICaller) { const groups: { [jobId: string]: string[] } = {}; const datafeeds: { [id: string]: DatafeedWithStats } = {}; const calendarsByJobId: { [jobId: string]: string[] } = {}; + const globalCalendars: string[] = []; + const requests: [ Promise<MlJobsResponse>, Promise<MlJobsStatsResponse>, @@ -298,7 +301,9 @@ export function jobsProvider(callAsCurrentUser: APICaller) { if (calendarResults) { calendarResults.forEach(cal => { cal.job_ids.forEach(id => { - if (groups[id]) { + if (id === GLOBAL_CALENDAR) { + globalCalendars.push(cal.calendar_id); + } else if (groups[id]) { groups[id].forEach(jId => { if (calendarsByJobId[jId] !== undefined) { calendarsByJobId[jId].push(cal.calendar_id); @@ -325,8 +330,12 @@ export function jobsProvider(callAsCurrentUser: APICaller) { jobResults.jobs.forEach(job => { const tempJob = job as CombinedJobWithStats; - if (calendarsByJobId[tempJob.job_id].length) { - tempJob.calendars = calendarsByJobId[tempJob.job_id]; + const calendars: string[] = [ + ...(calendarsByJobId[tempJob.job_id] || []), + ...(globalCalendars || []), + ]; + if (calendars.length) { + tempJob.calendars = calendars; } if (jobStatsResults && jobStatsResults.jobs) { diff --git a/x-pack/plugins/ml/server/routes/README.md b/x-pack/plugins/ml/server/routes/README.md index 1d08335af3d2e..70af73c37dadd 100644 --- a/x-pack/plugins/ml/server/routes/README.md +++ b/x-pack/plugins/ml/server/routes/README.md @@ -6,11 +6,14 @@ Each route handler requires [apiDoc](https://github.com/apidoc/apidoc) annotatio to generate documentation. The [apidoc-markdown](https://github.com/rigwild/apidoc-markdown) package is also required in order to generate the markdown. -For now the process is pretty manual. You need to make sure the packages mentioned above are installed globally -to execute the following command from the directory in which this README file is located. +There are custom parser and worker (`x-pack/plugins/ml/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 ml plugin folder: ``` -apidoc -i . -o ../routes_doc && apidoc-markdown -p ../routes_doc -o ../routes_doc/ML_API.md +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 `ML_API.md` file. \ No newline at end of file +as well as `ML_API.md` file. diff --git a/x-pack/plugins/ml/server/routes/annotations.ts b/x-pack/plugins/ml/server/routes/annotations.ts index 56c0b639e2c85..d5abebda00caa 100644 --- a/x-pack/plugins/ml/server/routes/annotations.ts +++ b/x-pack/plugins/ml/server/routes/annotations.ts @@ -5,10 +5,8 @@ */ import Boom from 'boom'; -import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { schema } from '@kbn/config-schema'; import { SecurityPluginSetup } from '../../../security/server'; import { isAnnotationsFeatureAvailable } from '../lib/check_annotations'; import { annotationServiceProvider } from '../models/annotation_service'; @@ -45,10 +43,7 @@ export function annotationRoutes( * @apiName GetAnnotations * @apiDescription Gets annotations. * - * @apiParam {String[]} jobIds List of job IDs - * @apiParam {String} earliestMs - * @apiParam {Number} latestMs - * @apiParam {Number} maxAnnotations Max limit of annotations returned + * @apiSchema (body) getAnnotationsSchema * * @apiSuccess {Boolean} success * @apiSuccess {Object} annotations @@ -57,7 +52,7 @@ export function annotationRoutes( { path: '/api/ml/annotations', validate: { - body: schema.object(getAnnotationsSchema), + body: getAnnotationsSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -83,14 +78,13 @@ export function annotationRoutes( * @apiName IndexAnnotations * @apiDescription Index the annotation. * - * @apiParam {Object} annotation - * @apiParam {String} username + * @apiSchema (body) indexAnnotationSchema */ router.put( { path: '/api/ml/annotations/index', validate: { - body: schema.object(indexAnnotationSchema), + body: indexAnnotationSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -124,17 +118,17 @@ export function annotationRoutes( /** * @apiGroup Annotations * - * @api {delete} /api/ml/annotations/index Deletes annotation + * @api {delete} /api/ml/annotations/delete/:annotationId Deletes annotation * @apiName DeleteAnnotation * @apiDescription Deletes specified annotation * - * @apiParam {String} annotationId + * @apiSchema (params) deleteAnnotationSchema */ router.delete( { path: '/api/ml/annotations/delete/{annotationId}', validate: { - params: schema.object(deleteAnnotationSchema), + params: deleteAnnotationSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { diff --git a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts index d03e76072c315..a675eb58dc792 100644 --- a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts +++ b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts @@ -10,6 +10,13 @@ import { RouteInitialization } from '../types'; import { anomalyDetectionJobSchema, anomalyDetectionUpdateJobSchema, + jobIdSchema, + getRecordsSchema, + getBucketsSchema, + getOverallBucketsSchema, + getCategoriesSchema, + forecastAnomalyDetector, + getBucketParamsSchema, } from './schemas/anomaly_detectors_schema'; /** @@ -50,15 +57,13 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { * @apiName GetAnomalyDetectorsById * @apiDescription Returns the anomaly detection job. * - * @apiParam {String} jobId Job ID. + * @apiSchema (params) jobIdSchema */ router.get( { path: '/api/ml/anomaly_detectors/{jobId}', validate: { - params: schema.object({ - jobId: schema.string(), - }), + params: jobIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -108,15 +113,13 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { * @apiName GetAnomalyDetectorsStatsById * @apiDescription Returns anomaly detection job statistics. * - * @apiParam {String} jobId Job ID. + * @apiSchema (params) jobIdSchema */ router.get( { path: '/api/ml/anomaly_detectors/{jobId}/_stats', validate: { - params: schema.object({ - jobId: schema.string(), - }), + params: jobIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -139,15 +142,14 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { * @apiName CreateAnomalyDetectors * @apiDescription Creates an anomaly detection job. * - * @apiParam {String} jobId Job ID. + * @apiSchema (params) jobIdSchema + * @apiSchema (body) anomalyDetectionJobSchema */ router.put( { path: '/api/ml/anomaly_detectors/{jobId}', validate: { - params: schema.object({ - jobId: schema.string(), - }), + params: jobIdSchema, body: schema.object(anomalyDetectionJobSchema), }, }, @@ -174,16 +176,15 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { * @apiName UpdateAnomalyDetectors * @apiDescription Updates certain properties of an anomaly detection job. * - * @apiParam {String} jobId Job ID. + * @apiSchema (params) jobIdSchema + * @apiSchema (body) anomalyDetectionUpdateJobSchema */ router.post( { path: '/api/ml/anomaly_detectors/{jobId}/_update', validate: { - params: schema.object({ - jobId: schema.string(), - }), - body: schema.object({ ...anomalyDetectionUpdateJobSchema }), + params: jobIdSchema, + body: anomalyDetectionUpdateJobSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -209,15 +210,13 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { * @apiName OpenAnomalyDetectorsJob * @apiDescription Opens an anomaly detection job. * - * @apiParam {String} jobId Job ID. + * @apiSchema (params) jobIdSchema */ router.post( { path: '/api/ml/anomaly_detectors/{jobId}/_open', validate: { - params: schema.object({ - jobId: schema.string(), - }), + params: jobIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -242,15 +241,13 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { * @apiName CloseAnomalyDetectorsJob * @apiDescription Closes an anomaly detection job. * - * @apiParam {String} jobId Job ID. + * @apiSchema (params) jobIdSchema */ router.post( { path: '/api/ml/anomaly_detectors/{jobId}/_close', validate: { - params: schema.object({ - jobId: schema.string(), - }), + params: jobIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -279,15 +276,13 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { * @apiName DeleteAnomalyDetectorsJob * @apiDescription Deletes specified anomaly detection job. * - * @apiParam {String} jobId Job ID. + * @apiSchema (params) jobIdSchema */ router.delete( { path: '/api/ml/anomaly_detectors/{jobId}', validate: { - params: schema.object({ - jobId: schema.string(), - }), + params: jobIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -315,8 +310,6 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/anomaly_detectors/_validate/detector Validate detector * @apiName ValidateAnomalyDetector * @apiDescription Validates specified detector. - * - * @apiParam {String} jobId Job ID. */ router.post( { @@ -346,16 +339,15 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { * @apiName ForecastAnomalyDetector * @apiDescription Creates a forecast for the specified anomaly detection job, predicting the future behavior of a time series by using its historical behavior. * - * @apiParam {String} jobId Job ID. + * @apiSchema (params) jobIdSchema + * @apiSchema (body) forecastAnomalyDetector */ router.post( { path: '/api/ml/anomaly_detectors/{jobId}/_forecast', validate: { - params: schema.object({ - jobId: schema.string(), - }), - body: schema.object({ duration: schema.any() }), + params: jobIdSchema, + body: forecastAnomalyDetector, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -382,7 +374,8 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { * @apiName GetRecords * @apiDescription Retrieves anomaly records for a job. * - * @apiParam {String} jobId Job ID. + * @apiSchema (params) jobIdSchema + * @apiSchema (body) getRecordsSchema * * @apiSuccess {Number} count * @apiSuccess {Object[]} records @@ -391,23 +384,8 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { { path: '/api/ml/anomaly_detectors/{jobId}/results/records', validate: { - params: schema.object({ - jobId: schema.string(), - }), - body: schema.object({ - desc: schema.maybe(schema.boolean()), - end: schema.maybe(schema.string()), - exclude_interim: schema.maybe(schema.boolean()), - page: schema.maybe( - schema.object({ - from: schema.maybe(schema.number()), - size: schema.maybe(schema.number()), - }) - ), - record_score: schema.maybe(schema.number()), - sort: schema.maybe(schema.string()), - start: schema.maybe(schema.string()), - }), + params: jobIdSchema, + body: getRecordsSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -432,8 +410,8 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { * @apiName GetBuckets * @apiDescription The get buckets API presents a chronological view of the records, grouped by bucket. * - * @apiParam {String} jobId Job ID. - * @apiParam {String} timestamp. + * @apiSchema (params) getBucketParamsSchema + * @apiSchema (body) getBucketsSchema * * @apiSuccess {Number} count * @apiSuccess {Object[]} buckets @@ -442,25 +420,8 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { { path: '/api/ml/anomaly_detectors/{jobId}/results/buckets/{timestamp?}', validate: { - params: schema.object({ - jobId: schema.string(), - timestamp: schema.maybe(schema.string()), - }), - body: schema.object({ - anomaly_score: schema.maybe(schema.number()), - desc: schema.maybe(schema.boolean()), - end: schema.maybe(schema.string()), - exclude_interim: schema.maybe(schema.boolean()), - expand: schema.maybe(schema.boolean()), - page: schema.maybe( - schema.object({ - from: schema.maybe(schema.number()), - size: schema.maybe(schema.number()), - }) - ), - sort: schema.maybe(schema.string()), - start: schema.maybe(schema.string()), - }), + params: getBucketParamsSchema, + body: getBucketsSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -486,7 +447,8 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { * @apiName GetOverallBuckets * @apiDescription Retrieves overall bucket results that summarize the bucket results of multiple anomaly detection jobs. * - * @apiParam {String} jobId Job ID. + * @apiSchema (params) jobIdSchema + * @apiSchema (body) getOverallBucketsSchema * * @apiSuccess {Number} count * @apiSuccess {Object[]} overall_buckets @@ -495,15 +457,8 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { { path: '/api/ml/anomaly_detectors/{jobId}/results/overall_buckets', validate: { - params: schema.object({ - jobId: schema.string(), - }), - body: schema.object({ - topN: schema.number(), - bucketSpan: schema.string(), - start: schema.number(), - end: schema.number(), - }), + params: jobIdSchema, + body: getOverallBucketsSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -531,17 +486,13 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { * @apiName GetCategories * @apiDescription Returns the categories results for the specified job ID and category ID. * - * @apiParam {String} jobId Job ID. - * @apiParam {String} categoryId Category ID. + * @apiSchema (params) getCategoriesSchema */ router.get( { path: '/api/ml/anomaly_detectors/{jobId}/results/categories/{categoryId}', validate: { - params: schema.object({ - categoryId: schema.string(), - jobId: schema.string(), - }), + params: getCategoriesSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index c5aa3e4d792fd..4848de6db7049 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -1,6 +1,6 @@ { "name": "ml_kibana_api", - "version": "0.1.0", + "version": "7.8.0", "description": "ML Kibana API", "title": "ML Kibana API", "order": [ @@ -9,61 +9,65 @@ "GetDataFrameAnalyticsById", "GetDataFrameAnalyticsStats", "GetDataFrameAnalyticsStatsById", - "UpdateDataFrameAnalytics", "EvaluateDataFrameAnalytics", "ExplainDataFrameAnalytics", - "DeleteDataFrameAnalytics", "StartDataFrameAnalyticsJob", "StopsDataFrameAnalyticsJob", "GetDataFrameAnalyticsMessages", + "UpdateDataFrameAnalytics", + "DeleteDataFrameAnalytics", + "DataVisualizer", "GetOverallStats", "GetStatsForFields", + "AnomalyDetectors", + "CreateAnomalyDetectors", + "OpenAnomalyDetectorsJob", "GetAnomalyDetectors", "GetAnomalyDetectorsById", "GetAnomalyDetectorsStats", "GetAnomalyDetectorsStatsById", - "CreateAnomalyDetectors", - "UpdateAnomalyDetectors", - "OpenAnomalyDetectorsJob", "CloseAnomalyDetectorsJob", - "DeleteAnomalyDetectorsJob", "ValidateAnomalyDetector", "ForecastAnomalyDetector", "GetRecords", "GetBuckets", "GetOverallBuckets", "GetCategories", + "UpdateAnomalyDetectors", + "DeleteAnomalyDetectorsJob", + "FileDataVisualizer", "AnalyzeFile", "ImportFile", + "ResultsService", "GetAnomaliesTableData", "GetCategoryDefinition", "GetMaxAnomalyScore", "GetCategoryExamples", "GetPartitionFieldsValues", + "DataRecognizer", "RecognizeIndex", "GetModule", "SetupModule", "CheckExistingModuleJobs", + "Annotations", "GetAnnotations", "IndexAnnotations", "DeleteAnnotation", + "JobService", "ForceStartDatafeeds", "StopDatafeeds", - "DeleteJobs", "CloseJobs", "JobsSummary", "JobsWithTimeRange", "CreateFullJobsList", "GetAllGroups", - "UpdateGroups", - "DeletingJobTasks", "JobsExist", "NewJobCaps", "NewJobLineChart", @@ -72,42 +76,60 @@ "GetLookBackProgress", "ValidateCategoryExamples", "TopCategories", + "UpdateGroups", + "DeletingJobTasks", + "DeleteJobs", + + "Calendars", + "PutCalendars", + "GetCalendars", + "GetCalendarById", + "UpdateCalendarById", + "DeleteCalendarById", + "Filters", + "CreateFilter", "GetFilters", "GetFilterById", - "CreateFilter", + "GetFiltersStats", "UpdateFilter", "DeleteFilter", - "GetFiltersStats", + "Indices", "FieldCaps", + "SystemRoutes", "HasPrivileges", "MlCapabilities", "MlNodeCount", "MlInfo", "MlEsSearch", + "JobAuditMessages", "GetJobAuditMessages", "GetAllJobAuditMessages", + "JobValidation", "EstimateBucketSpan", "CalculateModelMemoryLimit", "ValidateCardinality", "ValidateJob", + "NotificationSettings", "GetNotificationSettings", + "DatafeedService", + "CreateDatafeed", + "PreviewDatafeed", "GetDatafeeds", "GetDatafeed", "GetDatafeedsStats", "GetDatafeedStats", - "CreateDatafeed", "UpdateDatafeed", - "DeleteDatafeed", "StartDatafeed", "StopDatafeed", - "PreviewDatafeed", + "DeleteDatafeed", + "FieldsService", "GetCardinalityOfFields", "GetTimeFieldRange" diff --git a/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_extractor.ts b/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_extractor.ts new file mode 100644 index 0000000000000..01adcb462689e --- /dev/null +++ b/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_extractor.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as ts from 'typescript'; + +export interface DocEntry { + name: string; + documentation?: string; + type: string; + optional?: boolean; + nested?: DocEntry[]; +} + +/** Generate documentation for all schema definitions in a set of .ts files */ +export function extractDocumentation( + fileNames: string[], + options: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2015, + module: ts.ModuleKind.CommonJS, + } +): Map<string, DocEntry[]> { + // Build a program using the set of root file names in fileNames + const program = ts.createProgram(fileNames, options); + + // Get the checker, we will use it to find more about properties + const checker: ts.TypeChecker = program.getTypeChecker(); + + // Result map + const result = new Map<string, DocEntry[]>(); + + // Visit every sourceFile in the program + for (const sourceFile of program.getSourceFiles()) { + if (!sourceFile.isDeclarationFile) { + // Walk the tree to search for schemas + ts.forEachChild(sourceFile, visit); + } + } + + return result; + + /** visit nodes finding exported schemas */ + function visit(node: ts.Node) { + if (isNodeExported(node) && ts.isVariableDeclaration(node)) { + const schemaName = node.name.getText(); + const schemaType = checker.getTypeAtLocation(node); + result.set(schemaName, extractDocEntries(schemaType!)); + } + + if (node.getChildCount() > 0) { + ts.forEachChild(node, visit); + } + } + + /** + * Extracts doc entries for the schema definition + * @param schemaType + */ + function extractDocEntries(schemaType: ts.Type): DocEntry[] { + const collection: DocEntry[] = []; + + const members = getTypeMembers(schemaType); + + if (!members) { + return collection; + } + + members.forEach(member => { + collection.push(serializeProperty(member)); + }); + + return collection; + } + + /** + * Resolves members of the type + * @param type + */ + function getTypeMembers(type: ts.Type): ts.Symbol[] | undefined { + const argsOfType = checker.getTypeArguments((type as unknown) as ts.TypeReference); + + let members = type.getProperties(); + + if (argsOfType && argsOfType.length > 0) { + members = argsOfType[0].getProperties(); + } + + return members; + } + + function resolveTypeArgument(type: ts.Type): ts.SymbolTable | string { + // required to extract members + type.getProperty('type'); + + // @ts-ignores + let members = type.members; + + const typeArguments = checker.getTypeArguments((type as unknown) as ts.TypeReference); + + if (type.aliasTypeArguments) { + // @ts-ignores + members = type.aliasTypeArguments[0].members; + } + + if (typeArguments.length > 0) { + members = resolveTypeArgument(typeArguments[0]); + } + + if (members === undefined) { + members = checker.typeToString(type); + } + + return members; + } + + function serializeProperty(symbol: ts.Symbol): DocEntry { + // @ts-ignore + const typeOfSymbol = symbol.type; + const typeArguments = checker.getTypeArguments((typeOfSymbol as unknown) as ts.TypeReference); + + let resultType: ts.Type = typeOfSymbol; + + let members; + if (typeArguments.length > 0) { + members = resolveTypeArgument(typeArguments[0]); + resultType = typeArguments[0]; + } + + let typeAsString = checker.typeToString(resultType); + + const nestedEntries: DocEntry[] = []; + if (members && typeof members !== 'string' && members.size > 0) { + // we hit an object or collection + typeAsString = + resultType.symbol.name === 'Array' || typeOfSymbol.symbol.name === 'Array' + ? `${symbol.getName()}[]` + : symbol.getName(); + + members.forEach(member => { + nestedEntries.push(serializeProperty(member)); + }); + } + + return { + name: symbol.getName(), + documentation: getCommentString(symbol), + type: typeAsString, + ...(nestedEntries.length > 0 ? { nested: nestedEntries } : {}), + }; + } + + function getCommentString(symbol: ts.Symbol): string { + return ts.displayPartsToString(symbol.getDocumentationComment(checker)).replace(/\n/g, ' '); + } + + /** + * True if this is visible outside this file, false otherwise + */ + function isNodeExported(node: ts.Node): boolean { + return ( + // eslint-disable-next-line no-bitwise + (ts.getCombinedModifierFlags(node as ts.Declaration) & ts.ModifierFlags.Export) !== 0 || + (!!node.parent && node.parent.kind === ts.SyntaxKind.SourceFile) + ); + } +} diff --git a/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_parser.ts b/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_parser.ts new file mode 100644 index 0000000000000..eabe7dcd7bd8f --- /dev/null +++ b/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_parser.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. + */ + +function parse(content?: string) { + const schema = typeof content === 'string' && content.trim(); + + if (!schema) { + return null; + } + + const result = schema.match(/\((\w+)\)\s+(\w+)/); + + if (result === null || result.length < 3) { + throw new Error( + 'Invalid schema definition. Required format is `@apiSchema (<GROUP_NAME>) <SCHEMA_NAME>`' + ); + } + + const group = result[1]; + + return { + group, + name: result[2], + }; +} + +/** + * Exports + */ +module.exports = { + parse, + path: 'local.schemas', + method: 'push', +}; diff --git a/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_worker.ts b/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_worker.ts new file mode 100644 index 0000000000000..7514e482783b3 --- /dev/null +++ b/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_worker.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { DocEntry, extractDocumentation } from './schema_extractor'; +import { ApiParameter, Block } from './types'; + +export function postProcess(parsedFiles: any[]): void { + const schemasDirPath = `${__dirname}${path.sep}..${path.sep}..${path.sep}schemas${path.sep}`; + const schemaFiles = fs + .readdirSync(schemasDirPath) + .map(filename => path.resolve(schemasDirPath + filename)); + + const schemaDocs = extractDocumentation(schemaFiles); + + parsedFiles.forEach(parsedFile => { + parsedFile.forEach((block: Block) => { + const { + local: { schemas }, + } = block; + if (!schemas || schemas.length === 0) return; + + for (const schema of schemas) { + const { name: schemaName, group: paramsGroup } = schema; + const schemaFields = schemaDocs.get(schemaName); + + if (!schemaFields) return; + + updateBlockParameters(schemaFields, block, paramsGroup); + } + }); + }); +} + +/** + * Extracts schema's doc entries to apidoc parameters + * @param docEntries + * @param block + * @param paramsGroup + */ +function updateBlockParameters(docEntries: DocEntry[], block: Block, paramsGroup: string): void { + if (!block.local.parameter) { + block.local.parameter = { + fields: {}, + }; + } + + if (!block.local.parameter.fields![paramsGroup]) { + block.local.parameter.fields![paramsGroup] = []; + } + const collection = block.local.parameter.fields![paramsGroup] as ApiParameter[]; + + for (const field of docEntries) { + collection.push({ + group: paramsGroup, + type: escapeSpecial(field.type), + size: undefined, + allowedValues: undefined, + optional: !!field.optional, + field: field.name, + defaultValue: undefined, + description: field.documentation, + }); + + if (field.nested) { + updateBlockParameters(field.nested, block, field.name); + } + } +} + +/** + * Escape special character to make sure the markdown table isn't broken + */ +function escapeSpecial(str: string): string { + return str.replace(/\|/g, '\\|'); +} diff --git a/x-pack/plugins/ml/server/routes/apidoc_scripts/tsconfig.json b/x-pack/plugins/ml/server/routes/apidoc_scripts/tsconfig.json new file mode 100644 index 0000000000000..e3108b8c759f4 --- /dev/null +++ b/x-pack/plugins/ml/server/routes/apidoc_scripts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "target": "es6", + "moduleResolution": "node" + }, + "include": [ + "schema_worker.ts", + "schema_parser.ts", + "schema_extractor.ts", + "version_filter.ts" + ] +} diff --git a/x-pack/plugins/ml/server/routes/apidoc_scripts/types.ts b/x-pack/plugins/ml/server/routes/apidoc_scripts/types.ts new file mode 100644 index 0000000000000..08a443905ee05 --- /dev/null +++ b/x-pack/plugins/ml/server/routes/apidoc_scripts/types.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface ApiParameter { + group: string; + type: any; + size: undefined; + allowedValues: undefined; + optional: boolean; + field: string; + defaultValue: undefined; + description?: string; +} + +interface Local { + group: string; + type: string; + url: string; + title: string; + name: string; + description: string; + parameter: { + fields?: { + [key: string]: ApiParameter[] | undefined; + }; + }; + success: { fields: ObjectConstructor[] }; + version: string; + filename: string; + schemas?: Array<{ + name: string; + group: string; + }>; +} + +export interface Block { + global: any; + local: Local; +} diff --git a/x-pack/plugins/ml/server/routes/apidoc_scripts/version_filter.ts b/x-pack/plugins/ml/server/routes/apidoc_scripts/version_filter.ts new file mode 100644 index 0000000000000..8cbe38d667b2c --- /dev/null +++ b/x-pack/plugins/ml/server/routes/apidoc_scripts/version_filter.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 { Block } from './types'; + +const API_VERSION = '7.8.0'; + +/** + * Post Filter parsed results. + * Updates api version of the endpoints. + */ +export function postFilter(parsedFiles: any[]) { + parsedFiles.forEach(parsedFile => { + parsedFile.forEach((block: Block) => { + block.local.version = API_VERSION; + }); + }); +} diff --git a/x-pack/plugins/ml/server/routes/calendars.ts b/x-pack/plugins/ml/server/routes/calendars.ts index 34950c6ed79f7..a17601f74ae93 100644 --- a/x-pack/plugins/ml/server/routes/calendars.ts +++ b/x-pack/plugins/ml/server/routes/calendars.ts @@ -5,10 +5,9 @@ */ import { RequestHandlerContext } from 'kibana/server'; -import { schema } from '@kbn/config-schema'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; -import { calendarSchema } from './schemas/calendars_schema'; +import { calendarSchema, calendarIdSchema, calendarIdsSchema } from './schemas/calendars_schema'; import { CalendarManager, Calendar, FormCalendar } from '../models/calendar'; function getAllCalendars(context: RequestHandlerContext) { @@ -42,7 +41,13 @@ function getCalendarsByIds(context: RequestHandlerContext, calendarIds: string) } export function calendars({ router, mlLicense }: RouteInitialization) { - // Gets calendars - size limit has been explicitly set to 1000 + /** + * @apiGroup Calendars + * + * @api {get} /api/ml/calendars Gets calendars + * @apiName GetCalendars + * @apiDescription Gets calendars - size limit has been explicitly set to 1000 + */ router.get( { path: '/api/ml/calendars', @@ -61,11 +66,20 @@ export function calendars({ router, mlLicense }: RouteInitialization) { }) ); + /** + * @apiGroup Calendars + * + * @api {get} /api/ml/calendars/:calendarIds Gets a calendar + * @apiName GetCalendarById + * @apiDescription Gets calendar by id + * + * @apiSchema (params) calendarIdsSchema + */ router.get( { path: '/api/ml/calendars/{calendarIds}', validate: { - params: schema.object({ calendarIds: schema.string() }), + params: calendarIdsSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -88,11 +102,20 @@ export function calendars({ router, mlLicense }: RouteInitialization) { }) ); + /** + * @apiGroup Calendars + * + * @api {put} /api/ml/calendars Creates a calendar + * @apiName PutCalendars + * @apiDescription Creates a calendar + * + * @apiSchema (body) calendarSchema + */ router.put( { path: '/api/ml/calendars', validate: { - body: schema.object({ ...calendarSchema }), + body: calendarSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -109,12 +132,22 @@ export function calendars({ router, mlLicense }: RouteInitialization) { }) ); + /** + * @apiGroup Calendars + * + * @api {put} /api/ml/calendars/:calendarId Updates a calendar + * @apiName UpdateCalendarById + * @apiDescription Updates a calendar + * + * @apiSchema (params) calendarIdSchema + * @apiSchema (body) calendarSchema + */ router.put( { path: '/api/ml/calendars/{calendarId}', validate: { - params: schema.object({ calendarId: schema.string() }), - body: schema.object({ ...calendarSchema }), + params: calendarIdSchema, + body: calendarSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -132,11 +165,20 @@ export function calendars({ router, mlLicense }: RouteInitialization) { }) ); + /** + * @apiGroup Calendars + * + * @api {delete} /api/ml/calendars/:calendarId Deletes a calendar + * @apiName DeleteCalendarById + * @apiDescription Deletes a calendar + * + * @apiSchema (params) calendarIdSchema + */ router.delete( { path: '/api/ml/calendars/{calendarId}', validate: { - params: schema.object({ calendarId: schema.string() }), + params: calendarIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { diff --git a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts index 7ed1aa02b24ab..dd9e0ea66aa9d 100644 --- a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts +++ b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; import { wrapError } from '../client/error_wrapper'; import { analyticsAuditMessagesProvider } from '../models/data_frame_analytics/analytics_audit_messages'; import { RouteInitialization } from '../types'; @@ -12,6 +11,8 @@ import { dataAnalyticsJobConfigSchema, dataAnalyticsEvaluateSchema, dataAnalyticsExplainSchema, + analyticsIdSchema, + stopsDataFrameAnalyticsJobQuerySchema, } from './schemas/data_analytics_schema'; /** @@ -31,9 +32,7 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat router.get( { path: '/api/ml/data_frame/analytics', - validate: { - params: schema.object({ analyticsId: schema.maybe(schema.string()) }), - }, + validate: false, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { @@ -54,13 +53,13 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat * @apiName GetDataFrameAnalyticsById * @apiDescription Returns the data frame analytics job. * - * @apiParam {String} analyticsId Analytics ID. + * @apiSchema (params) analyticsIdSchema */ router.get( { path: '/api/ml/data_frame/analytics/{analyticsId}', validate: { - params: schema.object({ analyticsId: schema.string() }), + params: analyticsIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -111,13 +110,13 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat * @apiName GetDataFrameAnalyticsStatsById * @apiDescription Returns data frame analytics job statistics. * - * @apiParam {String} analyticsId Analytics ID. + * @apiSchema (params) analyticsIdSchema */ router.get( { path: '/api/ml/data_frame/analytics/{analyticsId}/_stats', validate: { - params: schema.object({ analyticsId: schema.string() }), + params: analyticsIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -146,16 +145,15 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat * @apiDescription This API creates a data frame analytics job that performs an analysis * on the source index and stores the outcome in a destination index. * - * @apiParam {String} analyticsId Analytics ID. + * @apiSchema (params) analyticsIdSchema + * @apiSchema (body) dataAnalyticsJobConfigSchema */ router.put( { path: '/api/ml/data_frame/analytics/{analyticsId}', validate: { - params: schema.object({ - analyticsId: schema.string(), - }), - body: schema.object(dataAnalyticsJobConfigSchema), + params: analyticsIdSchema, + body: dataAnalyticsJobConfigSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -183,12 +181,14 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat * @api {post} /api/ml/data_frame/_evaluate Evaluate the data frame analytics for an annotated index * @apiName EvaluateDataFrameAnalytics * @apiDescription Evaluates the data frame analytics for an annotated index. + * + * @apiSchema (body) dataAnalyticsEvaluateSchema */ router.post( { path: '/api/ml/data_frame/_evaluate', validate: { - body: schema.object({ ...dataAnalyticsEvaluateSchema }), + body: dataAnalyticsEvaluateSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -216,19 +216,13 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat * @apiDescription This API provides explanations for a data frame analytics config * that either exists already or one that has not been created yet. * - * @apiParam {String} [description] - * @apiParam {Object} [dest] - * @apiParam {Object} source - * @apiParam {String} source.index - * @apiParam {Object} analysis - * @apiParam {Object} [analyzed_fields] - * @apiParam {String} [model_memory_limit] + * @apiSchema (body) dataAnalyticsExplainSchema */ router.post( { path: '/api/ml/data_frame/analytics/_explain', validate: { - body: schema.object({ ...dataAnalyticsExplainSchema }), + body: dataAnalyticsExplainSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -255,15 +249,13 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat * @apiName DeleteDataFrameAnalytics * @apiDescription Deletes specified data frame analytics job. * - * @apiParam {String} analyticsId Analytics ID. + * @apiSchema (params) analyticsIdSchema */ router.delete( { path: '/api/ml/data_frame/analytics/{analyticsId}', validate: { - params: schema.object({ - analyticsId: schema.string(), - }), + params: analyticsIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -291,15 +283,13 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat * @apiName StartDataFrameAnalyticsJob * @apiDescription Starts a data frame analytics job. * - * @apiParam {String} analyticsId Analytics ID. + * @apiSchema (params) analyticsIdSchema */ router.post( { path: '/api/ml/data_frame/analytics/{analyticsId}/_start', validate: { - params: schema.object({ - analyticsId: schema.string(), - }), + params: analyticsIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -324,16 +314,15 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat * @apiName StopsDataFrameAnalyticsJob * @apiDescription Stops a data frame analytics job. * - * @apiParam {String} analyticsId Analytics ID. + * @apiSchema (params) analyticsIdSchema + * @apiSchema (query) stopsDataFrameAnalyticsJobQuerySchema */ router.post( { path: '/api/ml/data_frame/analytics/{analyticsId}/_stop', validate: { - params: schema.object({ - analyticsId: schema.string(), - force: schema.maybe(schema.boolean()), - }), + params: analyticsIdSchema, + query: stopsDataFrameAnalyticsJobQuerySchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -367,13 +356,13 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat * @apiName GetDataFrameAnalyticsMessages * @apiDescription Returns the list of audit messages for data frame analytics jobs. * - * @apiParam {String} analyticsId Analytics ID. + * @apiSchema (params) analyticsIdSchema */ router.get( { path: '/api/ml/data_frame/analytics/{analyticsId}/messages', validate: { - params: schema.object({ analyticsId: schema.string() }), + params: analyticsIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { diff --git a/x-pack/plugins/ml/server/routes/data_visualizer.ts b/x-pack/plugins/ml/server/routes/data_visualizer.ts index b37c80b815e1a..a4c0d5553a4b2 100644 --- a/x-pack/plugins/ml/server/routes/data_visualizer.ts +++ b/x-pack/plugins/ml/server/routes/data_visualizer.ts @@ -11,6 +11,7 @@ import { Field } from '../models/data_visualizer/data_visualizer'; import { dataVisualizerFieldStatsSchema, dataVisualizerOverallStatsSchema, + indexPatternTitleSchema, } from './schemas/data_visualizer_schema'; import { RouteInitialization } from '../types'; @@ -75,12 +76,16 @@ export function dataVisualizerRoutes({ router, mlLicense }: RouteInitialization) * @apiName GetStatsForFields * @apiDescription Returns fields stats of the index pattern. * - * @apiParam {String} indexPatternTitle Index pattern title. + * @apiSchema (params) indexPatternTitleSchema + * @apiSchema (body) dataVisualizerFieldStatsSchema */ router.post( { path: '/api/ml/data_visualizer/get_field_stats/{indexPatternTitle}', - validate: dataVisualizerFieldStatsSchema, + validate: { + params: indexPatternTitleSchema, + body: dataVisualizerFieldStatsSchema, + }, }, mlLicense.basicLicenseAPIGuard(async (context, request, response) => { try { @@ -127,12 +132,16 @@ export function dataVisualizerRoutes({ router, mlLicense }: RouteInitialization) * @apiName GetOverallStats * @apiDescription Returns overall stats of the index pattern. * - * @apiParam {String} indexPatternTitle Index pattern title. + * @apiSchema (params) indexPatternTitleSchema + * @apiSchema (body) dataVisualizerOverallStatsSchema */ router.post( { path: '/api/ml/data_visualizer/get_overall_stats/{indexPatternTitle}', - validate: dataVisualizerOverallStatsSchema, + validate: { + params: indexPatternTitleSchema, + body: dataVisualizerOverallStatsSchema, + }, }, mlLicense.basicLicenseAPIGuard(async (context, request, response) => { try { diff --git a/x-pack/plugins/ml/server/routes/datafeeds.ts b/x-pack/plugins/ml/server/routes/datafeeds.ts index c1ee839340996..ec667e1d305f5 100644 --- a/x-pack/plugins/ml/server/routes/datafeeds.ts +++ b/x-pack/plugins/ml/server/routes/datafeeds.ts @@ -4,10 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; -import { startDatafeedSchema, datafeedConfigSchema } from './schemas/datafeeds_schema'; +import { + startDatafeedSchema, + datafeedConfigSchema, + datafeedIdSchema, + deleteDatafeedQuerySchema, +} from './schemas/datafeeds_schema'; /** * Routes for datafeed service @@ -44,12 +48,14 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { * @api {get} /api/ml/datafeeds/:datafeedId Get datafeed for given datafeed id * @apiName GetDatafeed * @apiDescription Retrieves configuration information for datafeed + * + * @apiSchema (params) datafeedIdSchema */ router.get( { path: '/api/ml/datafeeds/{datafeedId}', validate: { - params: schema.object({ datafeedId: schema.string() }), + params: datafeedIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -97,12 +103,14 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { * @api {get} /api/ml/datafeeds/:datafeedId/_stats Get datafeed stats for given datafeed id * @apiName GetDatafeedStats * @apiDescription Retrieves usage information for datafeed + * + * @apiSchema (params) datafeedIdSchema */ router.get( { path: '/api/ml/datafeeds/{datafeedId}/_stats', validate: { - params: schema.object({ datafeedId: schema.string() }), + params: datafeedIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -127,12 +135,15 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { * @api {put} /api/ml/datafeeds/:datafeedId Creates datafeed * @apiName CreateDatafeed * @apiDescription Instantiates a datafeed + * + * @apiSchema (params) datafeedIdSchema + * @apiSchema (body) datafeedConfigSchema */ router.put( { path: '/api/ml/datafeeds/{datafeedId}', validate: { - params: schema.object({ datafeedId: schema.string() }), + params: datafeedIdSchema, body: datafeedConfigSchema, }, }, @@ -159,12 +170,15 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/datafeeds/:datafeedId/_update Updates datafeed for given datafeed id * @apiName UpdateDatafeed * @apiDescription Updates certain properties of a datafeed + * + * @apiSchema (params) datafeedIdSchema + * @apiSchema (body) datafeedConfigSchema */ router.post( { path: '/api/ml/datafeeds/{datafeedId}/_update', validate: { - params: schema.object({ datafeedId: schema.string() }), + params: datafeedIdSchema, body: datafeedConfigSchema, }, }, @@ -191,13 +205,16 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { * @api {delete} /api/ml/datafeeds/:datafeedId Deletes datafeed * @apiName DeleteDatafeed * @apiDescription Deletes an existing datafeed + * + * @apiSchema (params) datafeedIdSchema + * @apiSchema (query) deleteDatafeedQuerySchema */ router.delete( { path: '/api/ml/datafeeds/{datafeedId}', validate: { - params: schema.object({ datafeedId: schema.string() }), - query: schema.maybe(schema.object({ force: schema.maybe(schema.any()) })), + params: datafeedIdSchema, + query: deleteDatafeedQuerySchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -227,12 +244,15 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/datafeeds/:datafeedId/_start Starts datafeed for given datafeed id(s) * @apiName StartDatafeed * @apiDescription Starts one or more datafeeds + * + * @apiSchema (params) datafeedIdSchema + * @apiSchema (body) startDatafeedSchema */ router.post( { path: '/api/ml/datafeeds/{datafeedId}/_start', validate: { - params: schema.object({ datafeedId: schema.string() }), + params: datafeedIdSchema, body: startDatafeedSchema, }, }, @@ -262,12 +282,14 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/datafeeds/:datafeedId/_stop Stops datafeed for given datafeed id(s) * @apiName StopDatafeed * @apiDescription Stops one or more datafeeds + * + * @apiSchema (params) datafeedIdSchema */ router.post( { path: '/api/ml/datafeeds/{datafeedId}/_stop', validate: { - params: schema.object({ datafeedId: schema.string() }), + params: datafeedIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -293,12 +315,14 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { * @api {get} /api/ml/datafeeds/:datafeedId/_preview Preview datafeed for given datafeed id * @apiName PreviewDatafeed * @apiDescription Previews a datafeed + * + * @apiSchema (params) datafeedIdSchema */ router.get( { path: '/api/ml/datafeeds/{datafeedId}/_preview', validate: { - params: schema.object({ datafeedId: schema.string() }), + params: datafeedIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { diff --git a/x-pack/plugins/ml/server/routes/fields_service.ts b/x-pack/plugins/ml/server/routes/fields_service.ts index db7613b163457..9a5f47409c8a0 100644 --- a/x-pack/plugins/ml/server/routes/fields_service.ts +++ b/x-pack/plugins/ml/server/routes/fields_service.ts @@ -35,6 +35,8 @@ export function fieldsService({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/fields_service/field_cardinality Get cardinality of fields * @apiName GetCardinalityOfFields * @apiDescription Returns the cardinality of one or more fields. Returns an Object whose keys are the names of the fields, with values equal to the cardinality of the field + * + * @apiSchema (body) getCardinalityOfFieldsSchema */ router.post( { @@ -63,6 +65,8 @@ export function fieldsService({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/fields_service/time_field_range Get time field range * @apiName GetTimeFieldRange * @apiDescription Returns the timefield range for the given index + * + * @apiSchema (body) getTimeFieldRangeSchema */ router.post( { diff --git a/x-pack/plugins/ml/server/routes/file_data_visualizer.ts b/x-pack/plugins/ml/server/routes/file_data_visualizer.ts index b915d13aa9720..3f3fc3f547b6a 100644 --- a/x-pack/plugins/ml/server/routes/file_data_visualizer.ts +++ b/x-pack/plugins/ml/server/routes/file_data_visualizer.ts @@ -6,7 +6,7 @@ import { schema } from '@kbn/config-schema'; import { RequestHandlerContext } from 'kibana/server'; -import { MAX_BYTES } from '../../common/constants/file_datavisualizer'; +import { MAX_FILE_SIZE_BYTES } from '../../common/constants/file_datavisualizer'; import { InputOverrides, Settings, @@ -22,6 +22,11 @@ import { import { RouteInitialization } from '../types'; import { updateTelemetry } from '../lib/telemetry'; +import { + analyzeFileQuerySchema, + importFileBodySchema, + importFileQuerySchema, +} from './schemas/file_data_visualizer_schema'; function analyzeFiles(context: RequestHandlerContext, data: InputData, overrides: InputOverrides) { const { analyzeFile } = fileDataVisualizerProvider(context.ml!.mlClient.callAsCurrentUser); @@ -51,35 +56,20 @@ export function fileDataVisualizerRoutes({ router, mlLicense }: RouteInitializat * @api {post} /api/ml/file_data_visualizer/analyze_file Analyze file data * @apiName AnalyzeFile * @apiDescription Performs analysis of the file data. + * + * @apiSchema (query) analyzeFileQuerySchema */ router.post( { path: '/api/ml/file_data_visualizer/analyze_file', validate: { body: schema.any(), - query: schema.maybe( - schema.object({ - charset: schema.maybe(schema.string()), - column_names: schema.maybe(schema.string()), - delimiter: schema.maybe(schema.string()), - explain: schema.maybe(schema.string()), - format: schema.maybe(schema.string()), - grok_pattern: schema.maybe(schema.string()), - has_header_row: schema.maybe(schema.string()), - line_merge_size_limit: schema.maybe(schema.string()), - lines_to_sample: schema.maybe(schema.string()), - quote: schema.maybe(schema.string()), - should_trim_fields: schema.maybe(schema.string()), - timeout: schema.maybe(schema.string()), - timestamp_field: schema.maybe(schema.string()), - timestamp_format: schema.maybe(schema.string()), - }) - ), + query: analyzeFileQuerySchema, }, options: { body: { accepts: ['text/*', 'application/json'], - maxBytes: MAX_BYTES, + maxBytes: MAX_FILE_SIZE_BYTES, }, }, }, @@ -99,29 +89,21 @@ export function fileDataVisualizerRoutes({ router, mlLicense }: RouteInitializat * @api {post} /api/ml/file_data_visualizer/import Import file data * @apiName ImportFile * @apiDescription Imports file data into elasticsearch index. + * + * @apiSchema (query) importFileQuerySchema + * @apiSchema (body) importFileBodySchema */ router.post( { path: '/api/ml/file_data_visualizer/import', validate: { - query: schema.object({ - id: schema.maybe(schema.string()), - }), - body: schema.object({ - index: schema.maybe(schema.string()), - data: schema.arrayOf(schema.any()), - settings: schema.maybe(schema.any()), - mappings: schema.any(), - ingestPipeline: schema.object({ - id: schema.maybe(schema.string()), - pipeline: schema.maybe(schema.any()), - }), - }), + query: importFileQuerySchema, + body: importFileBodySchema, }, options: { body: { accepts: ['application/json'], - maxBytes: MAX_BYTES, + maxBytes: MAX_FILE_SIZE_BYTES, }, }, }, diff --git a/x-pack/plugins/ml/server/routes/filters.ts b/x-pack/plugins/ml/server/routes/filters.ts index e827ed96b12af..738c25070358d 100644 --- a/x-pack/plugins/ml/server/routes/filters.ts +++ b/x-pack/plugins/ml/server/routes/filters.ts @@ -5,10 +5,9 @@ */ import { RequestHandlerContext } from 'kibana/server'; -import { schema } from '@kbn/config-schema'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; -import { createFilterSchema, updateFilterSchema } from './schemas/filters_schema'; +import { createFilterSchema, filterIdSchema, updateFilterSchema } from './schemas/filters_schema'; import { FilterManager, FormFilter } from '../models/filter'; // TODO - add function for returning a list of just the filter IDs. @@ -79,6 +78,8 @@ export function filtersRoutes({ router, mlLicense }: RouteInitialization) { * @apiName GetFilterById * @apiDescription Retrieves the filter with the specified ID. * + * @apiSchema (params) filterIdSchema + * * @apiSuccess {Boolean} success * @apiSuccess {Object} filter the filter with the specified ID */ @@ -86,7 +87,7 @@ export function filtersRoutes({ router, mlLicense }: RouteInitialization) { { path: '/api/ml/filters/{filterId}', validate: { - params: schema.object({ filterId: schema.string() }), + params: filterIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -108,6 +109,8 @@ export function filtersRoutes({ router, mlLicense }: RouteInitialization) { * @apiName CreateFilter * @apiDescription Instantiates a filter, for use by custom rules in anomaly detection. * + * @apiSchema (body) createFilterSchema + * * @apiSuccess {Boolean} success * @apiSuccess {Object} filter created filter */ @@ -115,7 +118,7 @@ export function filtersRoutes({ router, mlLicense }: RouteInitialization) { { path: '/api/ml/filters', validate: { - body: schema.object(createFilterSchema), + body: createFilterSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -139,6 +142,9 @@ export function filtersRoutes({ router, mlLicense }: RouteInitialization) { * @apiName UpdateFilter * @apiDescription Updates the description of a filter, adds items or removes items. * + * @apiSchema (params) filterIdSchema + * @apiSchema (body) updateFilterSchema + * * @apiSuccess {Boolean} success * @apiSuccess {Object} filter updated filter */ @@ -146,8 +152,8 @@ export function filtersRoutes({ router, mlLicense }: RouteInitialization) { { path: '/api/ml/filters/{filterId}', validate: { - params: schema.object({ filterId: schema.string() }), - body: schema.object(updateFilterSchema), + params: filterIdSchema, + body: updateFilterSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -172,13 +178,13 @@ export function filtersRoutes({ router, mlLicense }: RouteInitialization) { * @apiName DeleteFilter * @apiDescription Deletes the filter with the specified ID. * - * @apiParam {String} filterId the ID of the filter to delete + * @apiSchema (params) filterIdSchema */ router.delete( { path: '/api/ml/filters/{filterId}', validate: { - params: schema.object({ filterId: schema.string() }), + params: filterIdSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { diff --git a/x-pack/plugins/ml/server/routes/indices.ts b/x-pack/plugins/ml/server/routes/indices.ts index fe66cc8b01396..e434936beba63 100644 --- a/x-pack/plugins/ml/server/routes/indices.ts +++ b/x-pack/plugins/ml/server/routes/indices.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; +import { indicesSchema } from './schemas/indices_schema'; /** * Indices routes. @@ -15,18 +15,17 @@ export function indicesRoutes({ router, mlLicense }: RouteInitialization) { /** * @apiGroup Indices * - * @api {post} /api/ml/indices/field_caps + * @api {post} /api/ml/indices/field_caps Field caps * @apiName FieldCaps * @apiDescription Retrieves the capabilities of fields among multiple indices. + * + * @apiSchema (body) indicesSchema */ router.post( { path: '/api/ml/indices/field_caps', validate: { - body: schema.object({ - index: schema.maybe(schema.string()), - fields: schema.maybe(schema.arrayOf(schema.string())), - }), + body: indicesSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { diff --git a/x-pack/plugins/ml/server/routes/job_audit_messages.ts b/x-pack/plugins/ml/server/routes/job_audit_messages.ts index 5c6d8023cc172..71499748691f6 100644 --- a/x-pack/plugins/ml/server/routes/job_audit_messages.ts +++ b/x-pack/plugins/ml/server/routes/job_audit_messages.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; import { jobAuditMessagesProvider } from '../models/job_audit_messages'; +import { jobAuditMessagesQuerySchema, jobIdSchema } from './schemas/job_audit_messages_schema'; /** * Routes for job audit message routes @@ -19,13 +19,16 @@ export function jobAuditMessagesRoutes({ router, mlLicense }: RouteInitializatio * @api {get} /api/ml/job_audit_messages/messages/:jobId Get audit messages * @apiName GetJobAuditMessages * @apiDescription Returns audit messages for specified job ID + * + * @apiSchema (params) jobIdSchema + * @apiSchema (query) jobAuditMessagesQuerySchema */ router.get( { path: '/api/ml/job_audit_messages/messages/{jobId}', validate: { - params: schema.object({ jobId: schema.maybe(schema.string()) }), - query: schema.maybe(schema.object({ from: schema.maybe(schema.any()) })), + params: jobIdSchema, + query: jobAuditMessagesQuerySchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -52,13 +55,14 @@ export function jobAuditMessagesRoutes({ router, mlLicense }: RouteInitializatio * @api {get} /api/ml/job_audit_messages/messages Get all audit messages * @apiName GetAllJobAuditMessages * @apiDescription Returns all audit messages + * + * @apiSchema (query) jobAuditMessagesQuerySchema */ router.get( { path: '/api/ml/job_audit_messages/messages', validate: { - params: schema.object({ jobId: schema.maybe(schema.string()) }), - query: schema.maybe(schema.object({ from: schema.maybe(schema.any()) })), + query: jobAuditMessagesQuerySchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { diff --git a/x-pack/plugins/ml/server/routes/job_service.ts b/x-pack/plugins/ml/server/routes/job_service.ts index 718f9e81603b1..493974cbafe36 100644 --- a/x-pack/plugins/ml/server/routes/job_service.ts +++ b/x-pack/plugins/ml/server/routes/job_service.ts @@ -53,12 +53,14 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/jobs/force_start_datafeeds Start datafeeds * @apiName ForceStartDatafeeds * @apiDescription Starts one or more datafeeds + * + * @apiSchema (body) forceStartDatafeedSchema */ router.post( { path: '/api/ml/jobs/force_start_datafeeds', validate: { - body: schema.object(forceStartDatafeedSchema), + body: forceStartDatafeedSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -82,12 +84,14 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/jobs/stop_datafeeds Stop datafeeds * @apiName StopDatafeeds * @apiDescription Stops one or more datafeeds + * + * @apiSchema (body) datafeedIdsSchema */ router.post( { path: '/api/ml/jobs/stop_datafeeds', validate: { - body: schema.object(datafeedIdsSchema), + body: datafeedIdsSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -111,12 +115,14 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/jobs/delete_jobs Delete jobs * @apiName DeleteJobs * @apiDescription Deletes an existing anomaly detection job + * + * @apiSchema (body) jobIdsSchema */ router.post( { path: '/api/ml/jobs/delete_jobs', validate: { - body: schema.object(jobIdsSchema), + body: jobIdsSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -140,12 +146,14 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/jobs/close_jobs Close jobs * @apiName CloseJobs * @apiDescription Closes one or more anomaly detection jobs + * + * @apiSchema (body) jobIdsSchema */ router.post( { path: '/api/ml/jobs/close_jobs', validate: { - body: schema.object(jobIdsSchema), + body: jobIdsSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -169,12 +177,14 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/jobs/jobs_summary Jobs summary * @apiName JobsSummary * @apiDescription Creates a summary jobs list. Jobs include job stats, datafeed stats, and calendars. + * + * @apiSchema (body) jobIdsSchema */ router.post( { path: '/api/ml/jobs/jobs_summary', validate: { - body: schema.object(jobIdsSchema), + body: jobIdsSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -198,6 +208,8 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/jobs/jobs_with_time_range Jobs with time range * @apiName JobsWithTimeRange * @apiDescription Creates a list of jobs with data about the job's time range + * + * @apiSchema (body) jobsWithTimerangeSchema */ router.post( { @@ -226,12 +238,14 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/jobs/jobs Create jobs list * @apiName CreateFullJobsList * @apiDescription Creates a list of jobs + * + * @apiSchema (body) jobIdsSchema */ router.post( { path: '/api/ml/jobs/jobs', validate: { - body: schema.object(jobIdsSchema), + body: jobIdsSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -281,6 +295,8 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/jobs/update_groups Update job groups * @apiName UpdateGroups * @apiDescription Updates 'groups' property of an anomaly detection job + * + * @apiSchema (body) updateGroupsSchema */ router.post( { @@ -336,12 +352,14 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/jobs/jobs_exist Check if jobs exist * @apiName JobsExist * @apiDescription Checks if each of the jobs in the specified list of IDs exist + * + * @apiSchema (body) jobIdsSchema */ router.post( { path: '/api/ml/jobs/jobs_exist', validate: { - body: schema.object(jobIdsSchema), + body: jobIdsSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -397,6 +415,8 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/jobs/new_job_line_chart Get job line chart data * @apiName NewJobLineChart * @apiDescription Returns line chart data for anomaly detection job + * + * @apiSchema (body) chartSchema */ router.post( { @@ -447,6 +467,8 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/jobs/new_job_population_chart Get population job chart data * @apiName NewJobPopulationChart * @apiDescription Returns population job chart data + * + * @apiSchema (body) chartSchema */ router.post( { @@ -523,6 +545,8 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/jobs/look_back_progress Get lookback progress * @apiName GetLookBackProgress * @apiDescription Returns current progress of anomaly detection job + * + * @apiSchema (body) lookBackProgressSchema */ router.post( { @@ -552,6 +576,8 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/jobs/categorization_field_examples Get categorization field examples * @apiName ValidateCategoryExamples * @apiDescription Validates category examples + * + * @apiSchema (body) categorizationFieldExamplesSchema */ router.post( { @@ -611,6 +637,8 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { * @api {post} /api/ml/jobs/top_categories Get top categories * @apiName TopCategories * @apiDescription Returns list of top categories + * + * @apiSchema (body) topCategoriesSchema */ router.post( { diff --git a/x-pack/plugins/ml/server/routes/job_validation.ts b/x-pack/plugins/ml/server/routes/job_validation.ts index 75d9cdf375049..dd2bd9deadf43 100644 --- a/x-pack/plugins/ml/server/routes/job_validation.ts +++ b/x-pack/plugins/ml/server/routes/job_validation.ts @@ -6,7 +6,7 @@ import Boom from 'boom'; import { RequestHandlerContext } from 'kibana/server'; -import { schema, TypeOf } from '@kbn/config-schema'; +import { TypeOf } from '@kbn/config-schema'; import { AnalysisConfig } from '../../common/types/anomaly_detection_jobs'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; @@ -48,6 +48,8 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, * @api {post} /api/ml/validate/estimate_bucket_span Estimate bucket span * @apiName EstimateBucketSpan * @apiDescription Estimates minimum viable bucket span based on the characteristics of a pre-viewed subset of the data + * + * @apiSchema (body) estimateBucketSpanSchema */ router.post( { @@ -94,6 +96,8 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, * @apiName CalculateModelMemoryLimit * @apiDescription Calls _estimate_model_memory endpoint to retrieve model memory estimation. * + * @apiSchema (body) modelMemoryLimitSchema + * * @apiSuccess {String} modelMemoryLimit */ router.post( @@ -122,12 +126,14 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, * @api {post} /api/ml/validate/cardinality Validate cardinality * @apiName ValidateCardinality * @apiDescription Validates cardinality for the given job configuration + * + * @apiSchema (body) validateCardinalitySchema */ router.post( { path: '/api/ml/validate/cardinality', validate: { - body: schema.object(validateCardinalitySchema), + body: validateCardinalitySchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -152,6 +158,8 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, * @api {post} /api/ml/validate/job Validates job * @apiName ValidateJob * @apiDescription Validates the given job configuration + * + * @apiSchema (body) validateJobSchema */ router.post( { diff --git a/x-pack/plugins/ml/server/routes/modules.ts b/x-pack/plugins/ml/server/routes/modules.ts index 358cd0ac2871c..2d462b6dc207a 100644 --- a/x-pack/plugins/ml/server/routes/modules.ts +++ b/x-pack/plugins/ml/server/routes/modules.ts @@ -152,7 +152,7 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { * @apiName SetupModule * @apiDescription Created module items. * - * @apiParam {String} moduleId Module id + * @apiSchema (body) setupModuleBodySchema */ router.post( { diff --git a/x-pack/plugins/ml/server/routes/results_service.ts b/x-pack/plugins/ml/server/routes/results_service.ts index 9849410eaf0d4..89c267340fe52 100644 --- a/x-pack/plugins/ml/server/routes/results_service.ts +++ b/x-pack/plugins/ml/server/routes/results_service.ts @@ -5,7 +5,6 @@ */ import { RequestHandlerContext } from 'kibana/server'; -import { schema } from '@kbn/config-schema'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; import { @@ -80,12 +79,14 @@ export function resultsServiceRoutes({ router, mlLicense }: RouteInitialization) * @api {post} /api/ml/results/anomalies_table_data Prepare anomalies records for table display * @apiName GetAnomaliesTableData * @apiDescription Retrieves anomaly records for an anomaly detection job and formats them for anomalies table display + * + * @apiSchema (body) anomaliesTableDataSchema */ router.post( { path: '/api/ml/results/anomalies_table_data', validate: { - body: schema.object(anomaliesTableDataSchema), + body: anomaliesTableDataSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -107,12 +108,14 @@ export function resultsServiceRoutes({ router, mlLicense }: RouteInitialization) * @api {post} /api/ml/results/category_definition Returns category definition * @apiName GetCategoryDefinition * @apiDescription Returns the definition of the category with the specified ID and job ID + * + * @apiSchema (body) categoryDefinitionSchema */ router.post( { path: '/api/ml/results/category_definition', validate: { - body: schema.object(categoryDefinitionSchema), + body: categoryDefinitionSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -134,12 +137,14 @@ export function resultsServiceRoutes({ router, mlLicense }: RouteInitialization) * @api {post} /api/ml/results/max_anomaly_score Returns the maximum anomaly_score * @apiName GetMaxAnomalyScore * @apiDescription Returns the maximum anomaly score of the bucket results for the request job ID(s) and time range + * + * @apiSchema (body) maxAnomalyScoreSchema */ router.post( { path: '/api/ml/results/max_anomaly_score', validate: { - body: schema.object(maxAnomalyScoreSchema), + body: maxAnomalyScoreSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -161,12 +166,14 @@ export function resultsServiceRoutes({ router, mlLicense }: RouteInitialization) * @api {post} /api/ml/results/category_examples Returns category examples * @apiName GetCategoryExamples * @apiDescription Returns examples for the categories with the specified IDs from the job with the supplied ID + * + * @apiSchema (body) categoryExamplesSchema */ router.post( { path: '/api/ml/results/category_examples', validate: { - body: schema.object(categoryExamplesSchema), + body: categoryExamplesSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { @@ -188,12 +195,14 @@ export function resultsServiceRoutes({ router, mlLicense }: RouteInitialization) * @api {post} /api/ml/results/partition_fields_values Returns partition fields values * @apiName GetPartitionFieldsValues * @apiDescription Returns the partition fields with values that match the provided criteria for the specified job ID. + * + * @apiSchema (body) partitionFieldValuesSchema */ router.post( { path: '/api/ml/results/partition_fields_values', validate: { - body: schema.object(partitionFieldValuesSchema), + body: partitionFieldValuesSchema, }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { diff --git a/x-pack/plugins/ml/server/routes/schemas/annotations_schema.ts b/x-pack/plugins/ml/server/routes/schemas/annotations_schema.ts index 7d3d6aabb129c..fade2093ac842 100644 --- a/x-pack/plugins/ml/server/routes/schemas/annotations_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/annotations_schema.ts @@ -6,7 +6,7 @@ import { schema } from '@kbn/config-schema'; -export const indexAnnotationSchema = { +export const indexAnnotationSchema = schema.object({ timestamp: schema.number(), end_timestamp: schema.number(), annotation: schema.string(), @@ -16,15 +16,16 @@ export const indexAnnotationSchema = { create_username: schema.maybe(schema.string()), modified_time: schema.maybe(schema.number()), modified_username: schema.maybe(schema.string()), + /** Document id */ _id: schema.maybe(schema.string()), key: schema.maybe(schema.string()), -}; +}); -export const getAnnotationsSchema = { +export const getAnnotationsSchema = schema.object({ jobIds: schema.arrayOf(schema.string()), earliestMs: schema.oneOf([schema.nullable(schema.number()), schema.maybe(schema.number())]), latestMs: schema.oneOf([schema.nullable(schema.number()), schema.maybe(schema.number())]), maxAnnotations: schema.number(), -}; +}); -export const deleteAnnotationSchema = { annotationId: schema.string() }; +export const deleteAnnotationSchema = schema.object({ annotationId: schema.string() }); diff --git a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts index 22c3d94dfb29e..ab1305d9bc354 100644 --- a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts @@ -26,6 +26,7 @@ const detectorSchema = schema.object({ over_field_name: schema.maybe(schema.string()), partition_field_name: schema.maybe(schema.string()), detector_description: schema.maybe(schema.string()), + /** Custom rules */ custom_rules: customRulesSchema, }); @@ -37,20 +38,24 @@ const customUrlSchema = { const customSettingsSchema = schema.object( { + /** Indicates the creator entity */ created_by: schema.maybe(schema.string()), - custom_urls: schema.maybe(schema.arrayOf(schema.maybe(schema.object({ ...customUrlSchema })))), + custom_urls: schema.maybe(schema.arrayOf(schema.maybe(schema.object(customUrlSchema)))), }, { unknowns: 'allow' } // Create / Update job API allows other fields to be added to custom_settings. ); -export const anomalyDetectionUpdateJobSchema = { +export const anomalyDetectionUpdateJobSchema = schema.object({ description: schema.maybe(schema.string()), detectors: schema.maybe( schema.arrayOf( schema.maybe( schema.object({ + /** Detector index */ detector_index: schema.number(), + /** Description */ description: schema.maybe(schema.string()), + /** Custom rules */ custom_rules: customRulesSchema, }) ) @@ -64,7 +69,7 @@ export const anomalyDetectionUpdateJobSchema = { }) ), groups: schema.maybe(schema.arrayOf(schema.maybe(schema.string()))), -}; +}); export const analysisConfigSchema = schema.object({ bucket_span: schema.maybe(schema.string()), @@ -78,6 +83,7 @@ export const anomalyDetectionJobSchema = { analysis_config: analysisConfigSchema, analysis_limits: schema.maybe( schema.object({ + /** Limit of categorization examples */ categorization_examples_limit: schema.maybe(schema.number()), model_memory_limit: schema.maybe(schema.string()), }) @@ -88,6 +94,7 @@ export const anomalyDetectionJobSchema = { allow_lazy_open: schema.maybe(schema.any()), data_counts: schema.maybe(schema.any()), data_description: schema.object({ + /** Format */ format: schema.maybe(schema.string()), time_field: schema.string(), time_format: schema.maybe(schema.string()), @@ -110,3 +117,63 @@ export const anomalyDetectionJobSchema = { results_retention_days: schema.maybe(schema.number()), state: schema.maybe(schema.string()), }; + +export const jobIdSchema = schema.object({ + /** Job id */ + jobId: schema.string(), +}); + +export const getRecordsSchema = schema.object({ + desc: schema.maybe(schema.boolean()), + end: schema.maybe(schema.string()), + exclude_interim: schema.maybe(schema.boolean()), + page: schema.maybe( + schema.object({ + from: schema.maybe(schema.number()), + size: schema.maybe(schema.number()), + }) + ), + record_score: schema.maybe(schema.number()), + sort: schema.maybe(schema.string()), + start: schema.maybe(schema.string()), +}); + +export const getBucketsSchema = schema.object({ + anomaly_score: schema.maybe(schema.number()), + desc: schema.maybe(schema.boolean()), + end: schema.maybe(schema.string()), + exclude_interim: schema.maybe(schema.boolean()), + expand: schema.maybe(schema.boolean()), + /** Page definition */ + page: schema.maybe( + schema.object({ + /** Page offset */ + from: schema.maybe(schema.number()), + /** Size of the page */ + size: schema.maybe(schema.number()), + }) + ), + sort: schema.maybe(schema.string()), + start: schema.maybe(schema.string()), +}); + +export const getBucketParamsSchema = schema.object({ + jobId: schema.string(), + timestamp: schema.maybe(schema.string()), +}); + +export const getOverallBucketsSchema = schema.object({ + topN: schema.number(), + bucketSpan: schema.string(), + start: schema.number(), + end: schema.number(), +}); + +export const getCategoriesSchema = schema.object({ + /** Category id */ + categoryId: schema.string(), + /** Job id */ + jobId: schema.string(), +}); + +export const forecastAnomalyDetector = schema.object({ duration: schema.any() }); diff --git a/x-pack/plugins/ml/server/routes/schemas/calendars_schema.ts b/x-pack/plugins/ml/server/routes/schemas/calendars_schema.ts index f5e59d983a9aa..6d8f94311816d 100644 --- a/x-pack/plugins/ml/server/routes/schemas/calendars_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/calendars_schema.ts @@ -6,7 +6,7 @@ import { schema } from '@kbn/config-schema'; -export const calendarSchema = { +export const calendarSchema = schema.object({ calendar_id: schema.maybe(schema.string()), calendarId: schema.string(), job_ids: schema.arrayOf(schema.maybe(schema.string())), @@ -22,4 +22,11 @@ export const calendarSchema = { }) ) ), -}; +}); + +export const calendarIdSchema = schema.object({ calendarId: schema.string() }); + +export const calendarIdsSchema = schema.object({ + /** Comma-separated list of calendar IDs */ + calendarIds: schema.string(), +}); diff --git a/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts b/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts index 21454fa884b82..f1d4947a7abc5 100644 --- a/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts @@ -6,7 +6,7 @@ import { schema } from '@kbn/config-schema'; -export const dataAnalyticsJobConfigSchema = { +export const dataAnalyticsJobConfigSchema = schema.object({ description: schema.maybe(schema.string()), dest: schema.object({ index: schema.string(), @@ -17,7 +17,9 @@ export const dataAnalyticsJobConfigSchema = { query: schema.maybe(schema.any()), _source: schema.maybe( schema.object({ + /** Fields to include in results */ includes: schema.maybe(schema.arrayOf(schema.maybe(schema.string()))), + /** Fields to exclude from results */ excludes: schema.maybe(schema.arrayOf(schema.maybe(schema.string()))), }) ), @@ -26,9 +28,9 @@ export const dataAnalyticsJobConfigSchema = { analysis: schema.any(), analyzed_fields: schema.any(), model_memory_limit: schema.string(), -}; +}); -export const dataAnalyticsEvaluateSchema = { +export const dataAnalyticsEvaluateSchema = schema.object({ index: schema.string(), query: schema.maybe(schema.any()), evaluation: schema.maybe( @@ -37,15 +39,27 @@ export const dataAnalyticsEvaluateSchema = { classification: schema.maybe(schema.any()), }) ), -}; +}); -export const dataAnalyticsExplainSchema = { +export const dataAnalyticsExplainSchema = schema.object({ description: schema.maybe(schema.string()), dest: schema.maybe(schema.any()), + /** Source */ source: schema.object({ index: schema.string(), }), analysis: schema.any(), analyzed_fields: schema.maybe(schema.any()), model_memory_limit: schema.maybe(schema.string()), -}; +}); + +export const analyticsIdSchema = schema.object({ + /** + * Analytics ID + */ + analyticsId: schema.string(), +}); + +export const stopsDataFrameAnalyticsJobQuerySchema = schema.object({ + force: schema.maybe(schema.boolean()), +}); diff --git a/x-pack/plugins/ml/server/routes/schemas/data_visualizer_schema.ts b/x-pack/plugins/ml/server/routes/schemas/data_visualizer_schema.ts index 0c10b2d5b4f16..1a1d02f991b55 100644 --- a/x-pack/plugins/ml/server/routes/schemas/data_visualizer_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/data_visualizer_schema.ts @@ -6,33 +6,27 @@ import { schema } from '@kbn/config-schema'; -export const dataVisualizerFieldStatsSchema = { - params: schema.object({ - indexPatternTitle: schema.string(), - }), - body: schema.object({ - query: schema.any(), - fields: schema.arrayOf(schema.any()), - samplerShardSize: schema.number(), - timeFieldName: schema.maybe(schema.string()), - earliest: schema.maybe(schema.number()), - latest: schema.maybe(schema.number()), - interval: schema.maybe(schema.string()), - maxExamples: schema.number(), - }), -}; +export const indexPatternTitleSchema = schema.object({ + indexPatternTitle: schema.string(), +}); -export const dataVisualizerOverallStatsSchema = { - params: schema.object({ - indexPatternTitle: schema.string(), - }), - body: schema.object({ - query: schema.any(), - aggregatableFields: schema.arrayOf(schema.string()), - nonAggregatableFields: schema.arrayOf(schema.string()), - samplerShardSize: schema.number(), - timeFieldName: schema.maybe(schema.string()), - earliest: schema.maybe(schema.number()), - latest: schema.maybe(schema.number()), - }), -}; +export const dataVisualizerFieldStatsSchema = schema.object({ + query: schema.any(), + fields: schema.arrayOf(schema.any()), + samplerShardSize: schema.number(), + timeFieldName: schema.maybe(schema.string()), + earliest: schema.maybe(schema.number()), + latest: schema.maybe(schema.number()), + interval: schema.maybe(schema.string()), + maxExamples: schema.number(), +}); + +export const dataVisualizerOverallStatsSchema = schema.object({ + query: schema.any(), + aggregatableFields: schema.arrayOf(schema.string()), + nonAggregatableFields: schema.arrayOf(schema.string()), + samplerShardSize: schema.number(), + timeFieldName: schema.maybe(schema.string()), + earliest: schema.maybe(schema.number()), + latest: schema.maybe(schema.number()), +}); diff --git a/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts b/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts index 466e70197e3d1..2cfb9d7d275d5 100644 --- a/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts @@ -42,3 +42,9 @@ export const datafeedConfigSchema = schema.object({ }) ), }); + +export const datafeedIdSchema = schema.object({ datafeedId: schema.string() }); + +export const deleteDatafeedQuerySchema = schema.maybe( + schema.object({ force: schema.maybe(schema.any()) }) +); diff --git a/x-pack/plugins/ml/server/routes/schemas/file_data_visualizer_schema.ts b/x-pack/plugins/ml/server/routes/schemas/file_data_visualizer_schema.ts new file mode 100644 index 0000000000000..9a80cf795cabf --- /dev/null +++ b/x-pack/plugins/ml/server/routes/schemas/file_data_visualizer_schema.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +export const analyzeFileQuerySchema = schema.maybe( + schema.object({ + charset: schema.maybe(schema.string()), + column_names: schema.maybe(schema.string()), + delimiter: schema.maybe(schema.string()), + explain: schema.maybe(schema.string()), + format: schema.maybe(schema.string()), + grok_pattern: schema.maybe(schema.string()), + has_header_row: schema.maybe(schema.string()), + line_merge_size_limit: schema.maybe(schema.string()), + lines_to_sample: schema.maybe(schema.string()), + quote: schema.maybe(schema.string()), + should_trim_fields: schema.maybe(schema.string()), + timeout: schema.maybe(schema.string()), + timestamp_field: schema.maybe(schema.string()), + timestamp_format: schema.maybe(schema.string()), + }) +); + +export const importFileQuerySchema = schema.object({ + id: schema.maybe(schema.string()), +}); + +export const importFileBodySchema = schema.object({ + index: schema.maybe(schema.string()), + data: schema.arrayOf(schema.any()), + settings: schema.maybe(schema.any()), + /** Mappings */ + mappings: schema.any(), + /** Ingest pipeline definition */ + ingestPipeline: schema.object({ + id: schema.maybe(schema.string()), + pipeline: schema.maybe(schema.any()), + }), +}); diff --git a/x-pack/plugins/ml/server/routes/schemas/filters_schema.ts b/x-pack/plugins/ml/server/routes/schemas/filters_schema.ts index dffee56565c73..d33d34c7096ce 100644 --- a/x-pack/plugins/ml/server/routes/schemas/filters_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/filters_schema.ts @@ -6,14 +6,21 @@ import { schema } from '@kbn/config-schema'; -export const createFilterSchema = { +export const createFilterSchema = schema.object({ filterId: schema.string(), description: schema.maybe(schema.string()), items: schema.arrayOf(schema.string()), -}; +}); -export const updateFilterSchema = { +export const updateFilterSchema = schema.object({ description: schema.maybe(schema.string()), addItems: schema.maybe(schema.arrayOf(schema.string())), removeItems: schema.maybe(schema.arrayOf(schema.string())), -}; +}); + +export const filterIdSchema = schema.object({ + /** + * ID of the filter + */ + filterId: schema.string(), +}); diff --git a/x-pack/plugins/ml/server/routes/schemas/indices_schema.ts b/x-pack/plugins/ml/server/routes/schemas/indices_schema.ts new file mode 100644 index 0000000000000..f1b06392292f0 --- /dev/null +++ b/x-pack/plugins/ml/server/routes/schemas/indices_schema.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 { schema } from '@kbn/config-schema'; + +export const indicesSchema = schema.object({ + index: schema.maybe(schema.string()), + fields: schema.maybe(schema.arrayOf(schema.string())), +}); diff --git a/x-pack/plugins/ml/server/routes/schemas/job_audit_messages_schema.ts b/x-pack/plugins/ml/server/routes/schemas/job_audit_messages_schema.ts new file mode 100644 index 0000000000000..b94a004384eb1 --- /dev/null +++ b/x-pack/plugins/ml/server/routes/schemas/job_audit_messages_schema.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 { schema } from '@kbn/config-schema'; + +export const jobIdSchema = schema.object({ jobId: schema.maybe(schema.string()) }); + +export const jobAuditMessagesQuerySchema = schema.maybe( + schema.object({ from: schema.maybe(schema.any()) }) +); diff --git a/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts b/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts index deb62678a777c..d2036b8a7c0fa 100644 --- a/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts @@ -29,21 +29,25 @@ export const chartSchema = { splitFieldValue: schema.maybe(schema.nullable(schema.string())), }; -export const datafeedIdsSchema = { datafeedIds: schema.arrayOf(schema.maybe(schema.string())) }; +export const datafeedIdsSchema = schema.object({ + datafeedIds: schema.arrayOf(schema.maybe(schema.string())), +}); -export const forceStartDatafeedSchema = { +export const forceStartDatafeedSchema = schema.object({ datafeedIds: schema.arrayOf(schema.maybe(schema.string())), start: schema.maybe(schema.number()), end: schema.maybe(schema.number()), -}; +}); -export const jobIdsSchema = { +export const jobIdsSchema = schema.object({ jobIds: schema.maybe( schema.oneOf([schema.string(), schema.arrayOf(schema.maybe(schema.string()))]) ), -}; +}); -export const jobsWithTimerangeSchema = { dateFormatTz: schema.maybe(schema.string()) }; +export const jobsWithTimerangeSchema = { + dateFormatTz: schema.maybe(schema.string()), +}; export const lookBackProgressSchema = { jobId: schema.string(), diff --git a/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts b/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts index 3ded6e770eed5..f12c85962a28d 100644 --- a/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts @@ -37,7 +37,7 @@ export const validateJobSchema = schema.object({ job: schema.object(anomalyDetectionJobSchema), }); -export const validateCardinalitySchema = { +export const validateCardinalitySchema = schema.object({ ...anomalyDetectionJobSchema, datafeed_config: datafeedConfigSchema, -}; +}); diff --git a/x-pack/plugins/ml/server/routes/schemas/results_service_schema.ts b/x-pack/plugins/ml/server/routes/schemas/results_service_schema.ts index 32d829db7f81b..f7317e534b33b 100644 --- a/x-pack/plugins/ml/server/routes/schemas/results_service_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/results_service_schema.ts @@ -12,7 +12,7 @@ const criteriaFieldSchema = schema.object({ fieldValue: schema.any(), }); -export const anomaliesTableDataSchema = { +export const anomaliesTableDataSchema = schema.object({ jobIds: schema.arrayOf(schema.string()), criteriaFields: schema.arrayOf(criteriaFieldSchema), influencers: schema.arrayOf( @@ -26,29 +26,29 @@ export const anomaliesTableDataSchema = { maxRecords: schema.number(), maxExamples: schema.maybe(schema.number()), influencersFilterQuery: schema.maybe(schema.any()), -}; +}); -export const categoryDefinitionSchema = { +export const categoryDefinitionSchema = schema.object({ jobId: schema.maybe(schema.string()), categoryId: schema.string(), -}; +}); -export const maxAnomalyScoreSchema = { +export const maxAnomalyScoreSchema = schema.object({ jobIds: schema.arrayOf(schema.string()), earliestMs: schema.maybe(schema.number()), latestMs: schema.maybe(schema.number()), -}; +}); -export const categoryExamplesSchema = { +export const categoryExamplesSchema = schema.object({ jobId: schema.string(), categoryIds: schema.arrayOf(schema.string()), maxExamples: schema.number(), -}; +}); -export const partitionFieldValuesSchema = { +export const partitionFieldValuesSchema = schema.object({ jobId: schema.string(), searchTerm: schema.maybe(schema.any()), criteriaFields: schema.arrayOf(criteriaFieldSchema), earliestMs: schema.number(), latestMs: schema.number(), -}; +}); diff --git a/x-pack/plugins/ml/shared_imports.ts b/x-pack/plugins/ml/shared_imports.ts index 94ce8c82f1d95..a82ed5387818d 100644 --- a/x-pack/plugins/ml/shared_imports.ts +++ b/x-pack/plugins/ml/shared_imports.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export { XJsonMode } from '../../../src/plugins/es_ui_shared/console_lang/ace/modes/x_json'; - export { + XJsonMode, collapseLiteralStrings, expandLiteralStrings, -} from '../../../src/plugins/es_ui_shared/console_lang/lib'; +} from '../../../src/plugins/es_ui_shared/public'; diff --git a/x-pack/plugins/monitoring/server/alerts/cluster_state.test.ts b/x-pack/plugins/monitoring/server/alerts/cluster_state.test.ts index 6a9ca88437347..bcc1a8abe5cb0 100644 --- a/x-pack/plugins/monitoring/server/alerts/cluster_state.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/cluster_state.test.ts @@ -4,14 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ import { Logger } from 'src/core/server'; -import { savedObjectsClientMock } from 'src/core/server/mocks'; import { getClusterState } from './cluster_state'; -import { AlertServices } from '../../../alerting/server'; import { ALERT_TYPE_CLUSTER_STATE } from '../../common/constants'; import { AlertCommonParams, AlertCommonState, AlertClusterStatePerClusterState } from './types'; import { getPreparedAlert } from '../lib/alerts/get_prepared_alert'; import { executeActions } from '../lib/alerts/cluster_state.lib'; import { AlertClusterStateState } from './enums'; +import { alertsMock, AlertServicesMock } from '../../../alerting/server/mocks'; jest.mock('../lib/alerts/cluster_state.lib', () => ({ executeActions: jest.fn(), @@ -26,18 +25,8 @@ jest.mock('../lib/alerts/get_prepared_alert', () => ({ }), })); -interface MockServices { - callCluster: jest.Mock; - alertInstanceFactory: jest.Mock; - savedObjectsClient: jest.Mock; -} - describe('getClusterState', () => { - const services: MockServices | AlertServices = { - callCluster: jest.fn(), - alertInstanceFactory: jest.fn(), - savedObjectsClient: savedObjectsClientMock.create(), - }; + const services: AlertServicesMock = alertsMock.createAlertServices(); const params: AlertCommonParams = { dateFormat: 'YYYY', @@ -107,7 +96,7 @@ describe('getClusterState', () => { it('should alert if green -> yellow', async () => { const result = await setupAlert(AlertClusterStateState.Green, AlertClusterStateState.Yellow); expect(executeActions).toHaveBeenCalledWith( - undefined, + services.alertInstanceFactory(ALERT_TYPE_CLUSTER_STATE), cluster, AlertClusterStateState.Yellow, emailAddress @@ -121,7 +110,7 @@ describe('getClusterState', () => { it('should alert if yellow -> green', async () => { const result = await setupAlert(AlertClusterStateState.Yellow, AlertClusterStateState.Green); expect(executeActions).toHaveBeenCalledWith( - undefined, + services.alertInstanceFactory(ALERT_TYPE_CLUSTER_STATE), cluster, AlertClusterStateState.Green, emailAddress, @@ -135,7 +124,7 @@ describe('getClusterState', () => { it('should alert if green -> red', async () => { const result = await setupAlert(AlertClusterStateState.Green, AlertClusterStateState.Red); expect(executeActions).toHaveBeenCalledWith( - undefined, + services.alertInstanceFactory(ALERT_TYPE_CLUSTER_STATE), cluster, AlertClusterStateState.Red, emailAddress @@ -149,7 +138,7 @@ describe('getClusterState', () => { it('should alert if red -> green', async () => { const result = await setupAlert(AlertClusterStateState.Red, AlertClusterStateState.Green); expect(executeActions).toHaveBeenCalledWith( - undefined, + services.alertInstanceFactory(ALERT_TYPE_CLUSTER_STATE), cluster, AlertClusterStateState.Green, emailAddress, diff --git a/x-pack/plugins/monitoring/server/alerts/license_expiration.test.ts b/x-pack/plugins/monitoring/server/alerts/license_expiration.test.ts index 92047e300bc1f..f9d2ec3e1d48e 100644 --- a/x-pack/plugins/monitoring/server/alerts/license_expiration.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/license_expiration.test.ts @@ -8,8 +8,6 @@ import moment from 'moment-timezone'; import { getLicenseExpiration } from './license_expiration'; import { ALERT_TYPE_LICENSE_EXPIRATION } from '../../common/constants'; import { Logger } from 'src/core/server'; -import { AlertServices } from '../../../alerting/server'; -import { savedObjectsClientMock } from 'src/core/server/mocks'; import { AlertCommonParams, AlertCommonState, @@ -18,6 +16,7 @@ import { } from './types'; import { executeActions } from '../lib/alerts/license_expiration.lib'; import { PreparedAlert, getPreparedAlert } from '../lib/alerts/get_prepared_alert'; +import { alertsMock, AlertServicesMock } from '../../../alerting/server/mocks'; jest.mock('../lib/alerts/license_expiration.lib', () => ({ executeActions: jest.fn(), @@ -32,18 +31,8 @@ jest.mock('../lib/alerts/get_prepared_alert', () => ({ }), })); -interface MockServices { - callCluster: jest.Mock; - alertInstanceFactory: jest.Mock; - savedObjectsClient: jest.Mock; -} - describe('getLicenseExpiration', () => { - const services: MockServices | AlertServices = { - callCluster: jest.fn(), - alertInstanceFactory: jest.fn(), - savedObjectsClient: savedObjectsClientMock.create(), - }; + const services: AlertServicesMock = alertsMock.createAlertServices(); const params: AlertCommonParams = { dateFormat: 'YYYY', @@ -106,6 +95,7 @@ describe('getLicenseExpiration', () => { } afterEach(() => { + jest.clearAllMocks(); (executeActions as jest.Mock).mockClear(); (getPreparedAlert as jest.Mock).mockClear(); }); @@ -135,7 +125,7 @@ describe('getLicenseExpiration', () => { const newState = result[clusterUuid] as AlertLicensePerClusterState; expect(newState.expiredCheckDateMS > 0).toBe(true); expect(executeActions).toHaveBeenCalledWith( - undefined, + services.alertInstanceFactory(ALERT_TYPE_LICENSE_EXPIRATION), cluster, moment.utc(expiryDateMS), dateFormat, @@ -157,7 +147,7 @@ describe('getLicenseExpiration', () => { const newState = result[clusterUuid] as AlertLicensePerClusterState; expect(newState.expiredCheckDateMS).toBe(0); expect(executeActions).toHaveBeenCalledWith( - undefined, + services.alertInstanceFactory(ALERT_TYPE_LICENSE_EXPIRATION), cluster, moment.utc(expiryDateMS), dateFormat, @@ -196,7 +186,7 @@ describe('getLicenseExpiration', () => { const newState = result[clusterUuid] as AlertLicensePerClusterState; expect(newState.expiredCheckDateMS > 0).toBe(true); expect(executeActions).toHaveBeenCalledWith( - undefined, + services.alertInstanceFactory(ALERT_TYPE_LICENSE_EXPIRATION), cluster, moment.utc(expiryDateMS), dateFormat, diff --git a/x-pack/plugins/monitoring/server/lib/errors/known_errors.js b/x-pack/plugins/monitoring/server/lib/errors/known_errors.js index 6f02d0b0b26c0..17bcdd0414adf 100644 --- a/x-pack/plugins/monitoring/server/lib/errors/known_errors.js +++ b/x-pack/plugins/monitoring/server/lib/errors/known_errors.js @@ -47,5 +47,7 @@ export function isKnownError(err) { export function handleKnownError(err) { err.message = err.message + ': ' + (err.description || mapTypeMessage[err.constructor.name]); - return boomify(err, { statusCode: KNOWN_ERROR_STATUS_CODE }); + let statusCode = err.statusCode || err.status; + statusCode = statusCode !== 500 ? statusCode : KNOWN_ERROR_STATUS_CODE; + return boomify(err, { statusCode }); } diff --git a/x-pack/plugins/monitoring/server/plugin.ts b/x-pack/plugins/monitoring/server/plugin.ts index 0fa1a5bf144ac..a45e80ac71d65 100644 --- a/x-pack/plugins/monitoring/server/plugin.ts +++ b/x-pack/plugins/monitoring/server/plugin.ts @@ -10,12 +10,6 @@ import { i18n } from '@kbn/i18n'; import { has, get } from 'lodash'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; -import { - LOGGING_TAG, - KIBANA_MONITORING_LOGGING_TAG, - KIBANA_ALERTING_ENABLED, - KIBANA_STATS_TYPE_MONITORING, -} from '../common/constants'; import { Logger, PluginInitializerContext, @@ -27,7 +21,15 @@ import { CoreStart, IRouter, IClusterClient, -} from '../../../../src/core/server'; + CustomHttpResponseOptions, + ResponseError, +} from 'kibana/server'; +import { + LOGGING_TAG, + KIBANA_MONITORING_LOGGING_TAG, + KIBANA_ALERTING_ENABLED, + KIBANA_STATS_TYPE_MONITORING, +} from '../common/constants'; import { MonitoringConfig } from './config'; // @ts-ignore import { requireUIRoutes } from './routes'; @@ -92,6 +94,16 @@ interface IBulkUploader { // This is used to test the version of kibana const snapshotRegex = /-snapshot/i; +const wrapError = (error: any): CustomHttpResponseOptions<ResponseError> => { + const options = { statusCode: error.statusCode ?? 500 }; + const boom = Boom.isBoom(error) ? error : Boom.boomify(error, options); + return { + body: boom, + headers: boom.output.headers, + statusCode: boom.output.statusCode, + }; +}; + export class Plugin { private readonly initializerContext: PluginInitializerContext; private readonly log: Logger; @@ -369,12 +381,16 @@ export class Plugin { }, }, }; - - const result = await options.handler(legacyRequest); - if (Boom.isBoom(result)) { - return res.customError({ statusCode: result.output.statusCode, body: result }); + try { + const result = await options.handler(legacyRequest); + return res.ok({ body: result }); + } catch (err) { + const statusCode: number = err.output?.statusCode || err.statusCode || err.status; + if (Boom.isBoom(err) || statusCode !== 500) { + return res.customError({ statusCode, body: err }); + } + return res.internalError(wrapError(err)); } - return res.ok({ body: result }); }; const validate: any = get(options, 'config.validate', false); diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/cluster.js b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/cluster.js index 8d6fe04cdb7bd..240cb84539dbf 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/cluster.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/cluster.js @@ -46,9 +46,13 @@ export function clusterRoute(server) { codePaths: req.payload.codePaths, }; - return getClustersFromRequest(req, indexPatterns, options).catch(err => - handleError(err, req) - ); + let clusters = []; + try { + clusters = await getClustersFromRequest(req, indexPatterns, options); + } catch (err) { + throw handleError(err, req); + } + return clusters; }, }); } diff --git a/x-pack/plugins/oss_telemetry/server/lib/tasks/index.ts b/x-pack/plugins/oss_telemetry/server/lib/tasks/index.ts index be08338807dd0..9342c2574bedd 100644 --- a/x-pack/plugins/oss_telemetry/server/lib/tasks/index.ts +++ b/x-pack/plugins/oss_telemetry/server/lib/tasks/index.ts @@ -17,12 +17,12 @@ import { export function registerTasks({ taskManager, logger, - elasticsearch, + getStartServices, config, }: { taskManager?: TaskManagerSetupContract; logger: Logger; - elasticsearch: CoreSetup['elasticsearch']; + getStartServices: CoreSetup['getStartServices']; config: Observable<{ kibana: { index: string } }>; }) { if (!taskManager) { @@ -30,13 +30,17 @@ export function registerTasks({ return; } + const esClientPromise = getStartServices().then( + ([{ elasticsearch }]) => elasticsearch.legacy.client + ); + taskManager.registerTaskDefinitions({ [VIS_TELEMETRY_TASK]: { title: 'X-Pack telemetry calculator for Visualizations', type: VIS_TELEMETRY_TASK, createTaskRunner({ taskInstance }: { taskInstance: TaskInstance }) { return { - run: visualizationsTaskRunner(taskInstance, config, elasticsearch), + run: visualizationsTaskRunner(taskInstance, config, esClientPromise), }; }, }, diff --git a/x-pack/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts b/x-pack/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts index dff5e24db3c6e..6a47983a6f4d9 100644 --- a/x-pack/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts +++ b/x-pack/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import moment from 'moment'; import { getMockCallWithInternal, getMockConfig, @@ -13,6 +12,7 @@ import { } from '../../../test_utils'; import { visualizationsTaskRunner } from './task_runner'; import { TaskInstance } from '../../../../../task_manager/server'; +import { getNextMidnight } from '../../get_next_midnight'; describe('visualizationsTaskRunner', () => { let mockTaskInstance: TaskInstance; @@ -41,12 +41,6 @@ describe('visualizationsTaskRunner', () => { }); test('Summarizes visualization response data', async () => { - const getNextMidnight = () => - moment() - .add(1, 'days') - .startOf('day') - .toDate(); - const runner = visualizationsTaskRunner(mockTaskInstance, getMockConfig(), getMockEs()); const result = await runner(); diff --git a/x-pack/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.ts b/x-pack/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.ts index 556dc465e562d..f60c44e548f3f 100644 --- a/x-pack/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.ts +++ b/x-pack/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.ts @@ -6,7 +6,7 @@ import { Observable } from 'rxjs'; import _, { countBy, groupBy, mapValues } from 'lodash'; -import { APICaller, CoreSetup } from 'kibana/server'; +import { APICaller, IClusterClient } from 'src/core/server'; import { getNextMidnight } from '../../get_next_midnight'; import { TaskInstance } from '../../../../../task_manager/server'; import { ESSearchHit } from '../../../../../apm/typings/elasticsearch'; @@ -73,17 +73,15 @@ async function getStats(callCluster: APICaller, index: string) { export function visualizationsTaskRunner( taskInstance: TaskInstance, config: Observable<{ kibana: { index: string } }>, - es: CoreSetup['elasticsearch'] + esClientPromise: Promise<IClusterClient> ) { - const { callAsInternalUser: callCluster } = es.createClient('data'); - return async () => { let stats; let error; try { const index = (await config.toPromise()).kibana.index; - stats = await getStats(callCluster, index); + stats = await getStats((await esClientPromise).callAsInternalUser, index); } catch (err) { if (err.constructor === Error) { error = err.message; diff --git a/x-pack/plugins/oss_telemetry/server/plugin.ts b/x-pack/plugins/oss_telemetry/server/plugin.ts index 430fca2d39837..6a447da66952a 100644 --- a/x-pack/plugins/oss_telemetry/server/plugin.ts +++ b/x-pack/plugins/oss_telemetry/server/plugin.ts @@ -35,7 +35,7 @@ export class OssTelemetryPlugin implements Plugin { registerTasks({ taskManager: deps.taskManager, logger: this.logger, - elasticsearch: core.elasticsearch, + getStartServices: core.getStartServices, config: this.config, }); registerCollectors( diff --git a/x-pack/plugins/oss_telemetry/server/test_utils/index.ts b/x-pack/plugins/oss_telemetry/server/test_utils/index.ts index fc8c98c164623..4d703db3dcc64 100644 --- a/x-pack/plugins/oss_telemetry/server/test_utils/index.ts +++ b/x-pack/plugins/oss_telemetry/server/test_utils/index.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { APICaller, CoreSetup } from 'kibana/server'; +import { APICaller } from 'kibana/server'; import { of } from 'rxjs'; +import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { ConcreteTaskInstance, TaskStatus, @@ -43,10 +44,11 @@ const defaultMockSavedObjects = [ const defaultMockTaskDocs = [getMockTaskInstance()]; -export const getMockEs = (mockCallWithInternal: APICaller = getMockCallWithInternal()) => - (({ - createClient: () => ({ callAsInternalUser: mockCallWithInternal }), - } as unknown) as CoreSetup['elasticsearch']); +export const getMockEs = async (mockCallWithInternal: APICaller = getMockCallWithInternal()) => { + const client = elasticsearchServiceMock.createClusterClient(); + (client.callAsInternalUser as any) = mockCallWithInternal; + return client; +}; export const getMockCallWithInternal = (hits: unknown[] = defaultMockSavedObjects): APICaller => { return ((() => { diff --git a/x-pack/plugins/remote_clusters/public/application/services/ui_metric.ts b/x-pack/plugins/remote_clusters/public/application/services/ui_metric.ts index 91354155cacb0..4fc3c438e76d6 100644 --- a/x-pack/plugins/remote_clusters/public/application/services/ui_metric.ts +++ b/x-pack/plugins/remote_clusters/public/application/services/ui_metric.ts @@ -3,17 +3,24 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import { UiStatsMetricType, METRIC_TYPE } from '@kbn/analytics'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; -import { UiStatsMetricType } from '@kbn/analytics'; import { UIM_APP_NAME } from '../constants'; -export let trackUiMetric: (metricType: UiStatsMetricType, eventName: string) => void; -export let METRIC_TYPE: UsageCollectionSetup['METRIC_TYPE']; +export { METRIC_TYPE }; + +export let usageCollection: UsageCollectionSetup | undefined; + +export function init(_usageCollection: UsageCollectionSetup): void { + usageCollection = _usageCollection; +} -export function init(usageCollection: UsageCollectionSetup): void { - trackUiMetric = usageCollection.reportUiStats.bind(usageCollection, UIM_APP_NAME); - METRIC_TYPE = usageCollection.METRIC_TYPE; +export function trackUiMetric(metricType: UiStatsMetricType, name: string) { + if (!usageCollection) { + return; + } + const { reportUiStats } = usageCollection; + reportUiStats(UIM_APP_NAME, metricType, name); } /** diff --git a/x-pack/plugins/remote_clusters/server/plugin.ts b/x-pack/plugins/remote_clusters/server/plugin.ts index b86f16228878a..fca4a5dbc5f94 100644 --- a/x-pack/plugins/remote_clusters/server/plugin.ts +++ b/x-pack/plugins/remote_clusters/server/plugin.ts @@ -29,15 +29,9 @@ export class RemoteClustersServerPlugin implements Plugin<void, void, any, any> this.licenseStatus = { valid: false }; } - async setup( - { http, elasticsearch: elasticsearchService }: CoreSetup, - { licensing, cloud }: Dependencies - ) { - const elasticsearch = await elasticsearchService.adminClient; + async setup({ http }: CoreSetup, { licensing, cloud }: Dependencies) { const router = http.createRouter(); const routeDependencies: RouteDependencies = { - elasticsearch, - elasticsearchService, router, getLicenseStatus: () => this.licenseStatus, config: { diff --git a/x-pack/plugins/remote_clusters/server/types.ts b/x-pack/plugins/remote_clusters/server/types.ts index 85678cba92f19..23f4ed158c2d4 100644 --- a/x-pack/plugins/remote_clusters/server/types.ts +++ b/x-pack/plugins/remote_clusters/server/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IRouter, ElasticsearchServiceSetup, IClusterClient } from 'kibana/server'; +import { IRouter } from 'kibana/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { CloudSetup } from '../../cloud/server'; @@ -16,8 +16,6 @@ export interface Dependencies { export interface RouteDependencies { router: IRouter; getLicenseStatus: () => LicenseStatus; - elasticsearchService: ElasticsearchServiceSetup; - elasticsearch: IClusterClient; config: { isCloudEnabled: boolean; }; diff --git a/x-pack/plugins/reporting/config.ts b/x-pack/plugins/reporting/config.ts deleted file mode 100644 index f1d6b1a8f248f..0000000000000 --- a/x-pack/plugins/reporting/config.ts +++ /dev/null @@ -1,10 +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 const reportingPollConfig = { - jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, - jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, -}; diff --git a/x-pack/plugins/reporting/constants.ts b/x-pack/plugins/reporting/constants.ts index 8f47a0a6b2ac1..5756d29face12 100644 --- a/x-pack/plugins/reporting/constants.ts +++ b/x-pack/plugins/reporting/constants.ts @@ -24,6 +24,7 @@ export const REPORTING_MANAGEMENT_HOME = '/app/kibana#/management/kibana/reporti // Statuses export const JOB_STATUS_FAILED = 'failed'; export const JOB_STATUS_COMPLETED = 'completed'; +export const JOB_STATUS_WARNINGS = 'completed_with_warnings'; export enum JobStatuses { PENDING = 'pending', @@ -31,6 +32,7 @@ export enum JobStatuses { COMPLETED = 'completed', FAILED = 'failed', CANCELLED = 'cancelled', + WARNINGS = 'completed_with_warnings', } // Types diff --git a/x-pack/plugins/reporting/index.d.ts b/x-pack/plugins/reporting/index.d.ts index 7c1a2ebd7d9de..26d661e29bd94 100644 --- a/x-pack/plugins/reporting/index.d.ts +++ b/x-pack/plugins/reporting/index.d.ts @@ -14,7 +14,12 @@ import { } from '../../../src/core/public'; export type JobId = string; -export type JobStatus = 'completed' | 'pending' | 'processing' | 'failed'; +export type JobStatus = + | 'completed' + | 'completed_with_warnings' + | 'pending' + | 'processing' + | 'failed'; export type HttpService = HttpSetup; export type NotificationsService = NotificationsStart; diff --git a/x-pack/plugins/reporting/kibana.json b/x-pack/plugins/reporting/kibana.json index 3da2d2a094706..d068711b87c9d 100644 --- a/x-pack/plugins/reporting/kibana.json +++ b/x-pack/plugins/reporting/kibana.json @@ -2,6 +2,9 @@ "id": "reporting", "version": "8.0.0", "kibanaVersion": "kibana", + "optionalPlugins": [ + "usageCollection" + ], "configPath": ["xpack", "reporting"], "requiredPlugins": [ "home", @@ -12,6 +15,6 @@ "share", "kibanaLegacy" ], - "server": false, + "server": true, "ui": true } diff --git a/x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx b/x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx index b0674c149609d..6c13264ebcb1f 100644 --- a/x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx +++ b/x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx @@ -14,7 +14,7 @@ type Props = { record: ListingJob } & ListingProps; export const ReportDownloadButton: FunctionComponent<Props> = (props: Props) => { const { record, apiClient, intl } = props; - if (record.status !== JobStatuses.COMPLETED) { + if (record.status !== JobStatuses.COMPLETED && record.status !== JobStatuses.WARNINGS) { return null; } diff --git a/x-pack/plugins/reporting/public/components/report_listing.test.tsx b/x-pack/plugins/reporting/public/components/report_listing.test.tsx index 9b541261a690b..380a3b3295b9f 100644 --- a/x-pack/plugins/reporting/public/components/report_listing.test.tsx +++ b/x-pack/plugins/reporting/public/components/report_listing.test.tsx @@ -17,17 +17,16 @@ import { ReportListing } from './report_listing'; const reportingAPIClient = { list: () => Promise.resolve([ - { _index: '.reporting-2019.08.18', _id: 'jzoik8dh1q2i89fb5f19znm6', _source: { payload: { layout: { id: 'preserve_layout', dimensions: { width: 1635, height: 792 } }, type: 'dashboard', title: 'Names', }, max_attempts: 3, browser_type: 'chromium', created_at: '2019-08-23T19:34:24.869Z', jobtype: 'printable_pdf', created_by: 'elastic', attempts: 0, status: 'pending', }, }, // prettier-ignore - { _index: '.reporting-2019.08.18', _id: 'jzoik7tn1q2i89fb5f60e5ve', _score: null, _source: { payload: { layout: { id: 'preserve_layout', dimensions: { width: 1635, height: 792 } }, type: 'dashboard', title: 'Names', }, max_attempts: 3, browser_type: 'chromium', created_at: '2019-08-23T19:34:24.155Z', jobtype: 'printable_pdf', created_by: 'elastic', attempts: 0, status: 'pending', }, }, // prettier-ignore - { _index: '.reporting-2019.08.18', _id: 'jzoik5tb1q2i89fb5fckchny', _score: null, _source: { payload: { layout: { id: 'png', dimensions: { width: 1898, height: 876 } }, title: 'cool dashboard', type: 'dashboard', }, max_attempts: 3, browser_type: 'chromium', created_at: '2019-08-23T19:34:21.551Z', jobtype: 'PNG', created_by: 'elastic', attempts: 0, status: 'pending', }, }, // prettier-ignore - { _index: '.reporting-2019.08.18', _id: 'jzoik5a11q2i89fb5f130t2m', _score: null, _source: { payload: { layout: { id: 'png', dimensions: { width: 1898, height: 876 } }, title: 'cool dashboard', type: 'dashboard', }, max_attempts: 3, browser_type: 'chromium', created_at: '2019-08-23T19:34:20.857Z', jobtype: 'PNG', created_by: 'elastic', attempts: 0, status: 'pending', }, }, // prettier-ignore - { _index: '.reporting-2019.08.18', _id: 'jzoik3ka1q2i89fb5fdx93g7', _score: null, _source: { payload: { layout: { id: 'preserve_layout', dimensions: { width: 1898, height: 876 } }, type: 'dashboard', title: 'cool dashboard', }, max_attempts: 3, browser_type: 'chromium', created_at: '2019-08-23T19:34:18.634Z', jobtype: 'printable_pdf', created_by: 'elastic', attempts: 0, status: 'pending', }, }, // prettier-ignore - { _index: '.reporting-2019.08.18', _id: 'jzoik2vt1q2i89fb5ffw723n', _score: null, _source: { payload: { layout: { id: 'preserve_layout', dimensions: { width: 1898, height: 876 } }, type: 'dashboard', title: 'cool dashboard', }, max_attempts: 3, browser_type: 'chromium', created_at: '2019-08-23T19:34:17.753Z', jobtype: 'printable_pdf', created_by: 'elastic', attempts: 0, status: 'pending', }, }, // prettier-ignore - { _index: '.reporting-2019.08.18', _id: 'jzoik1851q2i89fb5fdge6e7', _score: null, _source: { payload: { layout: { id: 'preserve_layout', dimensions: { width: 1080, height: 720 } }, type: 'canvas workpad', title: 'My Canvas Workpad - Dark', }, max_attempts: 3, browser_type: 'chromium', created_at: '2019-08-23T19:34:15.605Z', jobtype: 'printable_pdf', created_by: 'elastic', attempts: 0, status: 'pending', }, }, // prettier-ignore - { _index: '.reporting-2019.08.18', _id: 'jzoijyre1q2i89fb5fa7xzvi', _score: null, _source: { payload: { type: 'dashboard', title: 'tests-panels', }, max_attempts: 3, browser_type: 'chromium', created_at: '2019-08-23T19:34:12.410Z', jobtype: 'printable_pdf', created_by: 'elastic', attempts: 0, status: 'pending', }, }, // prettier-ignore - { _index: '.reporting-2019.08.18', _id: 'jzoijv5h1q2i89fb5ffklnhx', _score: null, _source: { payload: { type: 'dashboard', title: 'tests-panels', }, max_attempts: 3, browser_type: 'chromium', created_at: '2019-08-23T19:34:07.733Z', jobtype: 'printable_pdf', created_by: 'elastic', attempts: 0, status: 'pending', }, }, // prettier-ignore - { _index: '.reporting-2019.08.18', _id: 'jznhgk7r1bx789fb5f6hxok7', _score: null, _source: { kibana_name: 'spicy.local', browser_type: 'chromium', created_at: '2019-08-23T02:15:47.799Z', jobtype: 'printable_pdf', created_by: 'elastic', kibana_id: 'ca75e26c-2b7d-464f-aef0-babb67c735a0', output: { content_type: 'application/pdf', size: 877114 }, completed_at: '2019-08-23T02:15:57.707Z', payload: { type: 'dashboard (legacy)', title: 'tests-panels', }, max_attempts: 3, started_at: '2019-08-23T02:15:48.794Z', attempts: 1, status: 'completed', }, }, // prettier-ignore - ]), + { _id: 'k90e51pk1ieucbae0c3t8wo2', _index: '.reporting-2020.04.12', _score: null, _source: { attempts: 0, browser_type: 'chromium', created_at: '2020-04-14T21:01:13.064Z', created_by: 'elastic', jobtype: 'printable_pdf', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad', }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-14T21:01:13.062Z', layout: { dimensions: { height: 720, width: 1080, }, id: 'preserve_layout', }, objectType: 'canvas workpad', relativeUrls: [ '/s/hsyjklk/app/canvas#/export/workpad/pdf/workpad-53d38306-eda4-410f-b5e7-efbeca1a8c63/page/1', ], title: 'My Canvas Workpad', }, priority: 10, process_expiration: '1970-01-01T00:00:00.000Z', status: 'pending', timeout: 300000, }, sort: [1586898073064], }, // prettier-ignore + { _id: 'k90e51pk1ieucbae0c3t8wo1', _index: '.reporting-2020.04.12', _score: null, _source: { attempts: 1, browser_type: 'chromium', created_at: '2020-04-14T21:01:13.064Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad', }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-14T21:01:13.062Z', layout: { dimensions: { height: 720, width: 1080, }, id: 'preserve_layout', }, objectType: 'canvas workpad', relativeUrls: [ '/s/hsyjklk/app/canvas#/export/workpad/pdf/workpad-53d38306-eda4-410f-b5e7-efbeca1a8c63/page/1', ], title: 'My Canvas Workpad', }, priority: 10, process_expiration: '2020-04-14T21:06:14.526Z', started_at: '2020-04-14T21:01:14.526Z', status: 'processing', timeout: 300000, }, sort: [1586898073064], }, + { _id: 'k90cmthd1gv8cbae0c2le8bo', _index: '.reporting-2020.04.12', _score: null, _source: { attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T20:19:14.748Z', created_at: '2020-04-14T20:19:02.977Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad', }, output: { content_type: 'application/pdf', size: 80262, }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-14T20:19:02.976Z', layout: { dimensions: { height: 720, width: 1080, }, id: 'preserve_layout', }, objectType: 'canvas workpad', relativeUrls: [ '/s/hsyjklk/app/canvas#/export/workpad/pdf/workpad-53d38306-eda4-410f-b5e7-efbeca1a8c63/page/1', ], title: 'My Canvas Workpad', }, priority: 10, process_expiration: '2020-04-14T20:24:04.073Z', started_at: '2020-04-14T20:19:04.073Z', status: 'completed', timeout: 300000, }, sort: [1586895542977], }, + { _id: 'k906958e1d4wcbae0c9hip1a', _index: '.reporting-2020.04.12', _score: null, _source: { attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T17:21:08.223Z', created_at: '2020-04-14T17:20:27.326Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad', }, output: { content_type: 'application/pdf', size: 49468, warnings: [ 'An error occurred when trying to read the page for visualization panel info. You may need to increase \'xpack.reporting.capture.timeouts.waitForElements\'. TimeoutError: waiting for selector "[data-shared-item],[data-shared-items-count]" failed: timeout 30000ms exceeded', ], }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-14T17:20:27.326Z', layout: { dimensions: { height: 720, width: 1080, }, id: 'preserve_layout', }, objectType: 'canvas workpad', relativeUrls: [ '/s/hsyjklk/app/canvas#/export/workpad/pdf/workpad-53d38306-eda4-410f-b5e8-efbeca1a8c63/page/1', ], title: 'My Canvas Workpad', }, priority: 10, process_expiration: '2020-04-14T17:25:29.444Z', started_at: '2020-04-14T17:20:29.444Z', status: 'completed_with_warnings', timeout: 300000, }, sort: [1586884827326], }, + { _id: 'k9067y2a1d4wcbae0cad38n0', _index: '.reporting-2020.04.12', _score: null, _source: { attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T17:19:53.244Z', created_at: '2020-04-14T17:19:31.379Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad', }, output: { content_type: 'application/pdf', size: 80262, }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-14T17:19:31.378Z', layout: { dimensions: { height: 720, width: 1080, }, id: 'preserve_layout', }, objectType: 'canvas workpad', relativeUrls: [ '/s/hsyjklk/app/canvas#/export/workpad/pdf/workpad-53d38306-eda4-410f-b5e7-efbeca1a8c63/page/1', ], title: 'My Canvas Workpad', }, priority: 10, process_expiration: '2020-04-14T17:24:39.883Z', started_at: '2020-04-14T17:19:39.883Z', status: 'completed', timeout: 300000, }, sort: [1586884771379], }, + { _id: 'k9067s1m1d4wcbae0cdnvcms', _index: '.reporting-2020.04.12', _score: null, _source: { attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T17:19:36.822Z', created_at: '2020-04-14T17:19:23.578Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad', }, output: { content_type: 'application/pdf', size: 80262, }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-14T17:19:23.578Z', layout: { dimensions: { height: 720, width: 1080, }, id: 'preserve_layout', }, objectType: 'canvas workpad', relativeUrls: [ '/s/hsyjklk/app/canvas#/export/workpad/pdf/workpad-53d38306-eda4-410f-b5e7-efbeca1a8c63/page/1', ], title: 'My Canvas Workpad', }, priority: 10, process_expiration: '2020-04-14T17:24:25.247Z', started_at: '2020-04-14T17:19:25.247Z', status: 'completed', timeout: 300000, }, sort: [1586884763578], }, + { _id: 'k9065q3s1d4wcbae0c00fxlh', _index: '.reporting-2020.04.12', _score: null, _source: { attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T17:18:03.910Z', created_at: '2020-04-14T17:17:47.752Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad', }, output: { content_type: 'application/pdf', size: 80262, }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-14T17:17:47.750Z', layout: { dimensions: { height: 720, width: 1080, }, id: 'preserve_layout', }, objectType: 'canvas workpad', relativeUrls: [ '/s/hsyjklk/app/canvas#/export/workpad/pdf/workpad-53d38306-eda4-410f-b5e7-efbeca1a8c63/page/1', ], title: 'My Canvas Workpad', }, priority: 10, process_expiration: '2020-04-14T17:22:50.379Z', started_at: '2020-04-14T17:17:50.379Z', status: 'completed', timeout: 300000, }, sort: [1586884667752], }, + { _id: 'k905zdw11d34cbae0c3y6tzh', _index: '.reporting-2020.04.12', _score: null, _source: { attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T17:13:03.719Z', created_at: '2020-04-14T17:12:51.985Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad', }, output: { content_type: 'application/pdf', size: 80262, }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-14T17:12:51.984Z', layout: { dimensions: { height: 720, width: 1080, }, id: 'preserve_layout', }, objectType: 'canvas workpad', relativeUrls: [ '/s/hsyjklk/app/canvas#/export/workpad/pdf/workpad-53d38306-eda4-410f-b5e7-efbeca1a8c63/page/1', ], title: 'My Canvas Workpad', }, priority: 10, process_expiration: '2020-04-14T17:17:52.431Z', started_at: '2020-04-14T17:12:52.431Z', status: 'completed', timeout: 300000, }, sort: [1586884371985], }, + { _id: 'k8t4ylcb07mi9d006214ifyg', _index: '.reporting-2020.04.05', _score: null, _source: { attempts: 1, browser_type: 'chromium', completed_at: '2020-04-09T19:10:10.049Z', created_at: '2020-04-09T19:09:52.139Z', created_by: 'elastic', jobtype: 'PNG', kibana_id: 'f2e59b4e-f79b-4a48-8a7d-6d50a3c1d914', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'png', objectType: 'visualization', }, output: { content_type: 'image/png', }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-09T19:09:52.137Z', layout: { dimensions: { height: 1575, width: 1423, }, id: 'png', }, objectType: 'visualization', relativeUrl: "/s/hsyjklk/app/kibana#/visualize/edit/94d1fe40-7a94-11ea-b373-0749f92ad295?_a=(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!((enabled:!t,id:'1',params:(),schema:metric,type:count)),params:(addLegend:!f,addTooltip:!t,metric:(colorSchema:'Green%20to%20Red',colorsRange:!((from:0,to:10000)),invertColors:!f,labels:(show:!t),metricColorMode:None,percentageMode:!f,style:(bgColor:!f,bgFill:%23000,fontSize:60,labelColor:!f,subText:''),useRanges:!f),type:metric),title:count,type:metric))&_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now-15y,to:now))&indexPattern=d81752b0-7434-11ea-be36-1f978cda44d4&type=metric", title: 'count', }, priority: 10, process_expiration: '2020-04-09T19:14:54.570Z', started_at: '2020-04-09T19:09:54.570Z', status: 'completed', timeout: 300000, }, sort: [1586459392139], }, + ]), // prettier-ignore total: () => Promise.resolve(18), } as any; diff --git a/x-pack/plugins/reporting/public/components/report_listing.tsx b/x-pack/plugins/reporting/public/components/report_listing.tsx index 93dd293876f82..885e9577471a0 100644 --- a/x-pack/plugins/reporting/public/components/report_listing.tsx +++ b/x-pack/plugins/reporting/public/components/report_listing.tsx @@ -87,6 +87,12 @@ const jobStatusLabelsMap = new Map<JobStatuses, string>([ defaultMessage: 'Completed', }), ], + [ + JobStatuses.WARNINGS, + i18n.translate('xpack.reporting.jobStatuses.warningText', { + defaultMessage: 'Completed with warnings', + }), + ], [ JobStatuses.FAILED, i18n.translate('xpack.reporting.jobStatuses.failedText', { @@ -410,7 +416,11 @@ class ReportListingUi extends Component<Props, State> { statusTimestamp = this.formatDate(record.started_at); } else if ( record.completed_at && - (status === JobStatuses.COMPLETED || status === JobStatuses.FAILED) + ([ + JobStatuses.COMPLETED, + JobStatuses.FAILED, + JobStatuses.WARNINGS, + ] as string[]).includes(status) ) { statusTimestamp = this.formatDate(record.completed_at); } diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.ts b/x-pack/plugins/reporting/public/lib/stream_handler.ts index 1aae30f6fdfb0..3c121f1712685 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.ts @@ -11,6 +11,7 @@ import { JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY, JOB_STATUS_COMPLETED, JOB_STATUS_FAILED, + JOB_STATUS_WARNINGS, } from '../../constants'; import { @@ -112,7 +113,7 @@ export class ReportingNotifierStreamHandler { _source: { status: jobStatus }, } = job; if (storedJobs.includes(jobId)) { - if (jobStatus === JOB_STATUS_COMPLETED) { + if (jobStatus === JOB_STATUS_COMPLETED || jobStatus === JOB_STATUS_WARNINGS) { completedJobs.push(summarizeJob(job)); } else if (jobStatus === JOB_STATUS_FAILED) { failedJobs.push(summarizeJob(job)); diff --git a/x-pack/plugins/reporting/server/config/create_config.test.ts b/x-pack/plugins/reporting/server/config/create_config.test.ts new file mode 100644 index 0000000000000..3107866be6496 --- /dev/null +++ b/x-pack/plugins/reporting/server/config/create_config.test.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 * as Rx from 'rxjs'; +import { CoreSetup, Logger, PluginInitializerContext } from 'src/core/server'; +import { ConfigType as ReportingConfigType } from './schema'; +import { createConfig$ } from './create_config'; + +interface KibanaServer { + host?: string; + port?: number; + protocol?: string; +} + +const makeMockInitContext = (config: { + capture?: Partial<ReportingConfigType['capture']>; + encryptionKey?: string; + kibanaServer: Partial<ReportingConfigType['kibanaServer']>; +}): PluginInitializerContext => + ({ + config: { + create: () => + Rx.of({ + ...config, + capture: config.capture || { browser: { chromium: { disableSandbox: false } } }, + kibanaServer: config.kibanaServer || {}, + }), + }, + } as PluginInitializerContext); + +const makeMockCoreSetup = (serverInfo: KibanaServer): CoreSetup => + ({ http: { getServerInfo: () => serverInfo } } as any); + +describe('Reporting server createConfig$', () => { + let mockCoreSetup: CoreSetup; + let mockInitContext: PluginInitializerContext; + let mockLogger: Logger; + + beforeEach(() => { + mockCoreSetup = makeMockCoreSetup({ host: 'kibanaHost', port: 5601, protocol: 'http' }); + mockInitContext = makeMockInitContext({ + kibanaServer: {}, + }); + mockLogger = ({ warn: jest.fn(), debug: jest.fn() } as unknown) as Logger; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('creates random encryption key and default config using host, protocol, and port from server info', async () => { + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result.encryptionKey).toMatch(/\S{32,}/); // random 32 characters + expect(result.kibanaServer).toMatchInlineSnapshot(` + Object { + "hostname": "kibanaHost", + "port": 5601, + "protocol": "http", + } + `); + expect((mockLogger.warn as any).mock.calls.length).toBe(1); + expect((mockLogger.warn as any).mock.calls[0]).toMatchObject([ + 'Generating a random key for xpack.reporting.encryptionKey. To prevent sessions from being invalidated on restart, please set xpack.reporting.encryptionKey in kibana.yml', + ]); + }); + + it('uses the user-provided encryption key', async () => { + mockInitContext = makeMockInitContext({ + encryptionKey: 'iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii', + kibanaServer: {}, + }); + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result.encryptionKey).toMatch('iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii'); + expect((mockLogger.warn as any).mock.calls.length).toBe(0); + }); + + it('uses the user-provided encryption key, reporting kibanaServer settings to override server info', async () => { + mockInitContext = makeMockInitContext({ + encryptionKey: 'iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii', + kibanaServer: { + hostname: 'reportingHost', + port: 5677, + protocol: 'httpsa', + }, + }); + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result).toMatchInlineSnapshot(` + Object { + "capture": Object { + "browser": Object { + "chromium": Object { + "disableSandbox": false, + }, + }, + }, + "encryptionKey": "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii", + "kibanaServer": Object { + "hostname": "reportingHost", + "port": 5677, + "protocol": "httpsa", + }, + } + `); + expect((mockLogger.warn as any).mock.calls.length).toBe(0); + }); + + it('show warning when kibanaServer.hostName === "0"', async () => { + mockInitContext = makeMockInitContext({ + encryptionKey: 'aaaaaaaaaaaaabbbbbbbbbbbbaaaaaaaaa', + kibanaServer: { hostname: '0' }, + }); + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result.kibanaServer).toMatchInlineSnapshot(` + Object { + "hostname": "0.0.0.0", + "port": 5601, + "protocol": "http", + } + `); + expect((mockLogger.warn as any).mock.calls.length).toBe(1); + expect((mockLogger.warn as any).mock.calls[0]).toMatchObject([ + `Found 'server.host: \"0\"' in Kibana configuration. This is incompatible with Reporting. To enable Reporting to work, 'xpack.reporting.kibanaServer.hostname: 0.0.0.0' is being automatically ` + + `to the configuration. You can change the setting to 'server.host: 0.0.0.0' or add 'xpack.reporting.kibanaServer.hostname: 0.0.0.0' in kibana.yml to prevent this message.`, + ]); + }); + + it('uses user-provided disableSandbox: false', async () => { + mockInitContext = makeMockInitContext({ + encryptionKey: '888888888888888888888888888888888', + capture: { browser: { chromium: { disableSandbox: false } } }, + } as ReportingConfigType); + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result.capture.browser.chromium).toMatchObject({ disableSandbox: false }); + expect((mockLogger.warn as any).mock.calls.length).toBe(0); + }); + + it('uses user-provided disableSandbox: true', async () => { + mockInitContext = makeMockInitContext({ + encryptionKey: '888888888888888888888888888888888', + capture: { browser: { chromium: { disableSandbox: true } } }, + } as ReportingConfigType); + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result.capture.browser.chromium).toMatchObject({ disableSandbox: true }); + expect((mockLogger.warn as any).mock.calls.length).toBe(0); + }); + + it('provides a default for disableSandbox', async () => { + mockInitContext = makeMockInitContext({ + encryptionKey: '888888888888888888888888888888888', + } as ReportingConfigType); + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result.capture.browser.chromium).toMatchObject({ disableSandbox: expect.any(Boolean) }); + expect((mockLogger.warn as any).mock.calls.length).toBe(0); + }); +}); diff --git a/x-pack/plugins/reporting/server/config/create_config.ts b/x-pack/plugins/reporting/server/config/create_config.ts new file mode 100644 index 0000000000000..1e6e8bbde5d27 --- /dev/null +++ b/x-pack/plugins/reporting/server/config/create_config.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n/'; +import { TypeOf } from '@kbn/config-schema'; +import crypto from 'crypto'; +import { capitalize } from 'lodash'; +import { map, mergeMap } from 'rxjs/operators'; +import { CoreSetup, Logger, PluginInitializerContext } from 'src/core/server'; +import { getDefaultChromiumSandboxDisabled } from './default_chromium_sandbox_disabled'; +import { ConfigSchema } from './schema'; + +/* + * Set up dynamic config defaults + * - xpack.capture.browser.chromium.disableSandbox + * - xpack.kibanaServer + * - xpack.reporting.encryptionKey + */ +export function createConfig$(core: CoreSetup, context: PluginInitializerContext, logger: Logger) { + return context.config.create<TypeOf<typeof ConfigSchema>>().pipe( + map(config => { + // encryption key + let encryptionKey = config.encryptionKey; + if (encryptionKey === undefined) { + logger.warn( + i18n.translate('xpack.reporting.serverConfig.randomEncryptionKey', { + defaultMessage: + 'Generating a random key for xpack.reporting.encryptionKey. To prevent sessions from being invalidated on ' + + 'restart, please set xpack.reporting.encryptionKey in kibana.yml', + }) + ); + encryptionKey = crypto.randomBytes(16).toString('hex'); + } + const { kibanaServer: reportingServer } = config; + const serverInfo = core.http.getServerInfo(); + // kibanaServer.hostname, default to server.host, don't allow "0" + let kibanaServerHostname = reportingServer.hostname + ? reportingServer.hostname + : serverInfo.host; + if (kibanaServerHostname === '0') { + logger.warn( + i18n.translate('xpack.reporting.serverConfig.invalidServerHostname', { + defaultMessage: + `Found 'server.host: "0"' in Kibana configuration. This is incompatible with Reporting. ` + + `To enable Reporting to work, '{configKey}: 0.0.0.0' is being automatically to the configuration. ` + + `You can change the setting to 'server.host: 0.0.0.0' or add '{configKey}: 0.0.0.0' in kibana.yml to prevent this message.`, + values: { configKey: 'xpack.reporting.kibanaServer.hostname' }, + }) + ); + kibanaServerHostname = '0.0.0.0'; + } + // kibanaServer.port, default to server.port + const kibanaServerPort = reportingServer.port + ? reportingServer.port + : serverInfo.port; // prettier-ignore + // kibanaServer.protocol, default to server.protocol + const kibanaServerProtocol = reportingServer.protocol + ? reportingServer.protocol + : serverInfo.protocol; + return { + ...config, + encryptionKey, + kibanaServer: { + hostname: kibanaServerHostname, + port: kibanaServerPort, + protocol: kibanaServerProtocol, + }, + }; + }), + mergeMap(async config => { + if (config.capture.browser.chromium.disableSandbox != null) { + // disableSandbox was set by user + return config; + } + + // disableSandbox was not set by user, apply default for OS + const { os, disableSandbox } = await getDefaultChromiumSandboxDisabled(); + const osName = [os.os, os.dist, os.release] + .filter(Boolean) + .map(capitalize) + .join(' '); + + logger.debug( + i18n.translate('xpack.reporting.serverConfig.osDetected', { + defaultMessage: `Running on OS: '{osName}'`, + values: { osName }, + }) + ); + + if (disableSandbox === true) { + logger.warn( + i18n.translate('xpack.reporting.serverConfig.autoSet.sandboxDisabled', { + defaultMessage: `Chromium sandbox provides an additional layer of protection, but is not supported for {osName} OS. Automatically setting '{configKey}: true'.`, + values: { + configKey: 'xpack.reporting.capture.browser.chromium.disableSandbox', + osName, + }, + }) + ); + } else { + logger.info( + i18n.translate('xpack.reporting.serverConfig.autoSet.sandboxEnabled', { + defaultMessage: `Chromium sandbox provides an additional layer of protection, and is supported for {osName} OS. Automatically enabling Chromium sandbox.`, + values: { osName }, + }) + ); + } + + return { + ...config, + capture: { + ...config.capture, + browser: { + ...config.capture.browser, + chromium: { ...config.capture.browser.chromium, disableSandbox }, + }, + }, + }; + }) + ); +} diff --git a/x-pack/plugins/reporting/server/config/default_chromium_sandbox_disabled.test.ts b/x-pack/plugins/reporting/server/config/default_chromium_sandbox_disabled.test.ts new file mode 100644 index 0000000000000..307c96bb34909 --- /dev/null +++ b/x-pack/plugins/reporting/server/config/default_chromium_sandbox_disabled.test.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('getos', () => { + return jest.fn(); +}); + +import { getDefaultChromiumSandboxDisabled } from './default_chromium_sandbox_disabled'; +import getos from 'getos'; + +interface TestObject { + os: string; + dist?: string; + release?: string; +} + +function defaultTest(os: TestObject, expectedDefault: boolean) { + test(`${expectedDefault ? 'disabled' : 'enabled'} on ${JSON.stringify(os)}`, async () => { + (getos as jest.Mock).mockImplementation(cb => cb(null, os)); + const actualDefault = await getDefaultChromiumSandboxDisabled(); + expect(actualDefault.disableSandbox).toBe(expectedDefault); + }); +} + +defaultTest({ os: 'win32' }, false); +defaultTest({ os: 'darwin' }, false); +defaultTest({ os: 'linux', dist: 'Centos', release: '7.0' }, true); +defaultTest({ os: 'linux', dist: 'Red Hat Linux', release: '7.0' }, true); +defaultTest({ os: 'linux', dist: 'Ubuntu Linux', release: '14.04' }, false); +defaultTest({ os: 'linux', dist: 'Ubuntu Linux', release: '16.04' }, false); +defaultTest({ os: 'linux', dist: 'SUSE Linux', release: '11' }, false); +defaultTest({ os: 'linux', dist: 'SUSE Linux', release: '12' }, false); +defaultTest({ os: 'linux', dist: 'SUSE Linux', release: '42.0' }, false); +defaultTest({ os: 'linux', dist: 'Debian', release: '8' }, true); +defaultTest({ os: 'linux', dist: 'Debian', release: '9' }, true); diff --git a/x-pack/legacy/plugins/reporting/server/browsers/default_chromium_sandbox_disabled.ts b/x-pack/plugins/reporting/server/config/default_chromium_sandbox_disabled.ts similarity index 80% rename from x-pack/legacy/plugins/reporting/server/browsers/default_chromium_sandbox_disabled.ts rename to x-pack/plugins/reporting/server/config/default_chromium_sandbox_disabled.ts index a21a4b33722ff..525041d5c772b 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/default_chromium_sandbox_disabled.ts +++ b/x-pack/plugins/reporting/server/config/default_chromium_sandbox_disabled.ts @@ -31,12 +31,17 @@ const distroSupportsUnprivilegedUsernamespaces = (distro: string) => { return true; }; -export async function getDefaultChromiumSandboxDisabled() { +interface OsSummary { + disableSandbox: boolean; + os: { os: string; dist?: string; release?: string }; +} + +export async function getDefaultChromiumSandboxDisabled(): Promise<OsSummary> { const os = await getos(); if (os.os === 'linux' && !distroSupportsUnprivilegedUsernamespaces(os.dist)) { - return true; + return { os, disableSandbox: true }; } else { - return false; + return { os, disableSandbox: false }; } } diff --git a/x-pack/plugins/reporting/server/config/index.ts b/x-pack/plugins/reporting/server/config/index.ts new file mode 100644 index 0000000000000..f0a0a093aa8c0 --- /dev/null +++ b/x-pack/plugins/reporting/server/config/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginConfigDescriptor } from 'kibana/server'; +import { ConfigSchema, ConfigType } from './schema'; + +export { createConfig$ } from './create_config'; + +export const config: PluginConfigDescriptor<ConfigType> = { + schema: ConfigSchema, + deprecations: ({ unused }) => [ + unused('capture.browser.chromium.maxScreenshotDimension'), + unused('capture.concurrency'), + unused('capture.settleTime'), + unused('capture.timeout'), + unused('kibanaApp'), + ], +}; + +export { ConfigSchema, ConfigType }; diff --git a/x-pack/plugins/reporting/server/config/schema.test.ts b/x-pack/plugins/reporting/server/config/schema.test.ts new file mode 100644 index 0000000000000..41285c2bfa133 --- /dev/null +++ b/x-pack/plugins/reporting/server/config/schema.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { ConfigSchema } from './schema'; + +describe('Reporting Config Schema', () => { + it(`context {"dev":false,"dist":false} produces correct config`, () => { + expect(ConfigSchema.validate({}, { dev: false, dist: false })).toMatchObject({ + capture: { + browser: { + autoDownload: true, + chromium: { proxy: { enabled: false } }, + type: 'chromium', + }, + loadDelay: 3000, + maxAttempts: 1, + networkPolicy: { + enabled: true, + rules: [ + { allow: true, host: undefined, protocol: 'http:' }, + { allow: true, host: undefined, protocol: 'https:' }, + { allow: true, host: undefined, protocol: 'ws:' }, + { allow: true, host: undefined, protocol: 'wss:' }, + { allow: true, host: undefined, protocol: 'data:' }, + { allow: false, host: undefined, protocol: undefined }, + ], + }, + viewport: { height: 1200, width: 1950 }, + zoom: 2, + }, + csv: { + checkForFormulas: true, + enablePanelActionDownload: true, + maxSizeBytes: 10485760, + scroll: { duration: '30s', size: 500 }, + }, + encryptionKey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + index: '.reporting', + kibanaServer: {}, + poll: { + jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, + jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, + }, + queue: { + indexInterval: 'week', + pollEnabled: true, + pollInterval: 3000, + pollIntervalErrorMultiplier: 10, + timeout: 120000, + }, + roles: { allow: ['reporting_user'] }, + }); + }); + + it(`context {"dev":false,"dist":true} produces correct config`, () => { + expect(ConfigSchema.validate({}, { dev: false, dist: true })).toMatchObject({ + capture: { + browser: { + autoDownload: false, + chromium: { + inspect: false, + proxy: { enabled: false }, + }, + type: 'chromium', + }, + loadDelay: 3000, + maxAttempts: 3, + networkPolicy: { + enabled: true, + rules: [ + { allow: true, host: undefined, protocol: 'http:' }, + { allow: true, host: undefined, protocol: 'https:' }, + { allow: true, host: undefined, protocol: 'ws:' }, + { allow: true, host: undefined, protocol: 'wss:' }, + { allow: true, host: undefined, protocol: 'data:' }, + { allow: false, host: undefined, protocol: undefined }, + ], + }, + viewport: { height: 1200, width: 1950 }, + zoom: 2, + }, + csv: { + checkForFormulas: true, + enablePanelActionDownload: true, + maxSizeBytes: 10485760, + scroll: { duration: '30s', size: 500 }, + }, + index: '.reporting', + kibanaServer: {}, + poll: { + jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, + jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, + }, + queue: { + indexInterval: 'week', + pollEnabled: true, + pollInterval: 3000, + pollIntervalErrorMultiplier: 10, + timeout: 120000, + }, + roles: { allow: ['reporting_user'] }, + }); + }); + + it(`allows optional settings`, () => { + // encryption key + expect( + ConfigSchema.validate({ encryptionKey: 'qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq' }) + .encryptionKey + ).toBe('qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); + + // disableSandbox + expect( + ConfigSchema.validate({ capture: { browser: { chromium: { disableSandbox: true } } } }) + .capture.browser.chromium + ).toMatchObject({ disableSandbox: true, proxy: { enabled: false } }); + + // kibanaServer + expect( + ConfigSchema.validate({ kibanaServer: { hostname: 'Frodo' } }).kibanaServer + ).toMatchObject({ hostname: 'Frodo' }); + }); + + it(`logs the proper validation messages`, () => { + // kibanaServer + const throwValidationErr = () => ConfigSchema.validate({ kibanaServer: { hostname: '0' } }); + expect(throwValidationErr).toThrowError( + `[kibanaServer.hostname]: must not be "0" for the headless browser to correctly resolve the host` + ); + }); +}); diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts new file mode 100644 index 0000000000000..67d70c1513c15 --- /dev/null +++ b/x-pack/plugins/reporting/server/config/schema.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 { schema, TypeOf } from '@kbn/config-schema'; +import moment from 'moment'; + +const KibanaServerSchema = schema.object({ + hostname: schema.maybe( + schema.string({ + validate(value) { + if (value === '0') { + return 'must not be "0" for the headless browser to correctly resolve the host'; + } + }, + hostname: true, + }) + ), + port: schema.maybe(schema.number()), + protocol: schema.maybe( + schema.string({ + validate(value) { + if (!/^https?$/.test(value)) { + return 'must be "http" or "https"'; + } + }, + }) + ), +}); // default values are all dynamic in createConfig$ + +const QueueSchema = schema.object({ + indexInterval: schema.string({ defaultValue: 'week' }), + pollEnabled: schema.boolean({ defaultValue: true }), + pollInterval: schema.number({ defaultValue: 3000 }), + pollIntervalErrorMultiplier: schema.number({ defaultValue: 10 }), + timeout: schema.number({ defaultValue: moment.duration(2, 'm').asMilliseconds() }), +}); + +const RulesSchema = schema.object({ + allow: schema.boolean(), + host: schema.maybe(schema.string()), + protocol: schema.maybe(schema.string()), +}); + +const CaptureSchema = schema.object({ + timeouts: schema.object({ + openUrl: schema.number({ defaultValue: 30000 }), + waitForElements: schema.number({ defaultValue: 30000 }), + renderComplete: schema.number({ defaultValue: 30000 }), + }), + networkPolicy: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + rules: schema.arrayOf(RulesSchema, { + defaultValue: [ + { host: undefined, allow: true, protocol: 'http:' }, + { host: undefined, allow: true, protocol: 'https:' }, + { host: undefined, allow: true, protocol: 'ws:' }, + { host: undefined, allow: true, protocol: 'wss:' }, + { host: undefined, allow: true, protocol: 'data:' }, + { host: undefined, allow: false, protocol: undefined }, // Default action is to deny! + ], + }), + }), + zoom: schema.number({ defaultValue: 2 }), + viewport: schema.object({ + width: schema.number({ defaultValue: 1950 }), + height: schema.number({ defaultValue: 1200 }), + }), + loadDelay: schema.number({ + defaultValue: moment.duration(3, 's').asMilliseconds(), + }), // TODO: use schema.duration + browser: schema.object({ + autoDownload: schema.conditional( + schema.contextRef('dist'), + true, + schema.boolean({ defaultValue: false }), + schema.boolean({ defaultValue: true }) + ), + chromium: schema.object({ + inspect: schema.conditional( + schema.contextRef('dist'), + true, + schema.boolean({ defaultValue: false }), + schema.maybe(schema.never()) + ), + disableSandbox: schema.maybe(schema.boolean()), // default value is dynamic in createConfig$ + proxy: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + server: schema.conditional( + schema.siblingRef('enabled'), + true, + schema.uri({ scheme: ['http', 'https'] }), + schema.maybe(schema.never()) + ), + bypass: schema.conditional( + schema.siblingRef('enabled'), + true, + schema.arrayOf(schema.string({ hostname: true })), + schema.maybe(schema.never()) + ), + }), + }), + type: schema.string({ defaultValue: 'chromium' }), + }), + maxAttempts: schema.conditional( + schema.contextRef('dist'), + true, + schema.number({ defaultValue: 3 }), + schema.number({ defaultValue: 1 }) + ), +}); + +const CsvSchema = schema.object({ + checkForFormulas: schema.boolean({ defaultValue: true }), + enablePanelActionDownload: schema.boolean({ defaultValue: true }), + maxSizeBytes: schema.number({ + defaultValue: 1024 * 1024 * 10, // 10MB + }), // TODO: use schema.byteSize + useByteOrderMarkEncoding: schema.boolean({ defaultValue: false }), + scroll: schema.object({ + duration: schema.string({ + defaultValue: '30s', + validate(value) { + if (!/^[0-9]+(d|h|m|s|ms|micros|nanos)$/.test(value)) { + return 'must be a duration string'; + } + }, + }), + size: schema.number({ defaultValue: 500 }), + }), +}); + +const EncryptionKeySchema = schema.conditional( + schema.contextRef('dist'), + true, + schema.maybe(schema.string({ minLength: 32 })), // default value is dynamic in createConfig$ + schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) }) +); + +const RolesSchema = schema.object({ + allow: schema.arrayOf(schema.string(), { defaultValue: ['reporting_user'] }), +}); + +const IndexSchema = schema.string({ defaultValue: '.reporting' }); + +const PollSchema = schema.object({ + jobCompletionNotifier: schema.object({ + interval: schema.number({ + defaultValue: moment.duration(10, 's').asMilliseconds(), + }), // TODO: use schema.duration + intervalErrorMultiplier: schema.number({ defaultValue: 5 }), + }), + jobsRefresh: schema.object({ + interval: schema.number({ + defaultValue: moment.duration(5, 's').asMilliseconds(), + }), // TODO: use schema.duration + intervalErrorMultiplier: schema.number({ defaultValue: 5 }), + }), +}); + +export const ConfigSchema = schema.object({ + kibanaServer: KibanaServerSchema, + queue: QueueSchema, + capture: CaptureSchema, + csv: CsvSchema, + encryptionKey: EncryptionKeySchema, + roles: RolesSchema, + index: IndexSchema, + poll: PollSchema, +}); + +export type ConfigType = TypeOf<typeof ConfigSchema>; diff --git a/x-pack/plugins/reporting/server/index.ts b/x-pack/plugins/reporting/server/index.ts new file mode 100644 index 0000000000000..2b1844cf2e10e --- /dev/null +++ b/x-pack/plugins/reporting/server/index.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. + */ + +import { PluginInitializerContext } from 'src/core/server'; +import { ReportingPlugin } from './plugin'; + +export { config, ConfigSchema } from './config'; +export { ConfigType, PluginsSetup } from './plugin'; + +export const plugin = (initializerContext: PluginInitializerContext) => + new ReportingPlugin(initializerContext); diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts new file mode 100644 index 0000000000000..905ed2b237c86 --- /dev/null +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'src/core/server'; +import { ConfigType, createConfig$ } from './config'; + +export interface PluginsSetup { + /** @deprecated */ + __legacy: { + config$: Observable<ConfigType>; + }; +} + +export class ReportingPlugin implements Plugin<PluginsSetup> { + private readonly log: Logger; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.log = this.initializerContext.logger.get(); + } + + public async setup(core: CoreSetup): Promise<PluginsSetup> { + return { + __legacy: { + config$: createConfig$(core, this.initializerContext, this.log).pipe(first()), + }, + }; + } + + public start() {} + public stop() {} +} + +export { ConfigType }; diff --git a/x-pack/plugins/searchprofiler/public/application/editor/editor.test.tsx b/x-pack/plugins/searchprofiler/public/application/editor/editor.test.tsx index ad56b3957eb74..e56fc79156666 100644 --- a/x-pack/plugins/searchprofiler/public/application/editor/editor.test.tsx +++ b/x-pack/plugins/searchprofiler/public/application/editor/editor.test.tsx @@ -6,8 +6,6 @@ import 'brace'; import 'brace/mode/json'; -import '../../../../es_ui_shared/console_lang/mocks'; - import { registerTestBed } from '../../../../../test_utils'; import { Editor, Props } from '.'; diff --git a/x-pack/plugins/searchprofiler/public/application/editor/init_editor.ts b/x-pack/plugins/searchprofiler/public/application/editor/init_editor.ts index 6f19ce12eb639..3ad92531e4367 100644 --- a/x-pack/plugins/searchprofiler/public/application/editor/init_editor.ts +++ b/x-pack/plugins/searchprofiler/public/application/editor/init_editor.ts @@ -5,7 +5,7 @@ */ import ace from 'brace'; -import { installXJsonMode } from '../../../../es_ui_shared/console_lang'; +import { installXJsonMode } from '../../../../../../src/plugins/es_ui_shared/public'; export function initializeEditor({ el, diff --git a/x-pack/plugins/searchprofiler/public/application/utils/check_for_json_errors.ts b/x-pack/plugins/searchprofiler/public/application/utils/check_for_json_errors.ts index 99687de0f1440..58a62c4636c25 100644 --- a/x-pack/plugins/searchprofiler/public/application/utils/check_for_json_errors.ts +++ b/x-pack/plugins/searchprofiler/public/application/utils/check_for_json_errors.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { collapseLiteralStrings } from '../../../../../../src/plugins/es_ui_shared/console_lang/lib'; +import { collapseLiteralStrings } from '../../../../../../src/plugins/es_ui_shared/public'; export function checkForParseErrors(json: string) { const sanitizedJson = collapseLiteralStrings(json); diff --git a/x-pack/plugins/security/common/licensing/license_service.test.ts b/x-pack/plugins/security/common/licensing/license_service.test.ts index dfc94042ef930..5bdfa7d4886aa 100644 --- a/x-pack/plugins/security/common/licensing/license_service.test.ts +++ b/x-pack/plugins/security/common/licensing/license_service.test.ts @@ -78,14 +78,14 @@ describe('license features', function() { expect(subscriptionHandler.mock.calls[1]).toMatchInlineSnapshot(` Array [ Object { - "allowLogin": false, - "allowRbac": false, - "allowRoleDocumentLevelSecurity": false, - "allowRoleFieldLevelSecurity": false, - "allowSubFeaturePrivileges": false, - "showLinks": false, - "showLogin": false, - "showRoleMappingsManagement": false, + "allowLogin": true, + "allowRbac": true, + "allowRoleDocumentLevelSecurity": true, + "allowRoleFieldLevelSecurity": true, + "allowSubFeaturePrivileges": true, + "showLinks": true, + "showLogin": true, + "showRoleMappingsManagement": true, }, ] `); diff --git a/x-pack/plugins/security/server/audit/audit_logger.test.ts b/x-pack/plugins/security/server/audit/audit_logger.test.ts index 01cde02b7dfdd..f7ee210a21a74 100644 --- a/x-pack/plugins/security/server/audit/audit_logger.test.ts +++ b/x-pack/plugins/security/server/audit/audit_logger.test.ts @@ -18,25 +18,46 @@ describe(`#savedObjectsAuthorizationFailure`, () => { const username = 'foo-user'; const action = 'foo-action'; const types = ['foo-type-1', 'foo-type-2']; - const missing = [`saved_object:${types[0]}/foo-action`, `saved_object:${types[1]}/foo-action`]; + const spaceIds = ['foo-space', 'bar-space']; + const missing = [ + { + spaceId: 'foo-space', + privilege: `saved_object:${types[0]}/${action}`, + }, + { + spaceId: 'foo-space', + privilege: `saved_object:${types[1]}/${action}`, + }, + ]; const args = { foo: 'bar', baz: 'quz', }; - securityAuditLogger.savedObjectsAuthorizationFailure(username, action, types, missing, args); + securityAuditLogger.savedObjectsAuthorizationFailure( + username, + action, + types, + spaceIds, + missing, + args + ); expect(auditLogger.log).toHaveBeenCalledWith( 'saved_objects_authorization_failure', - expect.stringContaining(`${username} unauthorized to ${action}`), + expect.any(String), { username, action, types, + spaceIds, missing, args, } ); + expect(auditLogger.log.mock.calls[0][1]).toMatchInlineSnapshot( + `"foo-user unauthorized to [foo-action] [foo-type-1,foo-type-2] in [foo-space,bar-space]: missing [(foo-space)saved_object:foo-type-1/foo-action,(foo-space)saved_object:foo-type-2/foo-action]"` + ); }); }); @@ -47,22 +68,27 @@ describe(`#savedObjectsAuthorizationSuccess`, () => { const username = 'foo-user'; const action = 'foo-action'; const types = ['foo-type-1', 'foo-type-2']; + const spaceIds = ['foo-space', 'bar-space']; const args = { foo: 'bar', baz: 'quz', }; - securityAuditLogger.savedObjectsAuthorizationSuccess(username, action, types, args); + securityAuditLogger.savedObjectsAuthorizationSuccess(username, action, types, spaceIds, args); expect(auditLogger.log).toHaveBeenCalledWith( 'saved_objects_authorization_success', - expect.stringContaining(`${username} authorized to ${action}`), + expect.any(String), { username, action, types, + spaceIds, args, } ); + expect(auditLogger.log.mock.calls[0][1]).toMatchInlineSnapshot( + `"foo-user authorized to [foo-action] [foo-type-1,foo-type-2] in [foo-space,bar-space]"` + ); }); }); diff --git a/x-pack/plugins/security/server/audit/audit_logger.ts b/x-pack/plugins/security/server/audit/audit_logger.ts index df8df35f97b49..40b525b5d2188 100644 --- a/x-pack/plugins/security/server/audit/audit_logger.ts +++ b/x-pack/plugins/security/server/audit/audit_logger.ts @@ -13,16 +13,23 @@ export class SecurityAuditLogger { username: string, action: string, types: string[], - missing: string[], + spaceIds: string[], + missing: Array<{ spaceId?: string; privilege: string }>, args?: Record<string, unknown> ) { + const typesString = types.join(','); + const spacesString = spaceIds.length ? ` in [${spaceIds.join(',')}]` : ''; + const missingString = missing + .map(({ spaceId, privilege }) => `${spaceId ? `(${spaceId})` : ''}${privilege}`) + .join(','); this.getAuditLogger().log( 'saved_objects_authorization_failure', - `${username} unauthorized to ${action} ${types.join(',')}, missing ${missing.join(',')}`, + `${username} unauthorized to [${action}] [${typesString}]${spacesString}: missing [${missingString}]`, { username, action, types, + spaceIds, missing, args, } @@ -33,15 +40,19 @@ export class SecurityAuditLogger { username: string, action: string, types: string[], + spaceIds: string[], args?: Record<string, unknown> ) { + const typesString = types.join(','); + const spacesString = spaceIds.length ? ` in [${spaceIds.join(',')}]` : ''; this.getAuditLogger().log( 'saved_objects_authorization_success', - `${username} authorized to ${action} ${types.join(',')}`, + `${username} authorized to [${action}] [${typesString}]${spacesString}`, { username, action, types, + spaceIds, args, } ); diff --git a/x-pack/plugins/security/server/authorization/__snapshots__/check_privileges.test.ts.snap b/x-pack/plugins/security/server/authorization/__snapshots__/check_privileges.test.ts.snap deleted file mode 100644 index 1212c2cd6a5cb..0000000000000 --- a/x-pack/plugins/security/server/authorization/__snapshots__/check_privileges.test.ts.snap +++ /dev/null @@ -1,27 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`#atSpace throws error when checking for login and user has login but doesn't have version 1`] = `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]`; - -exports[`#atSpace with a malformed Elasticsearch response throws a validation error when an extra privilege is present in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because ["saved_object:bar-type/get" is not allowed]]]]`; - -exports[`#atSpace with a malformed Elasticsearch response throws a validation error when privileges are missing in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because [child "saved_object:foo-type/get" fails because ["saved_object:foo-type/get" is required]]]]]`; - -exports[`#atSpaces throws error when Elasticsearch returns malformed response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because [child "mock-action:version" fails because ["mock-action:version" is required]]]]]`; - -exports[`#atSpaces throws error when checking for login and user has login but doesn't have version 1`] = `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]`; - -exports[`#atSpaces with a malformed Elasticsearch response throws a validation error when an a space is missing in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]`; - -exports[`#atSpaces with a malformed Elasticsearch response throws a validation error when an extra privilege is present in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]`; - -exports[`#atSpaces with a malformed Elasticsearch response throws a validation error when an extra space is present in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because ["space:space_3" is not allowed]]]`; - -exports[`#atSpaces with a malformed Elasticsearch response throws a validation error when privileges are missing in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]`; - -exports[`#globally throws error when Elasticsearch returns malformed response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because [child "mock-action:version" fails because ["mock-action:version" is required]]]]]`; - -exports[`#globally throws error when checking for login and user has login but doesn't have version 1`] = `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]`; - -exports[`#globally with a malformed Elasticsearch response throws a validation error when an extra privilege is present in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because ["saved_object:bar-type/get" is not allowed]]]]`; - -exports[`#globally with a malformed Elasticsearch response throws a validation error when privileges are missing in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because [child "saved_object:foo-type/get" fails because ["saved_object:foo-type/get" is required]]]]]`; diff --git a/x-pack/plugins/security/server/authorization/api_authorization.ts b/x-pack/plugins/security/server/authorization/api_authorization.ts index cc672fbc69e06..88b3f2c6f7155 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.ts @@ -24,7 +24,6 @@ export function initAPIAuthorization( // if there are no tags starting with "access:", just continue if (actionTags.length === 0) { - logger.debug('API endpoint is not marked with "access:" tags, skipping.'); return toolkit.next(); } @@ -34,11 +33,11 @@ export function initAPIAuthorization( // we've actually authorized the request if (checkPrivilegesResponse.hasAllRequested) { - logger.debug(`authorized for "${request.url.path}"`); + logger.debug(`User authorized for "${request.url.path}"`); return toolkit.next(); } - logger.debug(`not authorized for "${request.url.path}"`); + logger.warn(`User not authorized for "${request.url.path}": responding with 404`); return response.notFound(); }); } 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 8c1241937892e..a64c5d509ca11 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.test.ts @@ -31,123 +31,121 @@ const createMockClusterClient = (response: any) => { }; describe('#atSpace', () => { - const checkPrivilegesAtSpaceTest = ( - description: string, - options: { - spaceId: string; - privilegeOrPrivileges: string | string[]; - esHasPrivilegesResponse: HasPrivilegesResponse; - expectedResult?: any; - expectErrorThrown?: any; + const checkPrivilegesAtSpaceTest = async (options: { + spaceId: string; + privilegeOrPrivileges: string | string[]; + esHasPrivilegesResponse: HasPrivilegesResponse; + }) => { + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( + options.esHasPrivilegesResponse + ); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( + mockActions, + mockClusterClient, + application + ); + const request = httpServerMock.createKibanaRequest(); + const checkPrivileges = checkPrivilegesWithRequest(request); + + let actualResult; + let errorThrown = null; + try { + actualResult = await checkPrivileges.atSpace(options.spaceId, options.privilegeOrPrivileges); + } catch (err) { + errorThrown = err; } - ) => { - test(description, async () => { - const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( - options.esHasPrivilegesResponse - ); - const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( - mockActions, - mockClusterClient, - application - ); - const request = httpServerMock.createKibanaRequest(); - const checkPrivileges = checkPrivilegesWithRequest(request); - - let actualResult; - let errorThrown = null; - try { - actualResult = await checkPrivileges.atSpace( - options.spaceId, - options.privilegeOrPrivileges - ); - } catch (err) { - errorThrown = err; - } - expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'shield.hasPrivileges', - { - body: { - applications: [ - { - application, - resources: [`space:${options.spaceId}`], - privileges: uniq([ - mockActions.version, - mockActions.login, - ...(Array.isArray(options.privilegeOrPrivileges) - ? options.privilegeOrPrivileges - : [options.privilegeOrPrivileges]), - ]), - }, - ], + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.hasPrivileges', { + body: { + applications: [ + { + application, + resources: [`space:${options.spaceId}`], + privileges: uniq([ + mockActions.version, + mockActions.login, + ...(Array.isArray(options.privilegeOrPrivileges) + ? options.privilegeOrPrivileges + : [options.privilegeOrPrivileges]), + ]), }, - } - ); - - if (options.expectedResult) { - expect(errorThrown).toBeNull(); - expect(actualResult).toEqual(options.expectedResult); - } - - if (options.expectErrorThrown) { - expect(errorThrown).toMatchSnapshot(); - } + ], + }, }); + + if (errorThrown) { + return errorThrown; + } + return actualResult; }; - checkPrivilegesAtSpaceTest('successful when checking for login and user has login', { - spaceId: 'space_1', - privilegeOrPrivileges: mockActions.login, - esHasPrivilegesResponse: { - has_all_requested: true, - username: 'foo-username', - application: { - [application]: { - 'space:space_1': { - [mockActions.login]: true, - [mockActions.version]: true, + test('successful when checking for login and user has login', async () => { + const result = await checkPrivilegesAtSpaceTest({ + spaceId: 'space_1', + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + }, }, }, }, - }, - expectedResult: { - hasAllRequested: true, - username: 'foo-username', - privileges: { - [mockActions.login]: true, - }, - }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": "space_1", + }, + ], + "username": "foo-username", + } + `); }); - checkPrivilegesAtSpaceTest(`failure when checking for login and user doesn't have login`, { - spaceId: 'space_1', - privilegeOrPrivileges: mockActions.login, - esHasPrivilegesResponse: { - has_all_requested: false, - username: 'foo-username', - application: { - [application]: { - 'space:space_1': { - [mockActions.login]: false, - [mockActions.version]: true, + test(`failure when checking for login and user doesn't have login`, async () => { + const result = await checkPrivilegesAtSpaceTest({ + spaceId: 'space_1', + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: false, + [mockActions.version]: true, + }, }, }, }, - }, - expectedResult: { - hasAllRequested: false, - username: 'foo-username', - privileges: { - [mockActions.login]: false, - }, - }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Array [ + Object { + "authorized": false, + "privilege": "mock-action:login", + "resource": "space_1", + }, + ], + "username": "foo-username", + } + `); }); - checkPrivilegesAtSpaceTest( - `throws error when checking for login and user has login but doesn't have version`, - { + 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, esHasPrivilegesResponse: { @@ -162,74 +160,99 @@ describe('#atSpace', () => { }, }, }, - expectErrorThrown: true, - } - ); - - checkPrivilegesAtSpaceTest(`successful when checking for two actions and the user has both`, { - spaceId: 'space_1', - privilegeOrPrivileges: [ - `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, + }); + expect(result).toMatchInlineSnapshot( + `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]` + ); + }); + + test(`successful when checking for two actions and the user has both`, async () => { + const result = await checkPrivilegesAtSpaceTest({ + spaceId: 'space_1', + privilegeOrPrivileges: [ + `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, + }, }, }, }, - }, - expectedResult: { - hasAllRequested: true, - username: 'foo-username', - privileges: { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, - }, - }, + }); + 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", + }, + ], + "username": "foo-username", + } + `); }); - checkPrivilegesAtSpaceTest(`failure when checking for two actions and the user has only one`, { - spaceId: 'space_1', - privilegeOrPrivileges: [ - `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`]: true, + test(`failure when checking for two actions and the user has only one`, async () => { + const result = await checkPrivilegesAtSpaceTest({ + spaceId: 'space_1', + privilegeOrPrivileges: [ + `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`]: true, + }, }, }, }, - }, - expectedResult: { - hasAllRequested: false, - username: 'foo-username', - privileges: { - [`saved_object:${savedObjectTypes[0]}/get`]: false, - [`saved_object:${savedObjectTypes[1]}/get`]: true, - }, - }, + }); + 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", + }, + ], + "username": "foo-username", + } + `); }); describe('with a malformed Elasticsearch response', () => { - checkPrivilegesAtSpaceTest( - `throws a validation error when an extra privilege is present in the response`, - { + 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`], esHasPrivilegesResponse: { @@ -246,13 +269,14 @@ describe('#atSpace', () => { }, }, }, - expectErrorThrown: true, - } - ); + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because ["saved_object:bar-type/get" is not allowed]]]]` + ); + }); - checkPrivilegesAtSpaceTest( - `throws a validation error when privileges are missing in the response`, - { + 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`], esHasPrivilegesResponse: { @@ -267,82 +291,69 @@ describe('#atSpace', () => { }, }, }, - expectErrorThrown: true, - } - ); + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because [child "saved_object:foo-type/get" fails because ["saved_object:foo-type/get" is required]]]]]` + ); + }); }); }); describe('#atSpaces', () => { - const checkPrivilegesAtSpacesTest = ( - description: string, - options: { - spaceIds: string[]; - privilegeOrPrivileges: string | string[]; - esHasPrivilegesResponse: HasPrivilegesResponse; - expectedResult?: any; - expectErrorThrown?: any; - } - ) => { - test(description, async () => { - const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( - options.esHasPrivilegesResponse - ); - const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( - mockActions, - mockClusterClient, - application - ); - const request = httpServerMock.createKibanaRequest(); - const checkPrivileges = checkPrivilegesWithRequest(request); - - let actualResult; - let errorThrown = null; - try { - actualResult = await checkPrivileges.atSpaces( - options.spaceIds, - options.privilegeOrPrivileges - ); - } catch (err) { - errorThrown = err; - } + const checkPrivilegesAtSpacesTest = async (options: { + spaceIds: string[]; + privilegeOrPrivileges: string | string[]; + esHasPrivilegesResponse: HasPrivilegesResponse; + }) => { + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( + options.esHasPrivilegesResponse + ); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( + mockActions, + mockClusterClient, + application + ); + const request = httpServerMock.createKibanaRequest(); + const checkPrivileges = checkPrivilegesWithRequest(request); - expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'shield.hasPrivileges', - { - body: { - applications: [ - { - application, - resources: options.spaceIds.map(spaceId => `space:${spaceId}`), - privileges: uniq([ - mockActions.version, - mockActions.login, - ...(Array.isArray(options.privilegeOrPrivileges) - ? options.privilegeOrPrivileges - : [options.privilegeOrPrivileges]), - ]), - }, - ], - }, - } + let actualResult; + let errorThrown = null; + try { + actualResult = await checkPrivileges.atSpaces( + options.spaceIds, + options.privilegeOrPrivileges ); + } catch (err) { + errorThrown = err; + } - if (options.expectedResult) { - expect(errorThrown).toBeNull(); - expect(actualResult).toEqual(options.expectedResult); - } - - if (options.expectErrorThrown) { - expect(errorThrown).toMatchSnapshot(); - } + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.hasPrivileges', { + body: { + applications: [ + { + application, + resources: options.spaceIds.map(spaceId => `space:${spaceId}`), + privileges: uniq([ + mockActions.version, + mockActions.login, + ...(Array.isArray(options.privilegeOrPrivileges) + ? options.privilegeOrPrivileges + : [options.privilegeOrPrivileges]), + ]), + }, + ], + }, }); + + if (errorThrown) { + return errorThrown; + } + return actualResult; }; - checkPrivilegesAtSpacesTest( - 'successful when checking for login and user has login at both spaces', - { + 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, esHasPrivilegesResponse: { @@ -361,24 +372,29 @@ describe('#atSpaces', () => { }, }, }, - expectedResult: { - hasAllRequested: true, - username: 'foo-username', - spacePrivileges: { - space_1: { - [mockActions.login]: true, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": "space_1", }, - space_2: { - [mockActions.login]: true, + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": "space_2", }, - }, - }, - } - ); + ], + "username": "foo-username", + } + `); + }); - checkPrivilegesAtSpacesTest( - 'failure when checking for login and user has login at only one space', - { + 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, esHasPrivilegesResponse: { @@ -397,24 +413,29 @@ describe('#atSpaces', () => { }, }, }, - expectedResult: { - hasAllRequested: false, - username: 'foo-username', - spacePrivileges: { - space_1: { - [mockActions.login]: true, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": "space_1", }, - space_2: { - [mockActions.login]: false, + Object { + "authorized": false, + "privilege": "mock-action:login", + "resource": "space_2", }, - }, - }, - } - ); + ], + "username": "foo-username", + } + `); + }); - checkPrivilegesAtSpacesTest( - `throws error when checking for login and user has login but doesn't have version`, - { + 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, esHasPrivilegesResponse: { @@ -433,38 +454,43 @@ describe('#atSpaces', () => { }, }, }, - expectErrorThrown: true, - } - ); - - checkPrivilegesAtSpacesTest(`throws error when Elasticsearch returns malformed response`, { - spaceIds: ['space_1', 'space_2'], - privilegeOrPrivileges: [ - `saved_object:${savedObjectTypes[0]}/get`, - `saved_object:${savedObjectTypes[1]}/get`, - ], - esHasPrivilegesResponse: { - has_all_requested: true, - username: 'foo-username', - application: { - [application]: { - 'space:space_1': { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, - }, - 'space:space_2': { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, + }); + expect(result).toMatchInlineSnapshot( + `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]` + ); + }); + + test(`throws error when Elasticsearch returns malformed response`, async () => { + const result = await checkPrivilegesAtSpacesTest({ + spaceIds: ['space_1', 'space_2'], + privilegeOrPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [`saved_object:${savedObjectTypes[0]}/get`]: true, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, + 'space:space_2': { + [`saved_object:${savedObjectTypes[0]}/get`]: true, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, }, }, }, - }, - expectErrorThrown: true, + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because [child "mock-action:version" fails because ["mock-action:version" is required]]]]]` + ); }); - checkPrivilegesAtSpacesTest( - `successful when checking for two actions at two spaces and user has it all`, - { + 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: [ `saved_object:${savedObjectTypes[0]}/get`, @@ -490,26 +516,39 @@ describe('#atSpaces', () => { }, }, }, - expectedResult: { - hasAllRequested: true, - username: 'foo-username', - spacePrivileges: { - space_1: { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", }, - space_2: { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, + 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", + } + `); + }); - checkPrivilegesAtSpacesTest( - `failure when checking for two actions at two spaces and user has one action at one space`, - { + 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: [ `saved_object:${savedObjectTypes[0]}/get`, @@ -535,26 +574,39 @@ describe('#atSpaces', () => { }, }, }, - expectedResult: { - hasAllRequested: false, - username: 'foo-username', - spacePrivileges: { - space_1: { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: false, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", }, - space_2: { - [`saved_object:${savedObjectTypes[0]}/get`]: false, - [`saved_object:${savedObjectTypes[1]}/get`]: false, + 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", + } + `); + }); - checkPrivilegesAtSpacesTest( - `failure when checking for two actions at two spaces and user has two actions at one space`, - { + 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: [ `saved_object:${savedObjectTypes[0]}/get`, @@ -580,26 +632,39 @@ describe('#atSpaces', () => { }, }, }, - expectedResult: { - hasAllRequested: false, - username: 'foo-username', - spacePrivileges: { - space_1: { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", }, - space_2: { - [`saved_object:${savedObjectTypes[0]}/get`]: false, - [`saved_object:${savedObjectTypes[1]}/get`]: false, + 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", + } + `); + }); - checkPrivilegesAtSpacesTest( - `failure when checking for two actions at two spaces and user has two actions at one space & one action at the other`, - { + 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: [ `saved_object:${savedObjectTypes[0]}/get`, @@ -625,27 +690,40 @@ describe('#atSpaces', () => { }, }, }, - expectedResult: { - hasAllRequested: false, - username: 'foo-username', - spacePrivileges: { - space_1: { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", }, - space_2: { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: false, + 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", + } + `); + }); describe('with a malformed Elasticsearch response', () => { - checkPrivilegesAtSpacesTest( - `throws a validation error when an extra privilege is present in the response`, - { + 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`], esHasPrivilegesResponse: { @@ -668,13 +746,14 @@ describe('#atSpaces', () => { }, }, }, - expectErrorThrown: true, - } - ); + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]` + ); + }); - checkPrivilegesAtSpacesTest( - `throws a validation error when privileges are missing in the response`, - { + 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`], esHasPrivilegesResponse: { @@ -695,13 +774,14 @@ describe('#atSpaces', () => { }, }, }, - expectErrorThrown: true, - } - ); + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]` + ); + }); - checkPrivilegesAtSpacesTest( - `throws a validation error when an extra space is present in the response`, - { + 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`], esHasPrivilegesResponse: { @@ -727,13 +807,14 @@ describe('#atSpaces', () => { }, }, }, - expectErrorThrown: true, - } - ); + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because ["space:space_3" is not allowed]]]` + ); + }); - checkPrivilegesAtSpacesTest( - `throws a validation error when an a space is missing in the response`, - { + 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`], esHasPrivilegesResponse: { @@ -749,124 +830,127 @@ describe('#atSpaces', () => { }, }, }, - expectErrorThrown: true, - } - ); + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]` + ); + }); }); }); describe('#globally', () => { - const checkPrivilegesGloballyTest = ( - description: string, - options: { - privilegeOrPrivileges: string | string[]; - esHasPrivilegesResponse: HasPrivilegesResponse; - expectedResult?: any; - expectErrorThrown?: any; + const checkPrivilegesGloballyTest = async (options: { + privilegeOrPrivileges: string | string[]; + esHasPrivilegesResponse: HasPrivilegesResponse; + }) => { + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( + options.esHasPrivilegesResponse + ); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( + mockActions, + mockClusterClient, + application + ); + const request = httpServerMock.createKibanaRequest(); + const checkPrivileges = checkPrivilegesWithRequest(request); + + let actualResult; + let errorThrown = null; + try { + actualResult = await checkPrivileges.globally(options.privilegeOrPrivileges); + } catch (err) { + errorThrown = err; } - ) => { - test(description, async () => { - const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( - options.esHasPrivilegesResponse - ); - const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( - mockActions, - mockClusterClient, - application - ); - const request = httpServerMock.createKibanaRequest(); - const checkPrivileges = checkPrivilegesWithRequest(request); - - let actualResult; - let errorThrown = null; - try { - actualResult = await checkPrivileges.globally(options.privilegeOrPrivileges); - } catch (err) { - errorThrown = err; - } - expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'shield.hasPrivileges', - { - body: { - applications: [ - { - application, - resources: [GLOBAL_RESOURCE], - privileges: uniq([ - mockActions.version, - mockActions.login, - ...(Array.isArray(options.privilegeOrPrivileges) - ? options.privilegeOrPrivileges - : [options.privilegeOrPrivileges]), - ]), - }, - ], + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.hasPrivileges', { + body: { + applications: [ + { + application, + resources: [GLOBAL_RESOURCE], + privileges: uniq([ + mockActions.version, + mockActions.login, + ...(Array.isArray(options.privilegeOrPrivileges) + ? options.privilegeOrPrivileges + : [options.privilegeOrPrivileges]), + ]), }, - } - ); - - if (options.expectedResult) { - expect(errorThrown).toBeNull(); - expect(actualResult).toEqual(options.expectedResult); - } - - if (options.expectErrorThrown) { - expect(errorThrown).toMatchSnapshot(); - } + ], + }, }); + + if (errorThrown) { + return errorThrown; + } + return actualResult; }; - checkPrivilegesGloballyTest('successful when checking for login and user has login', { - privilegeOrPrivileges: mockActions.login, - esHasPrivilegesResponse: { - has_all_requested: true, - username: 'foo-username', - application: { - [application]: { - [GLOBAL_RESOURCE]: { - [mockActions.login]: true, - [mockActions.version]: true, + test('successful when checking for login and user has login', async () => { + const result = await checkPrivilegesGloballyTest({ + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + }, }, }, }, - }, - expectedResult: { - hasAllRequested: true, - username: 'foo-username', - privileges: { - [mockActions.login]: true, - }, - }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": undefined, + }, + ], + "username": "foo-username", + } + `); }); - checkPrivilegesGloballyTest(`failure when checking for login and user doesn't have login`, { - privilegeOrPrivileges: mockActions.login, - esHasPrivilegesResponse: { - has_all_requested: false, - username: 'foo-username', - application: { - [application]: { - [GLOBAL_RESOURCE]: { - [mockActions.login]: false, - [mockActions.version]: true, + test(`failure when checking for login and user doesn't have login`, async () => { + const result = await checkPrivilegesGloballyTest({ + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: false, + [mockActions.version]: true, + }, }, }, }, - }, - expectedResult: { - hasAllRequested: false, - username: 'foo-username', - privileges: { - [mockActions.login]: false, - }, - }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Array [ + Object { + "authorized": false, + "privilege": "mock-action:login", + "resource": undefined, + }, + ], + "username": "foo-username", + } + `); }); - checkPrivilegesGloballyTest( - `throws error when checking for login and user has login but doesn't have version`, - { + test(`throws error when checking for login and user has login but doesn't have version`, async () => { + const result = await checkPrivilegesGloballyTest({ privilegeOrPrivileges: mockActions.login, esHasPrivilegesResponse: { has_all_requested: false, @@ -880,92 +964,121 @@ describe('#globally', () => { }, }, }, - expectErrorThrown: true, - } - ); - - checkPrivilegesGloballyTest(`throws error when Elasticsearch returns malformed response`, { - privilegeOrPrivileges: [ - `saved_object:${savedObjectTypes[0]}/get`, - `saved_object:${savedObjectTypes[1]}/get`, - ], - esHasPrivilegesResponse: { - has_all_requested: false, - username: 'foo-username', - application: { - [application]: { - [GLOBAL_RESOURCE]: { - [`saved_object:${savedObjectTypes[0]}/get`]: false, - [`saved_object:${savedObjectTypes[1]}/get`]: true, + }); + expect(result).toMatchInlineSnapshot( + `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]` + ); + }); + + test(`throws error when Elasticsearch returns malformed response`, async () => { + const result = await checkPrivilegesGloballyTest({ + privilegeOrPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [`saved_object:${savedObjectTypes[0]}/get`]: false, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, }, }, }, - }, - expectErrorThrown: true, + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because [child "mock-action:version" fails because ["mock-action:version" is required]]]]]` + ); }); - checkPrivilegesGloballyTest(`successful when checking for two actions and the user has both`, { - privilegeOrPrivileges: [ - `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, + test(`successful when checking for two actions and the user has both`, async () => { + const result = await checkPrivilegesGloballyTest({ + privilegeOrPrivileges: [ + `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, + }, }, }, }, - }, - expectedResult: { - hasAllRequested: true, - username: 'foo-username', - privileges: { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, - }, - }, + }); + 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, + }, + ], + "username": "foo-username", + } + `); }); - checkPrivilegesGloballyTest(`failure when checking for two actions and the user has only one`, { - privilegeOrPrivileges: [ - `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`]: true, + test(`failure when checking for two actions and the user has only one`, async () => { + const result = await checkPrivilegesGloballyTest({ + privilegeOrPrivileges: [ + `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`]: true, + }, }, }, }, - }, - expectedResult: { - hasAllRequested: false, - username: 'foo-username', - privileges: { - [`saved_object:${savedObjectTypes[0]}/get`]: false, - [`saved_object:${savedObjectTypes[1]}/get`]: true, - }, - }, + }); + 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, + }, + ], + "username": "foo-username", + } + `); }); describe('with a malformed Elasticsearch response', () => { - checkPrivilegesGloballyTest( - `throws a validation error when an extra privilege is present in the 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`], esHasPrivilegesResponse: { has_all_requested: false, @@ -981,13 +1094,14 @@ describe('#globally', () => { }, }, }, - expectErrorThrown: true, - } - ); + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because ["saved_object:bar-type/get" is not allowed]]]]` + ); + }); - checkPrivilegesGloballyTest( - `throws a validation error when privileges are missing in the response`, - { + test(`throws a validation error when privileges are missing in the response`, async () => { + const result = await checkPrivilegesGloballyTest({ privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { has_all_requested: false, @@ -1001,8 +1115,10 @@ describe('#globally', () => { }, }, }, - expectErrorThrown: true, - } - ); + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because [child "saved_object:foo-type/get" fails because ["saved_object:foo-type/get" is required]]]]]` + ); + }); }); }); diff --git a/x-pack/plugins/security/server/authorization/check_privileges.ts b/x-pack/plugins/security/server/authorization/check_privileges.ts index 3ef7a8f29a0bf..177a49d6defe9 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.ts @@ -16,32 +16,17 @@ interface CheckPrivilegesActions { version: string; } -interface CheckPrivilegesAtResourcesResponse { +export interface CheckPrivilegesResponse { hasAllRequested: boolean; username: string; - resourcePrivileges: { - [resource: string]: { - [privilege: string]: boolean; - }; - }; -} - -export interface CheckPrivilegesAtResourceResponse { - hasAllRequested: boolean; - username: string; - privileges: { - [privilege: string]: boolean; - }; -} - -export interface CheckPrivilegesAtSpacesResponse { - hasAllRequested: boolean; - username: string; - spacePrivileges: { - [spaceId: string]: { - [privilege: string]: boolean; - }; - }; + 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; @@ -50,12 +35,12 @@ export interface CheckPrivileges { atSpace( spaceId: string, privilegeOrPrivileges: string | string[] - ): Promise<CheckPrivilegesAtResourceResponse>; + ): Promise<CheckPrivilegesResponse>; atSpaces( spaceIds: string[], privilegeOrPrivileges: string | string[] - ): Promise<CheckPrivilegesAtSpacesResponse>; - globally(privilegeOrPrivileges: string | string[]): Promise<CheckPrivilegesAtResourceResponse>; + ): Promise<CheckPrivilegesResponse>; + globally(privilegeOrPrivileges: string | string[]): Promise<CheckPrivilegesResponse>; } export function checkPrivilegesWithRequestFactory( @@ -75,7 +60,7 @@ export function checkPrivilegesWithRequestFactory( const checkPrivilegesAtResources = async ( resources: string[], privilegeOrPrivileges: string | string[] - ): Promise<CheckPrivilegesAtResourcesResponse> => { + ): Promise<CheckPrivilegesResponse> => { const privileges = Array.isArray(privilegeOrPrivileges) ? privilegeOrPrivileges : [privilegeOrPrivileges]; @@ -106,55 +91,43 @@ 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); + }) as HasPrivilegesResponseApplication; + const privilegeArray = Object.entries(resourcePrivileges) + .map(([key, val]) => { + // we need to turn the resource responses back into the space ids + const resource = + key !== GLOBAL_RESOURCE ? ResourceSerializer.deserializeSpaceResource(key!) : undefined; + return Object.entries(val).map(([privilege, authorized]) => ({ + resource, + privilege, + authorized, + })); + }) + .flat(); + return { hasAllRequested: hasPrivilegesResponse.has_all_requested, username: hasPrivilegesResponse.username, - // we need to filter out the non requested privileges from the response - resourcePrivileges: transform(applicationPrivilegesResponse, (result, value, key) => { - result[key!] = pick(value, privileges); - }), - }; - }; - - const checkPrivilegesAtResource = async ( - resource: string, - privilegeOrPrivileges: string | string[] - ) => { - const { hasAllRequested, username, resourcePrivileges } = await checkPrivilegesAtResources( - [resource], - privilegeOrPrivileges - ); - return { - hasAllRequested, - username, - privileges: resourcePrivileges[resource], + privileges: privilegeArray, }; }; return { async atSpace(spaceId: string, privilegeOrPrivileges: string | string[]) { const spaceResource = ResourceSerializer.serializeSpaceResource(spaceId); - return await checkPrivilegesAtResource(spaceResource, privilegeOrPrivileges); + return await checkPrivilegesAtResources([spaceResource], privilegeOrPrivileges); }, async atSpaces(spaceIds: string[], privilegeOrPrivileges: string | string[]) { const spaceResources = spaceIds.map(spaceId => ResourceSerializer.serializeSpaceResource(spaceId) ); - const { hasAllRequested, username, resourcePrivileges } = await checkPrivilegesAtResources( - spaceResources, - privilegeOrPrivileges - ); - return { - hasAllRequested, - username, - // we need to turn the resource responses back into the space ids - spacePrivileges: transform(resourcePrivileges, (result, value, key) => { - result[ResourceSerializer.deserializeSpaceResource(key!)] = value; - }), - }; + return await checkPrivilegesAtResources(spaceResources, privilegeOrPrivileges); }, async globally(privilegeOrPrivileges: string | string[]) { - return await checkPrivilegesAtResource(GLOBAL_RESOURCE, privilegeOrPrivileges); + return await checkPrivilegesAtResources([GLOBAL_RESOURCE], 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 0377dd06eb669..6014bad739e77 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts @@ -6,11 +6,11 @@ import { KibanaRequest } from '../../../../../src/core/server'; import { SpacesService } from '../plugin'; -import { CheckPrivilegesAtResourceResponse, CheckPrivilegesWithRequest } from './check_privileges'; +import { CheckPrivilegesResponse, CheckPrivilegesWithRequest } from './check_privileges'; export type CheckPrivilegesDynamically = ( privilegeOrPrivileges: string | string[] -) => Promise<CheckPrivilegesAtResourceResponse>; +) => Promise<CheckPrivilegesResponse>; export type CheckPrivilegesDynamicallyWithRequest = ( request: KibanaRequest 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 4618e8e6641fc..43b3824500579 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,57 +7,113 @@ import { checkSavedObjectsPrivilegesWithRequestFactory } from './check_saved_objects_privileges'; import { httpServerMock } from '../../../../../src/core/server/mocks'; +import { CheckPrivileges, CheckPrivilegesWithRequest } from './check_privileges'; +import { SpacesService } from '../plugin'; -test(`checkPrivileges.atSpace when spaces is enabled`, async () => { - const expectedResult = Symbol(); - const spaceId = 'foo-space'; - const mockCheckPrivileges = { - atSpace: jest.fn().mockReturnValue(expectedResult), - }; - const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const request = httpServerMock.createKibanaRequest(); - const privilegeOrPrivileges = ['foo', 'bar']; - const mockSpacesService = { - getSpaceId: jest.fn(), - namespaceToSpaceId: jest.fn().mockReturnValue(spaceId), - }; +let mockCheckPrivileges: jest.Mocked<CheckPrivileges>; +let mockCheckPrivilegesWithRequest: jest.Mocked<CheckPrivilegesWithRequest>; +let mockSpacesService: jest.Mocked<SpacesService> | undefined; +const request = httpServerMock.createKibanaRequest(); - const checkSavedObjectsPrivileges = checkSavedObjectsPrivilegesWithRequestFactory( +const createFactory = () => + checkSavedObjectsPrivilegesWithRequestFactory( mockCheckPrivilegesWithRequest, () => mockSpacesService )(request); - const namespace = 'foo'; - - const result = await checkSavedObjectsPrivileges(privilegeOrPrivileges, namespace); +beforeEach(() => { + mockCheckPrivileges = { + atSpace: jest.fn(), + atSpaces: jest.fn(), + globally: jest.fn(), + }; + mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - expect(result).toBe(expectedResult); - expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, privilegeOrPrivileges); - expect(mockSpacesService.namespaceToSpaceId).toBeCalledWith(namespace); + mockSpacesService = { + getSpaceId: jest.fn(), + namespaceToSpaceId: jest.fn().mockImplementation((namespace: string) => `${namespace}-id`), + }; }); -test(`checkPrivileges.globally when spaces is disabled`, async () => { - const expectedResult = Symbol(); - const mockCheckPrivileges = { - globally: jest.fn().mockReturnValue(expectedResult), - }; - const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); +describe('#checkSavedObjectsPrivileges', () => { + const actions = ['foo', 'bar']; + const namespace1 = 'baz'; + const namespace2 = 'qux'; - const request = httpServerMock.createKibanaRequest(); + describe('when checking multiple namespaces', () => { + const namespaces = [namespace1, namespace2]; - const privilegeOrPrivileges = ['foo', 'bar']; + test(`throws an error when Spaces is disabled`, async () => { + mockSpacesService = undefined; + const checkSavedObjectsPrivileges = createFactory(); - const checkSavedObjectsPrivileges = checkSavedObjectsPrivilegesWithRequestFactory( - mockCheckPrivilegesWithRequest, - () => undefined - )(request); + await expect( + checkSavedObjectsPrivileges(actions, namespaces) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Can't check saved object privileges for multiple namespaces if Spaces is disabled"` + ); + }); + + test(`throws an error when using an empty namespaces array`, async () => { + const checkSavedObjectsPrivileges = createFactory(); + + await expect( + checkSavedObjectsPrivileges(actions, []) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Can't check saved object privileges for 0 namespaces"` + ); + }); + + test(`uses checkPrivileges.atSpaces when spaces is enabled`, async () => { + const expectedResult = Symbol(); + mockCheckPrivileges.atSpaces.mockReturnValue(expectedResult as any); + const checkSavedObjectsPrivileges = createFactory(); + + const result = await checkSavedObjectsPrivileges(actions, namespaces); + + expect(result).toBe(expectedResult); + expect(mockSpacesService!.namespaceToSpaceId).toHaveBeenCalledTimes(2); + expect(mockSpacesService!.namespaceToSpaceId).toHaveBeenNthCalledWith(1, namespace1); + expect(mockSpacesService!.namespaceToSpaceId).toHaveBeenNthCalledWith(2, namespace2); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledTimes(1); + 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); + }); + }); + + describe('when checking a single namespace', () => { + test(`uses checkPrivileges.atSpace when Spaces is enabled`, async () => { + const expectedResult = Symbol(); + mockCheckPrivileges.atSpace.mockReturnValue(expectedResult as any); + const checkSavedObjectsPrivileges = createFactory(); + + const result = await checkSavedObjectsPrivileges(actions, namespace1); + + expect(result).toBe(expectedResult); + expect(mockSpacesService!.namespaceToSpaceId).toHaveBeenCalledTimes(1); + expect(mockSpacesService!.namespaceToSpaceId).toHaveBeenCalledWith(namespace1); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledTimes(1); + const spaceId = mockSpacesService!.namespaceToSpaceId.mock.results[0].value; + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, actions); + }); - const namespace = 'foo'; + test(`uses checkPrivileges.globally when Spaces is disabled`, async () => { + const expectedResult = Symbol(); + mockCheckPrivileges.globally.mockReturnValue(expectedResult as any); + mockSpacesService = undefined; + const checkSavedObjectsPrivileges = createFactory(); - const result = await checkSavedObjectsPrivileges(privilegeOrPrivileges, namespace); + const result = await checkSavedObjectsPrivileges(actions, namespace1); - expect(result).toBe(expectedResult); - expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivileges.globally).toHaveBeenCalledWith(privilegeOrPrivileges); + expect(result).toBe(expectedResult); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivileges.globally).toHaveBeenCalledTimes(1); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith(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 02958fe265efa..43140143a1773 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,32 +6,44 @@ import { KibanaRequest } from '../../../../../src/core/server'; import { SpacesService } from '../plugin'; -import { CheckPrivilegesAtResourceResponse, CheckPrivilegesWithRequest } from './check_privileges'; +import { CheckPrivilegesWithRequest, CheckPrivilegesResponse } from './check_privileges'; export type CheckSavedObjectsPrivilegesWithRequest = ( request: KibanaRequest ) => CheckSavedObjectsPrivileges; + export type CheckSavedObjectsPrivileges = ( actions: string | string[], - namespace?: string -) => Promise<CheckPrivilegesAtResourceResponse>; + namespaceOrNamespaces?: string | string[] +) => Promise<CheckPrivilegesResponse>; export const checkSavedObjectsPrivilegesWithRequestFactory = ( checkPrivilegesWithRequest: CheckPrivilegesWithRequest, getSpacesService: () => SpacesService | undefined ): CheckSavedObjectsPrivilegesWithRequest => { - return function checkSavedObjectsPrivilegesWithRequest(request: KibanaRequest) { + return function checkSavedObjectsPrivilegesWithRequest( + request: KibanaRequest + ): CheckSavedObjectsPrivileges { return async function checkSavedObjectsPrivileges( actions: string | string[], - namespace?: string + namespaceOrNamespaces?: string | string[] ) { const spacesService = getSpacesService(); - return spacesService - ? await checkPrivilegesWithRequest(request).atSpace( - spacesService.namespaceToSpaceId(namespace), - actions - ) - : await checkPrivilegesWithRequest(request).globally(actions); + if (Array.isArray(namespaceOrNamespaces)) { + if (spacesService === undefined) { + throw new Error( + `Can't check saved object privileges for multiple namespaces if Spaces is disabled` + ); + } else if (!namespaceOrNamespaces.length) { + throw new Error(`Can't check saved object privileges for 0 namespaces`); + } + const spaceIds = namespaceOrNamespaces.map(x => spacesService.namespaceToSpaceId(x)); + return await checkPrivilegesWithRequest(request).atSpaces(spaceIds, actions); + } else if (spacesService) { + const spaceId = spacesService.namespaceToSpaceId(namespaceOrNamespaces); + return await checkPrivilegesWithRequest(request).atSpace(spaceId, actions); + } + return await checkPrivilegesWithRequest(request).globally(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 912ae60e12065..ea97a1b3b590c 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 @@ -11,7 +11,9 @@ import { httpServerMock, loggingServiceMock } from '../../../../../src/core/serv import { authorizationMock } from './index.mock'; import { Feature } from '../../../features/server'; -type MockAuthzOptions = { rejectCheckPrivileges: any } | { resolveCheckPrivileges: any }; +type MockAuthzOptions = + | { rejectCheckPrivileges: any } + | { resolveCheckPrivileges: { privileges: Array<{ privilege: string; authorized: boolean }> } }; const actions = new Actions('1.0.0-zeta1'); const mockRequest = httpServerMock.createKibanaRequest(); @@ -26,7 +28,8 @@ const createMockAuthz = (options: MockAuthzOptions) => { throw options.rejectCheckPrivileges; } - expect(checkActions).toEqual(Object.keys(options.resolveCheckPrivileges.privileges)); + const expected = options.resolveCheckPrivileges.privileges.map(x => x.privilege); + expect(checkActions).toEqual(expected); return options.resolveCheckPrivileges; }); }); @@ -226,17 +229,17 @@ describe('usingPrivileges', () => { test(`disables ui capabilities when they don't have privileges`, async () => { const mockAuthz = createMockAuthz({ resolveCheckPrivileges: { - privileges: { - [actions.ui.get('navLinks', 'foo')]: true, - [actions.ui.get('navLinks', 'bar')]: false, - [actions.ui.get('navLinks', 'quz')]: false, - [actions.ui.get('management', 'kibana', 'indices')]: true, - [actions.ui.get('management', 'kibana', 'settings')]: false, - [actions.ui.get('fooFeature', 'foo')]: true, - [actions.ui.get('fooFeature', 'bar')]: false, - [actions.ui.get('barFeature', 'foo')]: true, - [actions.ui.get('barFeature', 'bar')]: false, - }, + 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 }, + ], }, }); @@ -314,15 +317,15 @@ describe('usingPrivileges', () => { test(`doesn't re-enable disabled uiCapabilities`, async () => { const mockAuthz = createMockAuthz({ resolveCheckPrivileges: { - privileges: { - [actions.ui.get('navLinks', 'foo')]: true, - [actions.ui.get('navLinks', 'bar')]: true, - [actions.ui.get('management', 'kibana', 'indices')]: true, - [actions.ui.get('fooFeature', 'foo')]: true, - [actions.ui.get('fooFeature', 'bar')]: true, - [actions.ui.get('barFeature', 'foo')]: true, - [actions.ui.get('barFeature', 'bar')]: true, - }, + 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 }, + ], }, }); 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 be26f52fbf756..f0f1a42ad0bd5 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts @@ -9,7 +9,7 @@ import { UICapabilities } from 'ui/capabilities'; import { KibanaRequest, Logger } from '../../../../../src/core/server'; import { Feature } from '../../../features/server'; -import { CheckPrivilegesAtResourceResponse } from './check_privileges'; +import { CheckPrivilegesResponse } from './check_privileges'; import { Authorization } from './index'; export function disableUICapabilitiesFactory( @@ -77,7 +77,7 @@ export function disableUICapabilitiesFactory( [] ); - let checkPrivilegesResponse: CheckPrivilegesAtResourceResponse; + let checkPrivilegesResponse: CheckPrivilegesResponse; try { const checkPrivilegesDynamically = authz.checkPrivilegesDynamicallyWithRequest(request); checkPrivilegesResponse = await checkPrivilegesDynamically(uiActions); @@ -105,7 +105,9 @@ export function disableUICapabilitiesFactory( } const action = authz.actions.ui.get(featureId, ...uiCapabilityParts); - return checkPrivilegesResponse.privileges[action] === true; + return checkPrivilegesResponse.privileges.some( + x => x.privilege === action && x.authorized === true + ); }; return mapValues(uiCapabilities, (featureUICapabilities, featureId) => { diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 032d231fe798f..9dd4aaafa3494 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -149,6 +149,7 @@ export class Plugin { auditLogger: new SecurityAuditLogger(() => this.getLegacyAPI().auditLogger), authz, savedObjects: core.savedObjects, + getSpacesService: this.getSpacesService, }); core.capabilities.registerSwitcher(authz.disableUnauthorizedCapabilities); @@ -156,12 +157,12 @@ export class Plugin { defineRoutes({ router: core.http.createRouter(), basePath: core.http.basePath, + httpResources: core.http.resources, logger: this.initializerContext.logger.get('routes'), clusterClient: this.clusterClient, config, authc, authz, - csp: core.http.csp, license, }); diff --git a/x-pack/plugins/security/server/routes/authentication/index.ts b/x-pack/plugins/security/server/routes/authentication/index.ts index 7e9eb75bbf753..d09f65525f44e 100644 --- a/x-pack/plugins/security/server/routes/authentication/index.ts +++ b/x-pack/plugins/security/server/routes/authentication/index.ts @@ -11,17 +11,6 @@ import { defineCommonRoutes } from './common'; import { defineOIDCRoutes } from './oidc'; import { RouteDefinitionParams } from '..'; -export function createCustomResourceResponse(body: string, contentType: string, cspHeader: string) { - return { - body, - headers: { - 'content-type': contentType, - 'content-security-policy': cspHeader, - }, - statusCode: 200, - }; -} - export function defineAuthenticationRoutes(params: RouteDefinitionParams) { defineSessionRoutes(params); defineCommonRoutes(params); diff --git a/x-pack/plugins/security/server/routes/authentication/oidc.ts b/x-pack/plugins/security/server/routes/authentication/oidc.ts index d325a453af9d1..5d8a7ae7bdfea 100644 --- a/x-pack/plugins/security/server/routes/authentication/oidc.ts +++ b/x-pack/plugins/security/server/routes/authentication/oidc.ts @@ -8,7 +8,6 @@ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; import { KibanaRequest, KibanaResponseFactory } from '../../../../../../src/core/server'; import { OIDCLogin } from '../../authentication'; -import { createCustomResourceResponse } from '.'; import { createLicensedRouteHandler } from '../licensed_route_handler'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { @@ -20,7 +19,13 @@ import { RouteDefinitionParams } from '..'; /** * Defines routes required for SAML authentication. */ -export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: RouteDefinitionParams) { +export function defineOIDCRoutes({ + router, + httpResources, + logger, + authc, + basePath, +}: RouteDefinitionParams) { // Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used. for (const path of ['/api/security/oidc/implicit', '/api/security/v1/oidc/implicit']) { /** @@ -28,7 +33,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route * is used, so that we can extract authentication response from URL fragment and send it to * the `/api/security/oidc/callback` route. */ - router.get( + httpResources.register( { path, validate: false, @@ -42,18 +47,14 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route { tags: ['deprecation'] } ); } - return response.custom( - createCustomResourceResponse( - ` - <!DOCTYPE html> - <title>Kibana OpenID Connect Login - - - `, - 'text/html', - csp.header - ) - ); + return response.renderHtml({ + body: ` + + Kibana OpenID Connect Login + + + `, + }); } ); } @@ -63,7 +64,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route * that extracts fragment part from the URL and send it to the `/api/security/oidc/callback` route. * We need this separate endpoint because of default CSP policy that forbids inline scripts. */ - router.get( + httpResources.register( { path: '/internal/security/oidc/implicit.js', validate: false, @@ -71,17 +72,13 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route }, (context, request, response) => { const serverBasePath = basePath.serverBasePath; - return response.custom( - createCustomResourceResponse( - ` + return response.renderJs({ + body: ` window.location.replace( '${serverBasePath}/api/security/oidc/callback?authenticationResponseURI=' + encodeURIComponent(window.location.href) ); `, - 'text/javascript', - csp.header - ) - ); + }); } ); @@ -155,7 +152,9 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route } if (!loginAttempt) { - return response.badRequest({ body: 'Unrecognized login attempt.' }); + return response.badRequest({ + body: 'Unrecognized login attempt.', + }); } return performOIDCLogin(request, response, loginAttempt); diff --git a/x-pack/plugins/security/server/routes/authentication/saml.ts b/x-pack/plugins/security/server/routes/authentication/saml.ts index 8f08f250a1c75..30e1f6f336bdd 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.ts @@ -7,14 +7,19 @@ import { schema } from '@kbn/config-schema'; import { SAMLLogin } from '../../authentication'; import { SAMLAuthenticationProvider } from '../../authentication/providers'; -import { createCustomResourceResponse } from '.'; import { RouteDefinitionParams } from '..'; /** * Defines routes required for SAML authentication. */ -export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: RouteDefinitionParams) { - router.get( +export function defineSAMLRoutes({ + router, + httpResources, + logger, + authc, + basePath, +}: RouteDefinitionParams) { + httpResources.register( { path: '/internal/security/saml/capture-url-fragment', validate: false, @@ -22,39 +27,30 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route }, (context, request, response) => { // We're also preventing `favicon.ico` request since it can cause new SAML handshake. - return response.custom( - createCustomResourceResponse( - ` + return response.renderHtml({ + body: ` Kibana SAML Login `, - 'text/html', - csp.header - ) - ); + }); } ); - - router.get( + httpResources.register( { path: '/internal/security/saml/capture-url-fragment.js', validate: false, options: { authRequired: false }, }, (context, request, response) => { - return response.custom( - createCustomResourceResponse( - ` + return response.renderJs({ + body: ` window.location.replace( '${basePath.serverBasePath}/internal/security/saml/start?redirectURLFragment=' + encodeURIComponent(window.location.hash) ); `, - 'text/javascript', - csp.header - ) - ); + }); } ); diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index aaefdad6c221a..b0c74b98ee19b 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -8,6 +8,7 @@ import { elasticsearchServiceMock, httpServiceMock, loggingServiceMock, + httpResourcesMock, } from '../../../../../src/core/server/mocks'; import { authenticationMock } from '../authentication/index.mock'; import { authorizationMock } from '../authorization/index.mock'; @@ -27,5 +28,6 @@ export const routeDefinitionParamsMock = { authc: authenticationMock.create(), authz: authorizationMock.create(), license: licenseMock.create(), + httpResources: httpResourcesMock.createRegistrar(), }), }; diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index a372fcf092707..e43072b95c906 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -4,7 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, IClusterClient, IRouter, Logger } from '../../../../../src/core/server'; +import { + CoreSetup, + HttpResources, + IClusterClient, + IRouter, + Logger, +} from '../../../../../src/core/server'; import { SecurityLicense } from '../../common/licensing'; import { Authentication } from '../authentication'; import { Authorization } from '../authorization'; @@ -24,7 +30,7 @@ import { defineViewRoutes } from './views'; export interface RouteDefinitionParams { router: IRouter; basePath: CoreSetup['http']['basePath']; - csp: CoreSetup['http']['csp']; + httpResources: HttpResources; logger: Logger; clusterClient: IClusterClient; config: ConfigType; diff --git a/x-pack/plugins/security/server/routes/views/account_management.ts b/x-pack/plugins/security/server/routes/views/account_management.ts index 3c84483d8f494..696a5e12b64c1 100644 --- a/x-pack/plugins/security/server/routes/views/account_management.ts +++ b/x-pack/plugins/security/server/routes/views/account_management.ts @@ -9,11 +9,8 @@ import { RouteDefinitionParams } from '..'; /** * Defines routes required for the Account Management view. */ -export function defineAccountManagementRoutes({ router, csp }: RouteDefinitionParams) { - router.get({ path: '/security/account', validate: false }, async (context, request, response) => { - return response.ok({ - body: await context.core.rendering.render({ includeUserSettings: true }), - headers: { 'content-security-policy': csp.header }, - }); - }); +export function defineAccountManagementRoutes({ httpResources }: RouteDefinitionParams) { + httpResources.register({ path: '/security/account', validate: false }, (context, req, res) => + res.renderCoreApp() + ); } diff --git a/x-pack/plugins/security/server/routes/views/index.test.ts b/x-pack/plugins/security/server/routes/views/index.test.ts index 80f7f62a5ff43..a8e7e905b119a 100644 --- a/x-pack/plugins/security/server/routes/views/index.test.ts +++ b/x-pack/plugins/security/server/routes/views/index.test.ts @@ -17,7 +17,8 @@ describe('View routes', () => { defineViewRoutes(routeParamsMock); - expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` + expect(routeParamsMock.httpResources.register.mock.calls.map(([{ path }]) => path)) + .toMatchInlineSnapshot(` Array [ "/security/account", "/security/logged_out", @@ -25,6 +26,9 @@ describe('View routes', () => { "/security/overwritten_session", ] `); + expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot( + `Array []` + ); }); it('registers Login routes if `basic` provider is enabled', () => { @@ -35,16 +39,21 @@ describe('View routes', () => { defineViewRoutes(routeParamsMock); - expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` + expect(routeParamsMock.httpResources.register.mock.calls.map(([{ path }]) => path)) + .toMatchInlineSnapshot(` Array [ "/login", - "/internal/security/login_state", "/security/account", "/security/logged_out", "/logout", "/security/overwritten_session", ] `); + expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` + Array [ + "/internal/security/login_state", + ] + `); }); it('registers Login routes if `token` provider is enabled', () => { @@ -55,16 +64,21 @@ describe('View routes', () => { defineViewRoutes(routeParamsMock); - expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` + expect(routeParamsMock.httpResources.register.mock.calls.map(([{ path }]) => path)) + .toMatchInlineSnapshot(` Array [ "/login", - "/internal/security/login_state", "/security/account", "/security/logged_out", "/logout", "/security/overwritten_session", ] `); + expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` + Array [ + "/internal/security/login_state", + ] + `); }); it('registers Login routes if Login Selector is enabled even if both `token` and `basic` providers are not enabled', () => { @@ -75,15 +89,20 @@ describe('View routes', () => { defineViewRoutes(routeParamsMock); - expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` + expect(routeParamsMock.httpResources.register.mock.calls.map(([{ path }]) => path)) + .toMatchInlineSnapshot(` Array [ "/login", - "/internal/security/login_state", "/security/account", "/security/logged_out", "/logout", "/security/overwritten_session", ] `); + expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` + Array [ + "/internal/security/login_state", + ] + `); }); }); diff --git a/x-pack/plugins/security/server/routes/views/logged_out.test.ts b/x-pack/plugins/security/server/routes/views/logged_out.test.ts index 822802b62d874..3ff05d242d9dd 100644 --- a/x-pack/plugins/security/server/routes/views/logged_out.test.ts +++ b/x-pack/plugins/security/server/routes/views/logged_out.test.ts @@ -4,20 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - RequestHandler, - RouteConfig, - kibanaResponseFactory, -} from '../../../../../../src/core/server'; +import { HttpResourcesRequestHandler, RouteConfig } from '../../../../../../src/core/server'; import { Authentication } from '../../authentication'; import { defineLoggedOutRoutes } from './logged_out'; -import { coreMock, httpServerMock } from '../../../../../../src/core/server/mocks'; +import { httpServerMock, httpResourcesMock } from '../../../../../../src/core/server/mocks'; import { routeDefinitionParamsMock } from '../index.mock'; describe('LoggedOut view routes', () => { let authc: jest.Mocked; - let routeHandler: RequestHandler; + let routeHandler: HttpResourcesRequestHandler; let routeConfig: RouteConfig; beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); @@ -28,7 +24,7 @@ describe('LoggedOut view routes', () => { const [ loggedOutRouteConfig, loggedOutRouteHandler, - ] = routeParamsMock.router.get.mock.calls.find( + ] = routeParamsMock.httpResources.register.mock.calls.find( ([{ path }]) => path === '/security/logged_out' )!; @@ -51,9 +47,11 @@ describe('LoggedOut view routes', () => { const request = httpServerMock.createKibanaRequest(); - await expect(routeHandler({} as any, request, kibanaResponseFactory)).resolves.toEqual({ - options: { headers: { location: '/mock-server-basepath/' } }, - status: 302, + const responseFactory = httpResourcesMock.createResponseFactory(); + await routeHandler({} as any, request, responseFactory); + + expect(responseFactory.redirected).toHaveBeenCalledWith({ + headers: { location: '/mock-server-basepath/' }, }); expect(authc.getSessionInfo).toHaveBeenCalledWith(request); @@ -63,21 +61,10 @@ describe('LoggedOut view routes', () => { authc.getSessionInfo.mockResolvedValue(null); const request = httpServerMock.createKibanaRequest(); - const contextMock = coreMock.createRequestHandlerContext(); - - await expect( - routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) - ).resolves.toEqual({ - options: { - headers: { - 'content-security-policy': - "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'", - }, - }, - status: 200, - }); + const responseFactory = httpResourcesMock.createResponseFactory(); + await routeHandler({} as any, request, responseFactory); expect(authc.getSessionInfo).toHaveBeenCalledWith(request); - expect(contextMock.rendering.render).toHaveBeenCalledWith({ includeUserSettings: false }); + expect(responseFactory.renderAnonymousCoreApp).toHaveBeenCalledWith(); }); }); diff --git a/x-pack/plugins/security/server/routes/views/logged_out.ts b/x-pack/plugins/security/server/routes/views/logged_out.ts index 2f69d8c35f03e..43c2f01b1b53d 100644 --- a/x-pack/plugins/security/server/routes/views/logged_out.ts +++ b/x-pack/plugins/security/server/routes/views/logged_out.ts @@ -16,13 +16,12 @@ import { RouteDefinitionParams } from '..'; * Defines routes required for the Logged Out view. */ export function defineLoggedOutRoutes({ - router, logger, authc, - csp, + httpResources, basePath, }: RouteDefinitionParams) { - router.get( + httpResources.register( { path: '/security/logged_out', validate: false, @@ -39,10 +38,7 @@ export function defineLoggedOutRoutes({ }); } - return response.ok({ - body: await context.core.rendering.render({ includeUserSettings: false }), - headers: { 'content-security-policy': csp.header }, - }); + return response.renderAnonymousCoreApp(); } ); } diff --git a/x-pack/plugins/security/server/routes/views/login.test.ts b/x-pack/plugins/security/server/routes/views/login.test.ts index 7751f9a952c09..d43319efbdfb9 100644 --- a/x-pack/plugins/security/server/routes/views/login.test.ts +++ b/x-pack/plugins/security/server/routes/views/login.test.ts @@ -7,26 +7,34 @@ import { URL } from 'url'; import { Type } from '@kbn/config-schema'; import { + HttpResources, + HttpResourcesRequestHandler, + IRouter, RequestHandler, - RouteConfig, kibanaResponseFactory, - IRouter, + RouteConfig, } from '../../../../../../src/core/server'; import { SecurityLicense } from '../../../common/licensing'; import { LoginState } from '../../../common/login_state'; import { ConfigType } from '../../config'; import { defineLoginRoutes } from './login'; -import { coreMock, httpServerMock } from '../../../../../../src/core/server/mocks'; +import { + coreMock, + httpServerMock, + httpResourcesMock, +} from '../../../../../../src/core/server/mocks'; import { routeDefinitionParamsMock } from '../index.mock'; describe('Login view routes', () => { + let httpResources: jest.Mocked; let router: jest.Mocked; let license: jest.Mocked; let config: ConfigType; beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); router = routeParamsMock.router; + httpResources = routeParamsMock.httpResources; license = routeParamsMock.license; config = routeParamsMock.config; @@ -34,10 +42,10 @@ describe('Login view routes', () => { }); describe('View route', () => { - let routeHandler: RequestHandler; + let routeHandler: HttpResourcesRequestHandler; let routeConfig: RouteConfig; beforeEach(() => { - const [loginRouteConfig, loginRouteHandler] = router.get.mock.calls.find( + const [loginRouteConfig, loginRouteHandler] = httpResources.register.mock.calls.find( ([{ path }]) => path === '/login' )!; @@ -96,9 +104,11 @@ describe('Login view routes', () => { 'https://kibana.co' ); license.getFeatures.mockReturnValue({ showLogin: true } as any); - await expect(routeHandler({} as any, request, kibanaResponseFactory)).resolves.toEqual({ - options: { headers: { location: `${expectedLocation}` } }, - status: 302, + const responseFactory = httpResourcesMock.createResponseFactory(); + + await routeHandler({} as any, request, responseFactory); + expect(responseFactory.redirected).toHaveBeenCalledWith({ + headers: { location: `${expectedLocation}` }, }); // Redirect if `showLogin` is `false` even if user is not authenticated. @@ -108,9 +118,12 @@ describe('Login view routes', () => { 'https://kibana.co' ); license.getFeatures.mockReturnValue({ showLogin: false } as any); - await expect(routeHandler({} as any, request, kibanaResponseFactory)).resolves.toEqual({ - options: { headers: { location: `${expectedLocation}` } }, - status: 302, + responseFactory.redirected.mockClear(); + + await routeHandler({} as any, request, responseFactory); + + expect(responseFactory.redirected).toHaveBeenCalledWith({ + headers: { location: `${expectedLocation}` }, }); } }); @@ -121,19 +134,9 @@ describe('Login view routes', () => { const request = httpServerMock.createKibanaRequest({ auth: { isAuthenticated: false } }); const contextMock = coreMock.createRequestHandlerContext(); - await expect( - routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) - ).resolves.toEqual({ - options: { - headers: { - 'content-security-policy': - "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'", - }, - }, - status: 200, - }); - - expect(contextMock.rendering.render).toHaveBeenCalledWith({ includeUserSettings: false }); + const responseFactory = httpResourcesMock.createResponseFactory(); + await routeHandler({ core: contextMock } as any, request, responseFactory); + expect(responseFactory.renderAnonymousCoreApp).toHaveBeenCalledWith(); }); }); diff --git a/x-pack/plugins/security/server/routes/views/login.ts b/x-pack/plugins/security/server/routes/views/login.ts index 4cabd4337971c..4d6747de713f7 100644 --- a/x-pack/plugins/security/server/routes/views/login.ts +++ b/x-pack/plugins/security/server/routes/views/login.ts @@ -16,11 +16,11 @@ export function defineLoginRoutes({ config, router, logger, - csp, + httpResources, basePath, license, }: RouteDefinitionParams) { - router.get( + httpResources.register( { path: '/login', validate: { @@ -45,10 +45,7 @@ export function defineLoginRoutes({ }); } - return response.ok({ - body: await context.core.rendering.render({ includeUserSettings: false }), - headers: { 'content-security-policy': csp.header }, - }); + return response.renderAnonymousCoreApp(); } ); diff --git a/x-pack/plugins/security/server/routes/views/logout.ts b/x-pack/plugins/security/server/routes/views/logout.ts index 8fa8e689a1c38..370cb069096a3 100644 --- a/x-pack/plugins/security/server/routes/views/logout.ts +++ b/x-pack/plugins/security/server/routes/views/logout.ts @@ -9,18 +9,9 @@ import { RouteDefinitionParams } from '..'; /** * Defines routes required for the Logout out view. */ -export function defineLogoutRoutes({ router, csp }: RouteDefinitionParams) { - router.get( - { - path: '/logout', - validate: false, - options: { authRequired: false }, - }, - async (context, request, response) => { - return response.ok({ - body: await context.core.rendering.render({ includeUserSettings: false }), - headers: { 'content-security-policy': csp.header }, - }); - } +export function defineLogoutRoutes({ httpResources }: RouteDefinitionParams) { + httpResources.register( + { path: '/logout', validate: false, options: { authRequired: false } }, + (context, request, response) => response.renderAnonymousCoreApp() ); } diff --git a/x-pack/plugins/security/server/routes/views/overwritten_session.ts b/x-pack/plugins/security/server/routes/views/overwritten_session.ts index c21ab1c207362..ee4988cb122cc 100644 --- a/x-pack/plugins/security/server/routes/views/overwritten_session.ts +++ b/x-pack/plugins/security/server/routes/views/overwritten_session.ts @@ -9,14 +9,9 @@ import { RouteDefinitionParams } from '..'; /** * Defines routes required for the Overwritten Session view. */ -export function defineOverwrittenSessionRoutes({ router, csp }: RouteDefinitionParams) { - router.get( +export function defineOverwrittenSessionRoutes({ httpResources }: RouteDefinitionParams) { + httpResources.register( { path: '/security/overwritten_session', validate: false }, - async (context, request, response) => { - return response.ok({ - body: await context.core.rendering.render({ includeUserSettings: true }), - headers: { 'content-security-policy': csp.header }, - }); - } + (context, req, res) => res.renderCoreApp() ); } diff --git a/x-pack/plugins/security/server/saved_objects/index.ts b/x-pack/plugins/security/server/saved_objects/index.ts index 5954729562847..7dac745fcf84b 100644 --- a/x-pack/plugins/security/server/saved_objects/index.ts +++ b/x-pack/plugins/security/server/saved_objects/index.ts @@ -13,14 +13,21 @@ import { import { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper'; import { Authorization } from '../authorization'; import { SecurityAuditLogger } from '../audit'; +import { SpacesService } from '../plugin'; interface SetupSavedObjectsParams { auditLogger: SecurityAuditLogger; authz: Pick; savedObjects: CoreSetup['savedObjects']; + getSpacesService(): SpacesService | undefined; } -export function setupSavedObjects({ auditLogger, authz, savedObjects }: SetupSavedObjectsParams) { +export function setupSavedObjects({ + auditLogger, + authz, + savedObjects, + getSpacesService, +}: SetupSavedObjectsParams) { const getKibanaRequest = (request: KibanaRequest | LegacyRequest) => request instanceof KibanaRequest ? request : KibanaRequest.from(request); @@ -44,6 +51,7 @@ export function setupSavedObjects({ auditLogger, authz, savedObjects }: SetupSav kibanaRequest ), errors: SavedObjectsClient.errors, + getSpacesService, }) : client; }); 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 3c04508e3a74a..3c4034e07f995 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 @@ -9,6 +9,11 @@ import { Actions } from '../authorization'; import { securityAuditLoggerMock } from '../audit/index.mock'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { SavedObjectsClientContract } from 'kibana/server'; +import { SavedObjectActions } from '../authorization/actions/saved_object'; + +let clientOpts: ReturnType; +let client: SecureSavedObjectsClientWrapper; +const USERNAME = Symbol(); const createSecureSavedObjectsClientWrapperOptions = () => { const actions = new Actions('some-version'); @@ -22,810 +27,677 @@ const createSecureSavedObjectsClientWrapperOptions = () => { const errors = ({ decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError), decorateGeneralError: jest.fn().mockReturnValue(generalError), + isNotFoundError: jest.fn().mockReturnValue(false), } as unknown) as jest.Mocked; + const getSpacesService = jest.fn().mockReturnValue(true); return { actions, baseClient: savedObjectsClientMock.create(), checkSavedObjectsPrivilegesAsCurrentUser: jest.fn(), errors, + getSpacesService, auditLogger: securityAuditLoggerMock.create(), forbiddenError, generalError, }; }; -describe('#errors', () => { - test(`assigns errors from constructor to .errors`, () => { - const options = createSecureSavedObjectsClientWrapperOptions(); - const client = new SecureSavedObjectsClientWrapper(options); +const expectGeneralError = async (fn: Function, args: Record) => { + // mock the checkPrivileges.globally rejection + const rejection = new Error('An actual error would happen here'); + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(rejection); + + await expect(fn.bind(client)(...Object.values(args))).rejects.toThrowError( + clientOpts.generalError + ); + expect(clientOpts.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); +}; + +/** + * Fails the first authorization check, passes any others + * Requires that function args are passed in as key/value pairs + * The argument properties must be in the correct order to be spread properly + */ +const expectForbiddenError = async (fn: Function, args: Record) => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure + ); + + await expect(fn.bind(client)(...Object.values(args))).rejects.toThrowError( + clientOpts.forbiddenError + ); + const getCalls = (clientOpts.actions.savedObject.get as jest.MockedFunction< + SavedObjectActions['get'] + >).mock.calls; + const actions = clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mock.calls[0][0]; + const spaceId = args.options?.namespace || 'default'; + + const ACTION = getCalls[0][1]; + const types = getCalls.map(x => x[0]); + const missing = [{ spaceId, privilege: actions[0] }]; // if there was more than one type, only the first type was unauthorized + const spaceIds = [spaceId]; + + expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + USERNAME, + ACTION, + types, + spaceIds, + missing, + args + ); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); +}; + +const expectSuccess = async (fn: Function, args: Record) => { + const result = await fn.bind(client)(...Object.values(args)); + const getCalls = (clientOpts.actions.savedObject.get as jest.MockedFunction< + SavedObjectActions['get'] + >).mock.calls; + const ACTION = getCalls[0][1]; + const types = getCalls.map(x => x[0]); + const spaceIds = [args.options?.namespace || 'default']; + + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + USERNAME, + ACTION, + types, + spaceIds, + args + ); + return result; +}; + +const expectPrivilegeCheck = async (fn: Function, args: Record) => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure + ); + + await expect(fn.bind(client)(...Object.values(args))).rejects.toThrow(); // test is simpler with error case + const getResults = (clientOpts.actions.savedObject.get as jest.MockedFunction< + SavedObjectActions['get'] + >).mock.results; + const actions = getResults.map(x => x.value); + + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + actions, + args.options?.namespace + ); +}; + +const expectObjectNamespaceFiltering = async (fn: Function, args: Record) => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( + getMockCheckPrivilegesSuccess // privilege check for authorization + ); + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure // privilege check for namespace filtering + ); + + const authorizedNamespace = args.options.namespace || 'default'; + const namespaces = ['some-other-namespace', authorizedNamespace]; + const returnValue = { namespaces, foo: 'bar' }; + // we don't know which base client method will be called; mock them all + clientOpts.baseClient.create.mockReturnValue(returnValue as any); + clientOpts.baseClient.get.mockReturnValue(returnValue as any); + clientOpts.baseClient.update.mockReturnValue(returnValue as any); + + const result = await fn.bind(client)(...Object.values(args)); + expect(result).toEqual(expect.objectContaining({ namespaces: [authorizedNamespace, '?'] })); + + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith( + 'login:', + namespaces + ); +}; + +const expectObjectsNamespaceFiltering = async (fn: Function, args: Record) => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( + getMockCheckPrivilegesSuccess // privilege check for authorization + ); + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure // privilege check for namespace filtering + ); + + const authorizedNamespace = args.options.namespace || 'default'; + const returnValue = { + saved_objects: [ + { namespaces: ['foo'] }, + { namespaces: [authorizedNamespace] }, + { namespaces: ['foo', authorizedNamespace] }, + ], + }; + + // we don't know which base client method will be called; mock them all + clientOpts.baseClient.bulkCreate.mockReturnValue(returnValue as any); + clientOpts.baseClient.bulkGet.mockReturnValue(returnValue as any); + clientOpts.baseClient.bulkUpdate.mockReturnValue(returnValue as any); + clientOpts.baseClient.find.mockReturnValue(returnValue as any); + + const result = await fn.bind(client)(...Object.values(args)); + expect(result).toEqual( + expect.objectContaining({ + saved_objects: [ + { namespaces: ['?'] }, + { namespaces: [authorizedNamespace] }, + { namespaces: [authorizedNamespace, '?'] }, + ], + }) + ); + + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith('login:', [ + 'foo', + authorizedNamespace, + ]); +}; + +function getMockCheckPrivilegesSuccess(actions: string | string[], namespaces?: string | string[]) { + const _namespaces = Array.isArray(namespaces) ? namespaces : [namespaces || 'default']; + const _actions = Array.isArray(actions) ? actions : [actions]; + return { + hasAllRequested: true, + username: USERNAME, + privileges: _namespaces + .map(resource => + _actions.map(action => ({ + resource, + privilege: action, + authorized: true, + })) + ) + .flat(), + }; +} + +/** + * Fails the authorization check for the first privilege, and passes any others + * This check may be for an action for two different types in the same namespace + * Or, it may be for an action for the same type in two different namespaces + * Either way, the first privilege check returned is false, and any others return true + */ +function getMockCheckPrivilegesFailure(actions: string | string[], namespaces?: string | string[]) { + const _namespaces = Array.isArray(namespaces) ? namespaces : [namespaces || 'default']; + const _actions = Array.isArray(actions) ? actions : [actions]; + return { + hasAllRequested: false, + username: USERNAME, + privileges: _namespaces + .map((resource, idxa) => + _actions.map((action, idxb) => ({ + resource, + privilege: action, + authorized: idxa > 0 || idxb > 0, + })) + ) + .flat(), + }; +} + +/** + * Before each test, create the Client with its Options + */ +beforeEach(() => { + clientOpts = createSecureSavedObjectsClientWrapperOptions(); + client = new SecureSavedObjectsClientWrapper(clientOpts); + + // succeed privilege checks by default + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesSuccess + ); +}); + +describe('#addToNamespaces', () => { + const type = 'foo'; + const id = `${type}-id`; + const newNs1 = 'foo-namespace'; + const newNs2 = 'bar-namespace'; + const namespaces = [newNs1, newNs2]; + const currentNs = 'default'; + const privilege1 = `mock-saved_object:${type}/create`; + const privilege2 = `mock-saved_object:${type}/update`; + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.addToNamespaces, { type, id, namespaces }); + }); + + test(`throws decorated ForbiddenError when unauthorized to create in new space`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure + ); + + await expect(client.addToNamespaces(type, id, namespaces)).rejects.toThrowError( + clientOpts.forbiddenError + ); + + expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + USERNAME, + 'addToNamespacesCreate', + [type], + namespaces.sort(), + [{ privilege: privilege1, spaceId: newNs1 }], + { id, type, namespaces, options: {} } + ); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized to update in current space`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( + getMockCheckPrivilegesSuccess // create + ); + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure // update + ); + + await expect(client.addToNamespaces(type, id, namespaces)).rejects.toThrowError( + clientOpts.forbiddenError + ); + + expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect( + clientOpts.auditLogger.savedObjectsAuthorizationFailure + ).toHaveBeenLastCalledWith( + USERNAME, + 'addToNamespacesUpdate', + [type], + [currentNs], + [{ privilege: privilege2, spaceId: currentNs }], + { id, type, namespaces, options: {} } + ); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); + }); + + test(`returns result of baseClient.addToNamespaces when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.addToNamespaces.mockReturnValue(apiCallReturnValue as any); + + const result = await client.addToNamespaces(type, id, namespaces); + expect(result).toBe(apiCallReturnValue); + + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(2); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith( + 1, + USERNAME, + 'addToNamespacesCreate', // action for privilege check is 'create', but auditAction is 'addToNamespacesCreate' + [type], + namespaces.sort(), + { type, id, namespaces, options: {} } + ); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith( + 2, + USERNAME, + 'addToNamespacesUpdate', // action for privilege check is 'update', but auditAction is 'addToNamespacesUpdate' + [type], + [currentNs], + { type, id, namespaces, options: {} } + ); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( + getMockCheckPrivilegesSuccess // create + ); + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure // update + ); + + await expect(client.addToNamespaces(type, id, namespaces)).rejects.toThrow(); // test is simpler with error case + + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenNthCalledWith( + 1, + [privilege1], + namespaces + ); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenNthCalledWith( + 2, + [privilege2], + undefined // default namespace + ); + }); +}); + +describe('#bulkCreate', () => { + const attributes = { some: 'attr' }; + const obj1 = Object.freeze({ type: 'foo', otherThing: 'sup', attributes }); + const obj2 = Object.freeze({ type: 'bar', otherThing: 'everyone', attributes }); + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const objects = [obj1]; + await expectGeneralError(client.bulkCreate, { objects }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const objects = [obj1, obj2]; + await expectForbiddenError(client.bulkCreate, { objects, options }); + }); + + test(`returns result of baseClient.bulkCreate when authorized`, async () => { + const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.bulkCreate.mockReturnValue(apiCallReturnValue as any); + + const objects = [obj1, obj2]; + const result = await expectSuccess(client.bulkCreate, { objects, options }); + expect(result).toEqual(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + const objects = [obj1, obj2]; + await expectPrivilegeCheck(client.bulkCreate, { objects, options }); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + const objects = [obj1, obj2]; + await expectObjectsNamespaceFiltering(client.bulkCreate, { objects, options }); + }); +}); + +describe('#bulkGet', () => { + const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' }); + const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' }); + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const objects = [obj1]; + await expectGeneralError(client.bulkGet, { objects }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const objects = [obj1, obj2]; + await expectForbiddenError(client.bulkGet, { objects, options }); + }); + + test(`returns result of baseClient.bulkGet when authorized`, async () => { + const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); + + const objects = [obj1, obj2]; + const result = await expectSuccess(client.bulkGet, { objects, options }); + expect(result).toEqual(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + const objects = [obj1, obj2]; + await expectPrivilegeCheck(client.bulkGet, { objects, options }); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + const objects = [obj1, obj2]; + await expectObjectsNamespaceFiltering(client.bulkGet, { objects, options }); + }); +}); + +describe('#bulkUpdate', () => { + const obj1 = Object.freeze({ type: 'foo', id: 'foo-id', attributes: { some: 'attr' } }); + const obj2 = Object.freeze({ type: 'bar', id: 'bar-id', attributes: { other: 'attr' } }); + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const objects = [obj1]; + await expectGeneralError(client.bulkUpdate, { objects }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const objects = [obj1, obj2]; + await expectForbiddenError(client.bulkUpdate, { objects, options }); + }); + + test(`returns result of baseClient.bulkUpdate when authorized`, async () => { + const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.bulkUpdate.mockReturnValue(apiCallReturnValue as any); + + const objects = [obj1, obj2]; + const result = await expectSuccess(client.bulkUpdate, { objects, options }); + expect(result).toEqual(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + const objects = [obj1, obj2]; + await expectPrivilegeCheck(client.bulkUpdate, { objects, options }); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + const objects = [obj1, obj2]; + await expectObjectsNamespaceFiltering(client.bulkUpdate, { objects, options }); + }); +}); + +describe('#create', () => { + const type = 'foo'; + const attributes = { some_attr: 's' }; + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { + await expectGeneralError(client.create, { type }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + await expectForbiddenError(client.create, { type, attributes, options }); + }); + + test(`returns result of baseClient.create when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.create.mockResolvedValue(apiCallReturnValue as any); + + const result = await expectSuccess(client.create, { type, attributes, options }); + expect(result).toBe(apiCallReturnValue); + }); - expect(client.errors).toBe(options.errors); + test(`checks privileges for user, actions, and namespace`, async () => { + await expectPrivilegeCheck(client.create, { type, attributes, options }); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + await expectObjectNamespaceFiltering(client.create, { type, attributes, options }); }); }); -describe(`spaces disabled`, () => { - describe('#create', () => { - test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { - const type = 'foo'; - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( - new Error('An actual error would happen here') - ); - const client = new SecureSavedObjectsClientWrapper(options); - - await expect(client.create(type)).rejects.toThrowError(options.generalError); - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'create')], - undefined - ); - expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { [options.actions.savedObject.get(type, 'create')]: false }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const attributes = { some_attr: 's' }; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.create(type, attributes, apiCallOptions)).rejects.toThrowError( - options.forbiddenError - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'create')], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'create', - [type], - [options.actions.savedObject.get(type, 'create')], - { type, attributes, options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.create when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: true, - username, - privileges: { [options.actions.savedObject.get(type, 'create')]: true }, - }); - - const apiCallReturnValue = Symbol(); - options.baseClient.create.mockReturnValue(apiCallReturnValue as any); - - const client = new SecureSavedObjectsClientWrapper(options); - - const attributes = { some_attr: 's' }; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.create(type, attributes, apiCallOptions)).resolves.toBe( - apiCallReturnValue - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'create')], - apiCallOptions.namespace - ); - expect(options.baseClient.create).toHaveBeenCalledWith(type, attributes, apiCallOptions); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'create', - [type], - { type, attributes, options: apiCallOptions } - ); - }); - }); - - describe('#bulkCreate', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( - new Error('An actual error would happen here') - ); - const client = new SecureSavedObjectsClientWrapper(options); - - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect( - client.bulkCreate([{ type, attributes: {} }], apiCallOptions) - ).rejects.toThrowError(options.generalError); - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'bulk_create')], - apiCallOptions.namespace - ); - expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { - [options.actions.savedObject.get(type1, 'bulk_create')]: false, - [options.actions.savedObject.get(type2, 'bulk_create')]: true, - }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const objects = [ - { type: type1, attributes: {} }, - { type: type2, attributes: {} }, - ]; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.bulkCreate(objects, apiCallOptions)).rejects.toThrowError( - options.forbiddenError - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [ - options.actions.savedObject.get(type1, 'bulk_create'), - options.actions.savedObject.get(type2, 'bulk_create'), - ], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'bulk_create', - [type1, type2], - [options.actions.savedObject.get(type1, 'bulk_create')], - { objects, options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.bulkCreate when authorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: true, - username, - privileges: { - [options.actions.savedObject.get(type1, 'bulk_create')]: true, - [options.actions.savedObject.get(type2, 'bulk_create')]: true, - }, - }); - - const apiCallReturnValue = Symbol(); - options.baseClient.bulkCreate.mockReturnValue(apiCallReturnValue as any); - - const client = new SecureSavedObjectsClientWrapper(options); - - const objects = [ - { type: type1, otherThing: 'sup', attributes: {} }, - { type: type2, otherThing: 'everyone', attributes: {} }, - ]; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.bulkCreate(objects, apiCallOptions)).resolves.toBe(apiCallReturnValue); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [ - options.actions.savedObject.get(type1, 'bulk_create'), - options.actions.savedObject.get(type2, 'bulk_create'), - ], - apiCallOptions.namespace - ); - expect(options.baseClient.bulkCreate).toHaveBeenCalledWith(objects, apiCallOptions); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'bulk_create', - [type1, type2], - { objects, options: apiCallOptions } - ); - }); - }); - - describe('#delete', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( - new Error('An actual error would happen here') - ); - const client = new SecureSavedObjectsClientWrapper(options); - - await expect(client.delete(type, 'bar')).rejects.toThrowError(options.generalError); - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'delete')], - undefined - ); - expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const id = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { - [options.actions.savedObject.get(type, 'delete')]: false, - }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.delete(type, id, apiCallOptions)).rejects.toThrowError( - options.forbiddenError - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'delete')], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'delete', - [type], - [options.actions.savedObject.get(type, 'delete')], - { type, id, options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of internalRepository.delete when authorized`, async () => { - const type = 'foo'; - const id = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: true, - username, - privileges: { [options.actions.savedObject.get(type, 'delete')]: true }, - }); - - const apiCallReturnValue = Symbol(); - options.baseClient.delete.mockReturnValue(apiCallReturnValue as any); - - const client = new SecureSavedObjectsClientWrapper(options); - - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.delete(type, id, apiCallOptions)).resolves.toBe(apiCallReturnValue); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'delete')], - apiCallOptions.namespace - ); - expect(options.baseClient.delete).toHaveBeenCalledWith(type, id, apiCallOptions); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'delete', - [type], - { type, id, options: apiCallOptions } - ); - }); - }); - - describe('#find', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( - new Error('An actual error would happen here') - ); - const client = new SecureSavedObjectsClientWrapper(options); - - await expect(client.find({ type })).rejects.toThrowError(options.generalError); - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'find')], - undefined - ); - expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { [options.actions.savedObject.get(type, 'find')]: false }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const apiCallOptions = Object.freeze({ type, namespace: 'some-ns' }); - await expect(client.find(apiCallOptions)).rejects.toThrowError(options.forbiddenError); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'find')], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'find', - [type], - [options.actions.savedObject.get(type, 'find')], - { options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { - [options.actions.savedObject.get(type1, 'find')]: false, - [options.actions.savedObject.get(type2, 'find')]: true, - }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const apiCallOptions = Object.freeze({ type: [type1, type2], namespace: 'some-ns' }); - await expect(client.find(apiCallOptions)).rejects.toThrowError(options.forbiddenError); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [ - options.actions.savedObject.get(type1, 'find'), - options.actions.savedObject.get(type2, 'find'), - ], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'find', - [type1, type2], - [options.actions.savedObject.get(type1, 'find')], - { options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.find when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: true, - username, - privileges: { [options.actions.savedObject.get(type, 'find')]: true }, - }); - - const apiCallReturnValue = Symbol(); - options.baseClient.find.mockReturnValue(apiCallReturnValue as any); - - const client = new SecureSavedObjectsClientWrapper(options); - - const apiCallOptions = Object.freeze({ type, namespace: 'some-ns' }); - await expect(client.find(apiCallOptions)).resolves.toBe(apiCallReturnValue); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'find')], - apiCallOptions.namespace - ); - expect(options.baseClient.find).toHaveBeenCalledWith(apiCallOptions); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'find', - [type], - { options: apiCallOptions } - ); - }); - }); - - describe('#bulkGet', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( - new Error('An actual error would happen here') - ); - const client = new SecureSavedObjectsClientWrapper(options); - - await expect(client.bulkGet([{ id: 'bar', type }])).rejects.toThrowError( - options.generalError - ); - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'bulk_get')], - undefined - ); - expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { - [options.actions.savedObject.get(type1, 'bulk_get')]: false, - [options.actions.savedObject.get(type2, 'bulk_get')]: true, - }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const objects = [ - { type: type1, id: `bar-${type1}` }, - { type: type2, id: `bar-${type2}` }, - ]; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.bulkGet(objects, apiCallOptions)).rejects.toThrowError( - options.forbiddenError - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [ - options.actions.savedObject.get(type1, 'bulk_get'), - options.actions.savedObject.get(type2, 'bulk_get'), - ], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'bulk_get', - [type1, type2], - [options.actions.savedObject.get(type1, 'bulk_get')], - { objects, options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.bulkGet when authorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: true, - username, - privileges: { - [options.actions.savedObject.get(type1, 'bulk_get')]: true, - [options.actions.savedObject.get(type2, 'bulk_get')]: true, - }, - }); - - const apiCallReturnValue = Symbol(); - options.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); - - const client = new SecureSavedObjectsClientWrapper(options); - - const objects = [ - { type: type1, id: `id-${type1}` }, - { type: type2, id: `id-${type2}` }, - ]; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.bulkGet(objects, apiCallOptions)).resolves.toBe(apiCallReturnValue); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [ - options.actions.savedObject.get(type1, 'bulk_get'), - options.actions.savedObject.get(type2, 'bulk_get'), - ], - apiCallOptions.namespace - ); - expect(options.baseClient.bulkGet).toHaveBeenCalledWith(objects, apiCallOptions); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'bulk_get', - [type1, type2], - { objects, options: apiCallOptions } - ); - }); - }); - - describe('#get', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( - new Error('An actual error would happen here') - ); - const client = new SecureSavedObjectsClientWrapper(options); - - await expect(client.get(type, 'bar')).rejects.toThrowError(options.generalError); - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'get')], - undefined - ); - expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const id = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { - [options.actions.savedObject.get(type, 'get')]: false, - }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.get(type, id, apiCallOptions)).rejects.toThrowError( - options.forbiddenError - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'get')], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'get', - [type], - [options.actions.savedObject.get(type, 'get')], - { type, id, options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.get when authorized`, async () => { - const type = 'foo'; - const id = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: true, - username, - privileges: { [options.actions.savedObject.get(type, 'get')]: true }, - }); - - const apiCallReturnValue = Symbol(); - options.baseClient.get.mockReturnValue(apiCallReturnValue as any); - - const client = new SecureSavedObjectsClientWrapper(options); - - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.get(type, id, apiCallOptions)).resolves.toBe(apiCallReturnValue); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'get')], - apiCallOptions.namespace - ); - expect(options.baseClient.get).toHaveBeenCalledWith(type, id, apiCallOptions); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'get', - [type], - { type, id, options: apiCallOptions } - ); - }); - }); - - describe('#update', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( - new Error('An actual error would happen here') - ); - const client = new SecureSavedObjectsClientWrapper(options); - - await expect(client.update(type, 'bar', {})).rejects.toThrowError(options.generalError); - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'update')], - undefined - ); - expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const id = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { - [options.actions.savedObject.get(type, 'update')]: false, - }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const attributes = { some: 'attr' }; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.update(type, id, attributes, apiCallOptions)).rejects.toThrowError( - options.forbiddenError - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'update')], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'update', - [type], - [options.actions.savedObject.get(type, 'update')], - { type, id, attributes, options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.update when authorized`, async () => { - const type = 'foo'; - const id = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: true, - username, - privileges: { [options.actions.savedObject.get(type, 'update')]: true }, - }); - - const apiCallReturnValue = Symbol(); - options.baseClient.update.mockReturnValue(apiCallReturnValue as any); - - const client = new SecureSavedObjectsClientWrapper(options); - - const attributes = { some: 'attr' }; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.update(type, id, attributes, apiCallOptions)).resolves.toBe( - apiCallReturnValue - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'update')], - apiCallOptions.namespace - ); - expect(options.baseClient.update).toHaveBeenCalledWith(type, id, attributes, apiCallOptions); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'update', - [type], - { type, id, attributes, options: apiCallOptions } - ); - }); - }); - - describe('#bulkUpdate', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( - new Error('An actual error would happen here') - ); - const client = new SecureSavedObjectsClientWrapper(options); - - await expect(client.bulkUpdate([{ id: 'bar', type, attributes: {} }])).rejects.toThrowError( - options.generalError - ); - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'bulk_update')], - undefined - ); - expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { - [options.actions.savedObject.get(type, 'bulk_update')]: false, - }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const objects = [{ type, id: `bar-${type}`, attributes: {} }]; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.bulkUpdate(objects, apiCallOptions)).rejects.toThrowError( - options.forbiddenError - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'bulk_update')], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'bulk_update', - [type], - [options.actions.savedObject.get(type, 'bulk_update')], - { objects, options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.bulkUpdate when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: true, - username, - privileges: { - [options.actions.savedObject.get(type, 'bulk_update')]: true, - }, - }); - - const apiCallReturnValue = Symbol(); - options.baseClient.bulkUpdate.mockReturnValue(apiCallReturnValue as any); - - const client = new SecureSavedObjectsClientWrapper(options); - - const objects = [{ type, id: `id-${type}`, attributes: {} }]; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.bulkUpdate(objects, apiCallOptions)).resolves.toBe(apiCallReturnValue); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'bulk_update')], - apiCallOptions.namespace - ); - expect(options.baseClient.bulkUpdate).toHaveBeenCalledWith(objects, apiCallOptions); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'bulk_update', - [type], - { objects, options: apiCallOptions } - ); - }); +describe('#delete', () => { + const type = 'foo'; + const id = `${type}-id`; + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.delete, { type, id }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + await expectForbiddenError(client.delete, { type, id, options }); + }); + + test(`returns result of internalRepository.delete when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.delete.mockReturnValue(apiCallReturnValue as any); + + const result = await expectSuccess(client.delete, { type, id, options }); + expect(result).toBe(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + await expectPrivilegeCheck(client.delete, { type, id, options }); + }); +}); + +describe('#find', () => { + const type1 = 'foo'; + const type2 = 'bar'; + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.find, { type: type1 }); + }); + + test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => { + const options = Object.freeze({ type: type1, namespace: 'some-ns' }); + await expectForbiddenError(client.find, { options }); + }); + + test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { + const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' }); + await expectForbiddenError(client.find, { options }); + }); + + test(`returns result of baseClient.find when authorized`, async () => { + const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any); + + const options = Object.freeze({ type: type1, namespace: 'some-ns' }); + const result = await expectSuccess(client.find, { options }); + expect(result).toEqual(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' }); + await expectPrivilegeCheck(client.find, { options }); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' }); + await expectObjectsNamespaceFiltering(client.find, { options }); + }); +}); + +describe('#get', () => { + const type = 'foo'; + const id = `${type}-id`; + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.get, { type, id }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + await expectForbiddenError(client.get, { type, id, options }); + }); + + test(`returns result of baseClient.get when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.get.mockReturnValue(apiCallReturnValue as any); + + const result = await expectSuccess(client.get, { type, id, options }); + expect(result).toBe(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + await expectPrivilegeCheck(client.get, { type, id, options }); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + await expectObjectNamespaceFiltering(client.get, { type, id, options }); + }); +}); + +describe('#deleteFromNamespaces', () => { + const type = 'foo'; + const id = `${type}-id`; + const namespace1 = 'foo-namespace'; + const namespace2 = 'bar-namespace'; + const namespaces = [namespace1, namespace2]; + const privilege = `mock-saved_object:${type}/delete`; + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.deleteFromNamespaces, { type, id, namespaces }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure + ); + + await expect(client.deleteFromNamespaces(type, id, namespaces)).rejects.toThrowError( + clientOpts.forbiddenError + ); + + expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + USERNAME, + 'deleteFromNamespaces', // action for privilege check is 'delete', but auditAction is 'deleteFromNamespaces' + [type], + namespaces.sort(), + [{ privilege, spaceId: namespace1 }], + { type, id, namespaces, options: {} } + ); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.deleteFromNamespaces when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.deleteFromNamespaces.mockReturnValue(apiCallReturnValue as any); + + const result = await client.deleteFromNamespaces(type, id, namespaces); + expect(result).toBe(apiCallReturnValue); + + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + USERNAME, + 'deleteFromNamespaces', // action for privilege check is 'delete', but auditAction is 'deleteFromNamespaces' + [type], + namespaces.sort(), + { type, id, namespaces, options: {} } + ); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure + ); + + await expect(client.deleteFromNamespaces(type, id, namespaces)).rejects.toThrow(); // test is simpler with error case + + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [privilege], + namespaces + ); + }); +}); + +describe('#update', () => { + const type = 'foo'; + const id = `${type}-id`; + const attributes = { some: 'attr' }; + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.update, { type, id, attributes }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + await expectForbiddenError(client.update, { type, id, attributes, options }); + }); + + test(`returns result of baseClient.update when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.update.mockReturnValue(apiCallReturnValue as any); + + const result = await expectSuccess(client.update, { type, id, attributes, options }); + expect(result).toBe(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + await expectPrivilegeCheck(client.update, { type, id, attributes, options }); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + await expectObjectNamespaceFiltering(client.update, { type, id, attributes, options }); + }); +}); + +describe('other', () => { + test(`assigns errors from constructor to .errors`, () => { + expect(client.errors).toBe(clientOpts.errors); }); }); 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 2209e7fb66fcb..29503d475be73 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 @@ -13,9 +13,13 @@ import { SavedObjectsCreateOptions, SavedObjectsFindOptions, SavedObjectsUpdateOptions, + SavedObjectsAddToNamespacesOptions, + SavedObjectsDeleteFromNamespacesOptions, } from '../../../../../src/core/server'; import { SecurityAuditLogger } from '../audit'; import { Actions, CheckSavedObjectsPrivileges } from '../authorization'; +import { CheckPrivilegesResponse } from '../authorization/check_privileges'; +import { SpacesService } from '../plugin'; interface SecureSavedObjectsClientWrapperOptions { actions: Actions; @@ -23,6 +27,19 @@ interface SecureSavedObjectsClientWrapperOptions { baseClient: SavedObjectsClientContract; errors: SavedObjectsClientContract['errors']; checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges; + getSpacesService(): SpacesService | undefined; +} + +interface SavedObjectNamespaces { + namespaces?: string[]; +} + +interface SavedObjectsNamespaces { + saved_objects: SavedObjectNamespaces[]; +} + +function uniq(arr: T[]): T[] { + return Array.from(new Set(arr)); } export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContract { @@ -30,19 +47,23 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra private readonly auditLogger: PublicMethodsOf; private readonly baseClient: SavedObjectsClientContract; private readonly checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges; + private getSpacesService: () => SpacesService | undefined; public readonly errors: SavedObjectsClientContract['errors']; + constructor({ actions, auditLogger, baseClient, checkSavedObjectsPrivilegesAsCurrentUser, errors, + getSpacesService, }: SecureSavedObjectsClientWrapperOptions) { this.errors = errors; this.actions = actions; this.auditLogger = auditLogger; this.baseClient = baseClient; this.checkSavedObjectsPrivilegesAsCurrentUser = checkSavedObjectsPrivilegesAsCurrentUser; + this.getSpacesService = getSpacesService; } public async create( @@ -52,7 +73,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) { await this.ensureAuthorized(type, 'create', options.namespace, { type, attributes, options }); - return await this.baseClient.create(type, attributes, options); + const savedObject = await this.baseClient.create(type, attributes, options); + return await this.redactSavedObjectNamespaces(savedObject); } public async bulkCreate( @@ -66,7 +88,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra { objects, options } ); - return await this.baseClient.bulkCreate(objects, options); + const response = await this.baseClient.bulkCreate(objects, options); + return await this.redactSavedObjectsNamespaces(response); } public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) { @@ -78,7 +101,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra public async find(options: SavedObjectsFindOptions) { await this.ensureAuthorized(options.type, 'find', options.namespace, { options }); - return this.baseClient.find(options); + const response = await this.baseClient.find(options); + return await this.redactSavedObjectsNamespaces(response); } public async bulkGet( @@ -90,13 +114,15 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra options, }); - return await this.baseClient.bulkGet(objects, options); + const response = await this.baseClient.bulkGet(objects, options); + return await this.redactSavedObjectsNamespaces(response); } public async get(type: string, id: string, options: SavedObjectsBaseOptions = {}) { await this.ensureAuthorized(type, 'get', options.namespace, { type, id, options }); - return await this.baseClient.get(type, id, options); + const savedObject = await this.baseClient.get(type, id, options); + return await this.redactSavedObjectNamespaces(savedObject); } public async update( @@ -105,14 +131,44 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra attributes: Partial, options: SavedObjectsUpdateOptions = {} ) { - await this.ensureAuthorized(type, 'update', options.namespace, { - type, - id, - attributes, - options, - }); + const args = { type, id, attributes, options }; + await this.ensureAuthorized(type, 'update', options.namespace, args); - return await this.baseClient.update(type, id, attributes, options); + const savedObject = await this.baseClient.update(type, id, attributes, options); + return await this.redactSavedObjectNamespaces(savedObject); + } + + public async addToNamespaces( + type: string, + id: string, + namespaces: string[], + options: SavedObjectsAddToNamespacesOptions = {} + ) { + const args = { type, id, namespaces, options }; + const { namespace } = options; + // To share an object, the user must have the "create" permission in each of the destination namespaces. + await this.ensureAuthorized(type, 'create', namespaces, args, 'addToNamespacesCreate'); + + // To share an object, the user must also have the "update" permission in one or more of the source namespaces. Because the + // `addToNamespaces` operation is scoped to the current namespace, we can just check if the user has the "update" permission in the + // current namespace. If the user has permission, but the saved object doesn't exist in this namespace, the base client operation will + // result in a 404 error. + await this.ensureAuthorized(type, 'update', namespace, args, 'addToNamespacesUpdate'); + + return await this.baseClient.addToNamespaces(type, id, namespaces, options); + } + + public async deleteFromNamespaces( + type: string, + id: string, + namespaces: string[], + options: SavedObjectsDeleteFromNamespacesOptions = {} + ) { + const args = { type, id, namespaces, options }; + // To un-share an object, the user must have the "delete" permission in each of the target namespaces. + await this.ensureAuthorized(type, 'delete', namespaces, args, 'deleteFromNamespaces'); + + return await this.baseClient.deleteFromNamespaces(type, id, namespaces, options); } public async bulkUpdate( @@ -126,12 +182,16 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra { objects, options } ); - return await this.baseClient.bulkUpdate(objects, options); + const response = await this.baseClient.bulkUpdate(objects, options); + return await this.redactSavedObjectsNamespaces(response); } - private async checkPrivileges(actions: string | string[], namespace?: string) { + private async checkPrivileges( + actions: string | string[], + namespaceOrNamespaces?: string | string[] + ) { try { - return await this.checkSavedObjectsPrivilegesAsCurrentUser(actions, namespace); + return await this.checkSavedObjectsPrivilegesAsCurrentUser(actions, namespaceOrNamespaces); } catch (error) { throw this.errors.decorateGeneralError(error, error.body && error.body.reason); } @@ -140,43 +200,133 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra private async ensureAuthorized( typeOrTypes: string | string[], action: string, - namespace?: string, - args?: Record + namespaceOrNamespaces?: string | string[], + args?: Record, + auditAction: string = action, + requiresAll = true ) { const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; const actionsToTypesMap = new Map( types.map(type => [this.actions.savedObject.get(type, action), type]) ); const actions = Array.from(actionsToTypesMap.keys()); - const { hasAllRequested, username, privileges } = await this.checkPrivileges( - actions, - namespace - ); + const result = await this.checkPrivileges(actions, namespaceOrNamespaces); + + const { hasAllRequested, username, privileges } = result; + const spaceIds = uniq( + privileges.map(({ resource }) => resource).filter(x => x !== undefined) + ).sort() as string[]; - if (hasAllRequested) { - this.auditLogger.savedObjectsAuthorizationSuccess(username, action, types, args); + const isAuthorized = + (requiresAll && hasAllRequested) || + (!requiresAll && privileges.some(({ authorized }) => authorized)); + if (isAuthorized) { + this.auditLogger.savedObjectsAuthorizationSuccess( + username, + auditAction, + types, + spaceIds, + args + ); } else { const missingPrivileges = this.getMissingPrivileges(privileges); this.auditLogger.savedObjectsAuthorizationFailure( username, - action, + auditAction, types, + spaceIds, missingPrivileges, args ); - const msg = `Unable to ${action} ${missingPrivileges - .map(privilege => actionsToTypesMap.get(privilege)) - .sort() - .join(',')}`; + const targetTypes = uniq( + missingPrivileges.map(({ privilege }) => actionsToTypesMap.get(privilege)).sort() + ).join(','); + const msg = `Unable to ${action} ${targetTypes}`; throw this.errors.decorateForbiddenError(new Error(msg)); } } - private getMissingPrivileges(privileges: Record) { - return Object.keys(privileges).filter(privilege => !privileges[privilege]); + private getMissingPrivileges(privileges: CheckPrivilegesResponse['privileges']) { + return privileges + .filter(({ authorized }) => !authorized) + .map(({ resource, privilege }) => ({ spaceId: resource, privilege })); } private getUniqueObjectTypes(objects: Array<{ type: string }>) { - return [...new Set(objects.map(o => o.type))]; + return uniq(objects.map(o => o.type)); + } + + private async getNamespacesPrivilegeMap(namespaces: string[]) { + 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( + (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) + if (resource && (!authorized || !acc.hasOwnProperty(resource))) { + acc[resource] = authorized; + } + return acc; + }, + {} + ); + return map; + } + + private redactAndSortNamespaces(spaceIds: string[], privilegeMap: Record) { + const comparator = (a: string, b: string) => { + const _a = a.toLowerCase(); + const _b = b.toLowerCase(); + if (_a === '?') { + return 1; + } else if (_a < _b) { + return -1; + } else if (_a > _b) { + return 1; + } + return 0; + }; + return spaceIds.map(spaceId => (privilegeMap[spaceId] ? spaceId : '?')).sort(comparator); + } + + private async redactSavedObjectNamespaces( + savedObject: T + ): Promise { + if (this.getSpacesService() === undefined || savedObject.namespaces == null) { + return savedObject; + } + + const privilegeMap = await this.getNamespacesPrivilegeMap(savedObject.namespaces); + + return { + ...savedObject, + namespaces: this.redactAndSortNamespaces(savedObject.namespaces, privilegeMap), + }; + } + + private async redactSavedObjectsNamespaces( + response: T + ): Promise { + if (this.getSpacesService() === undefined) { + return response; + } + const { saved_objects: savedObjects } = response; + const namespaces = uniq(savedObjects.flatMap(savedObject => savedObject.namespaces || [])); + if (namespaces.length === 0) { + return response; + } + + const privilegeMap = await this.getNamespacesPrivilegeMap(namespaces); + + return { + ...response, + saved_objects: savedObjects.map(savedObject => ({ + ...savedObject, + namespaces: + savedObject.namespaces && + this.redactAndSortNamespaces(savedObject.namespaces, privilegeMap), + })), + }; } } diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_service.test.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_service.test.ts index 7d680f0ee0ed6..4e61756d933c9 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_service.test.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_service.test.ts @@ -22,8 +22,8 @@ describe('CopySavedObjectsToSpaceService', () => { const service = new CopySavedObjectsToSpaceService(); service.setup(deps); - expect(deps.savedObjectsManagementSetup.actionRegistry.register).toHaveBeenCalledTimes(1); - expect(deps.savedObjectsManagementSetup.actionRegistry.register).toHaveBeenCalledWith( + expect(deps.savedObjectsManagementSetup.actions.register).toHaveBeenCalledTimes(1); + expect(deps.savedObjectsManagementSetup.actions.register).toHaveBeenCalledWith( expect.any(CopyToSpaceSavedObjectsManagementAction) ); }); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_service.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_service.ts index d564514beebff..93d0f92744d41 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_service.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_service.ts @@ -18,6 +18,6 @@ interface SetupDeps { export class CopySavedObjectsToSpaceService { public setup({ spacesManager, savedObjectsManagementSetup, notificationsSetup }: SetupDeps) { const action = new CopyToSpaceSavedObjectsManagementAction(spacesManager, notificationsSetup); - savedObjectsManagementSetup.actionRegistry.register(action); + savedObjectsManagementSetup.actions.register(action); } } diff --git a/x-pack/plugins/spaces/public/management/edit_space/delete_spaces_button.tsx b/x-pack/plugins/spaces/public/management/edit_space/delete_spaces_button.tsx index 28e45bc8cfd2a..ea63905e27b26 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/delete_spaces_button.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/delete_spaces_button.tsx @@ -99,10 +99,14 @@ export class DeleteSpacesButton extends Component { public deleteSpaces = async () => { const { spacesManager, space } = this.props; + this.setState({ + showConfirmDeleteModal: false, + }); + try { await spacesManager.deleteSpace(space); } catch (error) { - const { message: errorMessage = '' } = error.data || {}; + const { message: errorMessage = '' } = error.data || error.body || {}; this.props.notifications.toasts.addDanger( i18n.translate('xpack.spaces.management.deleteSpacesButton.deleteSpaceErrorTitle', { @@ -110,12 +114,9 @@ export class DeleteSpacesButton extends Component { values: { errorMessage }, }) ); + return; } - this.setState({ - showConfirmDeleteModal: false, - }); - const message = i18n.translate( 'xpack.spaces.management.deleteSpacesButton.spaceSuccessfullyDeletedNotificationMessage', { 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 ff4be84207832..df5e6a2ca34af 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 @@ -176,10 +176,14 @@ export class SpacesGridPage extends Component { return; } + this.setState({ + showConfirmDeleteModal: false, + }); + try { await spacesManager.deleteSpace(space); } catch (error) { - const { message: errorMessage = '' } = error.data || {}; + const { message: errorMessage = '' } = error.data || error.body || {}; this.props.notifications.toasts.addDanger( i18n.translate('xpack.spaces.management.spacesGridPage.errorDeletingSpaceErrorMessage', { @@ -189,12 +193,9 @@ export class SpacesGridPage extends Component { }, }) ); + return; } - this.setState({ - showConfirmDeleteModal: false, - }); - this.loadGrid(); const message = i18n.translate( diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts index 59e157c3fc2db..d842f07cdb205 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts @@ -57,26 +57,26 @@ describe('copySavedObjectsToSpaces', () => { typeRegistry.getAllTypes.mockReturnValue([ { name: 'dashboard', - namespaceAgnostic: false, + namespaceType: 'single', hidden: false, mappings: { properties: {} }, }, { name: 'visualization', - namespaceAgnostic: false, + namespaceType: 'single', hidden: false, mappings: { properties: {} }, }, { name: 'globaltype', - namespaceAgnostic: true, + namespaceType: 'agnostic', hidden: false, mappings: { properties: {} }, }, ]); typeRegistry.isNamespaceAgnostic.mockImplementation((type: string) => - typeRegistry.getAllTypes().some(t => t.name === type && t.namespaceAgnostic) + typeRegistry.getAllTypes().some(t => t.name === type && t.namespaceType === 'agnostic') ); coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); @@ -188,11 +188,13 @@ describe('copySavedObjectsToSpaces', () => { }, ], "savedObjectsClient": Object { + "addToNamespaces": [MockFunction], "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], "create": [MockFunction], "delete": [MockFunction], + "deleteFromNamespaces": [MockFunction], "errors": [Function], "find": [MockFunction], "get": [MockFunction], @@ -252,11 +254,13 @@ describe('copySavedObjectsToSpaces', () => { "readable": false, }, "savedObjectsClient": Object { + "addToNamespaces": [MockFunction], "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], "create": [MockFunction], "delete": [MockFunction], + "deleteFromNamespaces": [MockFunction], "errors": [Function], "find": [MockFunction], "get": [MockFunction], @@ -315,11 +319,13 @@ describe('copySavedObjectsToSpaces', () => { "readable": false, }, "savedObjectsClient": Object { + "addToNamespaces": [MockFunction], "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], "create": [MockFunction], "delete": [MockFunction], + "deleteFromNamespaces": [MockFunction], "errors": [Function], "find": [MockFunction], "get": [MockFunction], diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts index 7809f1f8be66f..0654712ecff0e 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts @@ -57,26 +57,26 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { typeRegistry.getAllTypes.mockReturnValue([ { name: 'dashboard', - namespaceAgnostic: false, + namespaceType: 'single', hidden: false, mappings: { properties: {} }, }, { name: 'visualization', - namespaceAgnostic: false, + namespaceType: 'single', hidden: false, mappings: { properties: {} }, }, { name: 'globaltype', - namespaceAgnostic: true, + namespaceType: 'agnostic', hidden: false, mappings: { properties: {} }, }, ]); typeRegistry.isNamespaceAgnostic.mockImplementation((type: string) => - typeRegistry.getAllTypes().some(t => t.name === type && t.namespaceAgnostic) + typeRegistry.getAllTypes().some(t => t.name === type && t.namespaceType === 'agnostic') ); coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); @@ -204,11 +204,13 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { }, ], "savedObjectsClient": Object { + "addToNamespaces": [MockFunction], "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], "create": [MockFunction], "delete": [MockFunction], + "deleteFromNamespaces": [MockFunction], "errors": [Function], "find": [MockFunction], "get": [MockFunction], @@ -275,11 +277,13 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { }, ], "savedObjectsClient": Object { + "addToNamespaces": [MockFunction], "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], "create": [MockFunction], "delete": [MockFunction], + "deleteFromNamespaces": [MockFunction], "errors": [Function], "find": [MockFunction], "get": [MockFunction], @@ -345,11 +349,13 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { }, ], "savedObjectsClient": Object { + "addToNamespaces": [MockFunction], "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], "create": [MockFunction], "delete": [MockFunction], + "deleteFromNamespaces": [MockFunction], "errors": [Function], "find": [MockFunction], "get": [MockFunction], 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 74e75fb8f12c7..c83830f6feace 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 @@ -250,14 +250,10 @@ describe('#getAll', () => { mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); mockCheckPrivilegesAtSpaces.mockReturnValue({ username, - spacePrivileges: { - [savedObjects[0].id]: { - [privilege]: false, - }, - [savedObjects[1].id]: { - [privilege]: false, - }, - }, + privileges: [ + { resource: savedObjects[0].id, privilege, authorized: false }, + { resource: savedObjects[1].id, privilege, authorized: false }, + ], }); const maxSpaces = 1234; const mockConfig = createMockConfig({ @@ -314,14 +310,10 @@ describe('#getAll', () => { mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); mockCheckPrivilegesAtSpaces.mockReturnValue({ username, - spacePrivileges: { - [savedObjects[0].id]: { - [privilege]: true, - }, - [savedObjects[1].id]: { - [privilege]: false, - }, - }, + privileges: [ + { resource: savedObjects[0].id, privilege, authorized: true }, + { resource: savedObjects[1].id, privilege, authorized: false }, + ], }); const mockInternalRepository = { find: jest.fn().mockReturnValue({ 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 22c34c03368e3..0c066fb76994f 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 @@ -74,16 +74,14 @@ export class SpacesClient { const privilege = privilegeFactory(this.authorization!); - const { username, spacePrivileges } = await checkPrivileges.atSpaces(spaceIds, privilege); + const { username, privileges } = await checkPrivileges.atSpaces(spaceIds, privilege); - const authorized = Object.keys(spacePrivileges).filter(spaceId => { - return spacePrivileges[spaceId][privilege]; - }); + const authorized = privileges.filter(x => x.authorized).map(x => x.resource); this.debugLogger( `SpacesClient.getAll(), authorized for ${ authorized.length - } spaces, derived from ES privilege check: ${JSON.stringify(spacePrivileges)}` + } spaces, derived from ES privilege check: ${JSON.stringify(privileges)}` ); if (authorized.length === 0) { @@ -94,7 +92,7 @@ export class SpacesClient { throw Boom.forbidden(); } - this.auditLogger.spacesAuthorizationSuccess(username, 'getAll', authorized); + this.auditLogger.spacesAuthorizationSuccess(username, 'getAll', authorized as string[]); const filteredSpaces: Space[] = spaces.filter((space: any) => authorized.includes(space.id)); this.debugLogger( `SpacesClient.getAll(), using RBAC. returning spaces: ${filteredSpaces @@ -211,9 +209,9 @@ export class SpacesClient { throw Boom.badRequest('This Space cannot be deleted because it is reserved.'); } - await repository.delete('space', id); - await repository.deleteByNamespace(id); + + await repository.delete('space', id); } private useRbac(): boolean { diff --git a/x-pack/plugins/spaces/server/plugin.test.ts b/x-pack/plugins/spaces/server/plugin.test.ts index 0b9905d5e9c95..75ddee772b168 100644 --- a/x-pack/plugins/spaces/server/plugin.test.ts +++ b/x-pack/plugins/spaces/server/plugin.test.ts @@ -81,7 +81,7 @@ describe('Spaces Plugin', () => { expect(core.savedObjects.registerType).toHaveBeenCalledWith({ name: 'space', - namespaceAgnostic: true, + namespaceType: 'agnostic', hidden: true, mappings: expect.any(Object), migrations: expect.any(Object), diff --git a/x-pack/plugins/spaces/server/plugin.ts b/x-pack/plugins/spaces/server/plugin.ts index a24d626c2a85d..09b38adb70682 100644 --- a/x-pack/plugins/spaces/server/plugin.ts +++ b/x-pack/plugins/spaces/server/plugin.ts @@ -115,10 +115,8 @@ export class Plugin { const savedObjectsService = new SpacesSavedObjectsService(); savedObjectsService.setup({ core, spacesService }); - const viewRouter = core.http.createRouter(); initSpacesViewsRoutes({ - viewRouter, - cspHeader: core.http.csp.header, + httpResources: core.http.resources, }); const externalRouter = core.http.createRouter(); diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts index d8c318369834e..cbe832e485d66 100644 --- a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts @@ -46,37 +46,37 @@ export const createMockSavedObjectsService = (spaces: any[] = []) => { typeRegistry.getAllTypes.mockReturnValue([ { name: 'visualization', - namespaceAgnostic: false, + namespaceType: 'single', hidden: false, mappings: { properties: {} }, }, { name: 'dashboard', - namespaceAgnostic: false, + namespaceType: 'single', hidden: false, mappings: { properties: {} }, }, { name: 'index-pattern', - namespaceAgnostic: false, + namespaceType: 'single', hidden: false, mappings: { properties: {} }, }, { name: 'globalType', - namespaceAgnostic: true, + namespaceType: 'agnostic', hidden: false, mappings: { properties: {} }, }, { name: 'space', - namespaceAgnostic: true, + namespaceType: 'agnostic', hidden: true, mappings: { properties: {} }, }, ]); typeRegistry.isNamespaceAgnostic.mockImplementation((type: string) => - typeRegistry.getAllTypes().some(t => t.name === type && t.namespaceAgnostic) + typeRegistry.getAllTypes().some(t => t.name === type && t.namespaceType === 'agnostic') ); savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); diff --git a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts index f2ba8785f5a3f..511e9676940d2 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts @@ -11,7 +11,13 @@ import { mockRouteContext, mockRouteContextWithInvalidLicense, } from '../__fixtures__'; -import { CoreSetup, IRouter, kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; +import { + CoreSetup, + IRouter, + kibanaResponseFactory, + RouteValidatorConfig, + SavedObjectsErrorHelpers, +} from 'src/core/server'; import { loggingServiceMock, httpServiceMock, @@ -75,6 +81,7 @@ describe('Spaces Public API', () => { return { routeValidation: routeDefinition.validate as RouteValidatorConfig<{}, {}, {}>, routeHandler, + savedObjectsRepositoryMock, }; }; @@ -143,6 +150,27 @@ describe('Spaces Public API', () => { expect(status).toEqual(404); }); + it(`returns http/400 when scripts cannot be executed in Elasticsearch`, async () => { + const { routeHandler, savedObjectsRepositoryMock } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + params: { + id: 'a-space', + }, + method: 'delete', + }); + // @ts-ignore + savedObjectsRepositoryMock.deleteByNamespace.mockRejectedValue( + SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError(new Error()) + ); + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + const { status, payload } = response; + + expect(status).toEqual(400); + expect(payload.message).toEqual('Cannot execute script in Elasticsearch query'); + }); + it(`DELETE spaces/{id}' cannot delete reserved spaces`, async () => { const { routeHandler } = await setup(); diff --git a/x-pack/plugins/spaces/server/routes/api/external/delete.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.ts index 4b7e6b00182ac..150f1d198cdf6 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/delete.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import Boom from 'boom'; import { schema } from '@kbn/config-schema'; import { SavedObjectsErrorHelpers } from '../../../../../../../src/core/server'; import { wrapError } from '../../../lib/errors'; @@ -12,7 +13,7 @@ import { ExternalRouteDeps } from '.'; import { createLicensedRouteHandler } from '../../lib'; export function initDeleteSpacesApi(deps: ExternalRouteDeps) { - const { externalRouter, spacesService } = deps; + const { externalRouter, log, spacesService } = deps; externalRouter.delete( { @@ -33,6 +34,13 @@ export function initDeleteSpacesApi(deps: ExternalRouteDeps) { } catch (error) { if (SavedObjectsErrorHelpers.isNotFoundError(error)) { return response.notFound(); + } else if (SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)) { + log.error( + `Failed to delete space '${id}', cannot execute script in Elasticsearch query: ${error.message}` + ); + return response.customError( + wrapError(Boom.badRequest('Cannot execute script in Elasticsearch query')) + ); } return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/spaces/server/routes/api/external/index.ts b/x-pack/plugins/spaces/server/routes/api/external/index.ts index 1bdb7ceb8a3f7..079f690bfe546 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/index.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/index.ts @@ -12,6 +12,8 @@ import { initPostSpacesApi } from './post'; import { initPutSpacesApi } from './put'; import { SpacesServiceSetup } from '../../../spaces_service/spaces_service'; import { initCopyToSpacesApi } from './copy_to_space'; +import { initShareAddSpacesApi } from './share_add_spaces'; +import { initShareRemoveSpacesApi } from './share_remove_spaces'; export interface ExternalRouteDeps { externalRouter: IRouter; @@ -28,4 +30,6 @@ export function initExternalSpacesApi(deps: ExternalRouteDeps) { initPostSpacesApi(deps); initPutSpacesApi(deps); initCopyToSpacesApi(deps); + initShareAddSpacesApi(deps); + initShareRemoveSpacesApi(deps); } diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.ts b/x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.ts new file mode 100644 index 0000000000000..f40cc5cc50572 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.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 { schema } from '@kbn/config-schema'; +import { wrapError } from '../../../lib/errors'; +import { ExternalRouteDeps } from '.'; +import { SPACE_ID_REGEX } from '../../../lib/space_schema'; +import { createLicensedRouteHandler } from '../../lib'; + +const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); +export function initShareAddSpacesApi(deps: ExternalRouteDeps) { + const { externalRouter, getStartServices } = deps; + + externalRouter.post( + { + path: '/api/spaces/_share_saved_object_add', + validate: { + body: schema.object({ + spaces: schema.arrayOf( + schema.string({ + validate: value => { + if (!SPACE_ID_REGEX.test(value)) { + return `lower case, a-z, 0-9, "_", and "-" are allowed`; + } + }, + }), + { + validate: spaceIds => { + if (!spaceIds.length) { + return 'must specify one or more space ids'; + } else if (uniq(spaceIds).length !== spaceIds.length) { + return 'duplicate space ids are not allowed'; + } + }, + } + ), + object: schema.object({ + type: schema.string(), + id: schema.string(), + }), + }), + }, + }, + createLicensedRouteHandler(async (_context, request, response) => { + const [startServices] = await getStartServices(); + const scopedClient = startServices.savedObjects.getScopedClient(request); + + const spaces = request.body.spaces; + const { type, id } = request.body.object; + + try { + await scopedClient.addToNamespaces(type, id, spaces); + } catch (error) { + return response.customError(wrapError(error)); + } + return response.noContent(); + }) + ); +} diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.ts b/x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.ts new file mode 100644 index 0000000000000..5f58a5dfd5e5f --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.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 { schema } from '@kbn/config-schema'; +import { wrapError } from '../../../lib/errors'; +import { ExternalRouteDeps } from '.'; +import { SPACE_ID_REGEX } from '../../../lib/space_schema'; +import { createLicensedRouteHandler } from '../../lib'; + +const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); +export function initShareRemoveSpacesApi(deps: ExternalRouteDeps) { + const { externalRouter, getStartServices } = deps; + + externalRouter.post( + { + path: '/api/spaces/_share_saved_object_remove', + validate: { + body: schema.object({ + spaces: schema.arrayOf( + schema.string({ + validate: value => { + if (!SPACE_ID_REGEX.test(value)) { + return `lower case, a-z, 0-9, "_", and "-" are allowed`; + } + }, + }), + { + validate: spaceIds => { + if (!spaceIds.length) { + return 'must specify one or more space ids'; + } else if (uniq(spaceIds).length !== spaceIds.length) { + return 'duplicate space ids are not allowed'; + } + }, + } + ), + object: schema.object({ + type: schema.string(), + id: schema.string(), + }), + }), + }, + }, + createLicensedRouteHandler(async (_context, request, response) => { + const [startServices] = await getStartServices(); + const scopedClient = startServices.savedObjects.getScopedClient(request); + + const spaces = request.body.spaces; + const { type, id } = request.body.object; + + try { + await scopedClient.deleteFromNamespaces(type, id, spaces); + } catch (error) { + return response.customError(wrapError(error)); + } + return response.noContent(); + }) + ); +} diff --git a/x-pack/plugins/spaces/server/routes/views/index.ts b/x-pack/plugins/spaces/server/routes/views/index.ts index 2a346c7e5241a..57ad8872ce558 100644 --- a/x-pack/plugins/spaces/server/routes/views/index.ts +++ b/x-pack/plugins/spaces/server/routes/views/index.ts @@ -4,26 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IRouter } from 'src/core/server'; +import { HttpResources } from 'src/core/server'; export interface ViewRouteDeps { - viewRouter: IRouter; - cspHeader: string; + httpResources: HttpResources; } export function initSpacesViewsRoutes(deps: ViewRouteDeps) { - deps.viewRouter.get( - { - path: '/spaces/space_selector', - validate: false, - }, - async (context, request, response) => { - return response.ok({ - headers: { - 'Content-Security-Policy': deps.cspHeader, - }, - body: await context.core.rendering.render({ includeUserSettings: true }), - }); - } + deps.httpResources.register( + { path: '/spaces/space_selector', validate: false }, + (context, request, response) => response.renderCoreApp() ); } diff --git a/x-pack/plugins/spaces/server/saved_objects/__snapshots__/spaces_saved_objects_client.test.ts.snap b/x-pack/plugins/spaces/server/saved_objects/__snapshots__/spaces_saved_objects_client.test.ts.snap deleted file mode 100644 index 8b1a258138355..0000000000000 --- a/x-pack/plugins/spaces/server/saved_objects/__snapshots__/spaces_saved_objects_client.test.ts.snap +++ /dev/null @@ -1,29 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`default space #bulkCreate throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`default space #bulkGet throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`default space #create throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`default space #delete throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`default space #find throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`default space #get throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`default space #update throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`space_1 space #bulkCreate throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`space_1 space #bulkGet throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`space_1 space #create throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`space_1 space #delete throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`space_1 space #find throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`space_1 space #get throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`space_1 space #update throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; diff --git a/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.test.ts b/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.test.ts index 4a9756d9e03f8..31f2c98d74c96 100644 --- a/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.test.ts @@ -58,7 +58,7 @@ describe('SpacesSavedObjectsService', () => { "6.6.0": [Function], }, "name": "space", - "namespaceAgnostic": true, + "namespaceType": "agnostic", }, ] `); diff --git a/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.ts b/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.ts index 40ea49573e3c1..58aa1fe08558a 100644 --- a/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.ts +++ b/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.ts @@ -20,7 +20,7 @@ export class SpacesSavedObjectsService { core.savedObjects.registerType({ name: 'space', hidden: true, - namespaceAgnostic: true, + namespaceType: 'agnostic', mappings: SpacesSavedObjectMappings, migrations: { '6.6.0': migrateToKibana660, diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts index 2d6fe36792c40..569219a0b8990 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts @@ -13,21 +13,21 @@ import { SavedObjectTypeRegistry } from 'src/core/server'; const typeRegistry = new SavedObjectTypeRegistry(); typeRegistry.registerType({ name: 'foo', - namespaceAgnostic: false, + namespaceType: 'single', hidden: false, mappings: { properties: {} }, }); typeRegistry.registerType({ name: 'bar', - namespaceAgnostic: false, + namespaceType: 'single', hidden: false, mappings: { properties: {} }, }); typeRegistry.registerType({ name: 'space', - namespaceAgnostic: true, + namespaceType: 'agnostic', hidden: true, mappings: { properties: {} }, }); @@ -50,42 +50,41 @@ const createMockResponse = () => ({ references: [], }); +const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; + [ { id: DEFAULT_SPACE_ID, expectedNamespace: undefined }, { id: 'space_1', expectedNamespace: 'space_1' }, ].forEach(currentSpace => { describe(`${currentSpace.id} space`, () => { + const createSpacesSavedObjectsClient = async () => { + const request = createMockRequest(); + const baseClient = createMockClient(); + const spacesService = await createSpacesService(currentSpace.id); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + typeRegistry, + }); + return { client, baseClient }; + }; + describe('#get', () => { test(`throws error if options.namespace is specified`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); + const { client } = await createSpacesSavedObjectsClient(); - await expect( - client.get('foo', '', { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + await expect(client.get('foo', '', { namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); }); - test(`supplements options with undefined namespace`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); const expectedReturnValue = createMockResponse(); baseClient.get.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); const type = Symbol(); const id = Symbol(); const options = Object.freeze({ foo: 'bar' }); @@ -102,37 +101,17 @@ const createMockResponse = () => ({ describe('#bulkGet', () => { test(`throws error if options.namespace is specified`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); + const { client } = await createSpacesSavedObjectsClient(); await expect( client.bulkGet([{ id: '', type: 'foo' }], { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); - test(`supplements options with undefined namespace`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const expectedReturnValue = { - saved_objects: [createMockResponse()], - }; + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = { saved_objects: [createMockResponse()] }; baseClient.bulkGet.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); const objects = [{ type: 'foo' }]; const options = Object.freeze({ foo: 'bar' }); @@ -149,25 +128,15 @@ const createMockResponse = () => ({ describe('#find', () => { test(`throws error if options.namespace is specified`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); + const { client } = await createSpacesSavedObjectsClient(); - await expect( - client.find({ type: 'foo', namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + await expect(client.find({ type: 'foo', namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); }); test(`passes options.type to baseClient if valid singular type specified`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); + const { client, baseClient } = await createSpacesSavedObjectsClient(); const expectedReturnValue = { saved_objects: [createMockResponse()], total: 1, @@ -175,16 +144,8 @@ const createMockResponse = () => ({ page: 0, }; baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); const options = Object.freeze({ type: 'foo' }); - const actualReturnValue = await client.find(options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -194,9 +155,8 @@ const createMockResponse = () => ({ }); }); - test(`supplements options with undefined namespace`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); const expectedReturnValue = { saved_objects: [createMockResponse()], total: 1, @@ -204,14 +164,6 @@ const createMockResponse = () => ({ page: 0, }; baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); const options = Object.freeze({ type: ['foo', 'bar'] }); const actualReturnValue = await client.find(options); @@ -226,35 +178,17 @@ const createMockResponse = () => ({ describe('#create', () => { test(`throws error if options.namespace is specified`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); + const { client } = await createSpacesSavedObjectsClient(); - await expect( - client.create('foo', {}, { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + await expect(client.create('foo', {}, { namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); }); - test(`supplements options with undefined namespace`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); const expectedReturnValue = createMockResponse(); baseClient.create.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); const type = Symbol(); const attributes = Symbol(); @@ -272,37 +206,17 @@ const createMockResponse = () => ({ describe('#bulkCreate', () => { test(`throws error if options.namespace is specified`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); + const { client } = await createSpacesSavedObjectsClient(); await expect( client.bulkCreate([{ id: '', type: 'foo', attributes: {} }], { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); - test(`supplements options with undefined namespace`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const expectedReturnValue = { - saved_objects: [createMockResponse()], - }; + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = { saved_objects: [createMockResponse()] }; baseClient.bulkCreate.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); const objects = [{ type: 'foo' }]; const options = Object.freeze({ foo: 'bar' }); @@ -319,36 +233,18 @@ const createMockResponse = () => ({ describe('#update', () => { test(`throws error if options.namespace is specified`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); + const { client } = await createSpacesSavedObjectsClient(); await expect( // @ts-ignore client.update(null, null, null, { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); - test(`supplements options with undefined namespace`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); const expectedReturnValue = createMockResponse(); baseClient.update.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); const type = Symbol(); const id = Symbol(); @@ -366,21 +262,19 @@ const createMockResponse = () => ({ }); describe('#bulkUpdate', () => { - test(`supplements options with the spaces namespace`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const expectedReturnValue = { - saved_objects: [createMockResponse()], - }; - baseClient.bulkUpdate.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); + test(`throws error if options.namespace is specified`, async () => { + const { client } = await createSpacesSavedObjectsClient(); - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); + await expect( + // @ts-ignore + client.bulkUpdate(null, { namespace: 'bar' }) + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = { saved_objects: [createMockResponse()] }; + baseClient.bulkUpdate.mockReturnValue(Promise.resolve(expectedReturnValue)); const actualReturnValue = await client.bulkUpdate([ { id: 'id', type: 'foo', attributes: {}, references: [] }, @@ -403,36 +297,18 @@ const createMockResponse = () => ({ describe('#delete', () => { test(`throws error if options.namespace is specified`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); + const { client } = await createSpacesSavedObjectsClient(); await expect( // @ts-ignore client.delete(null, null, { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); - test(`supplements options with undefined namespace`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); const expectedReturnValue = createMockResponse(); baseClient.delete.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); const type = Symbol(); const id = Symbol(); @@ -447,5 +323,65 @@ const createMockResponse = () => ({ }); }); }); + + describe('#addToNamespaces', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = await createSpacesSavedObjectsClient(); + + await expect( + // @ts-ignore + client.addToNamespaces(null, null, null, { namespace: 'bar' }) + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = createMockResponse(); + baseClient.addToNamespaces.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const type = Symbol(); + const id = Symbol(); + const namespaces = Symbol(); + const options = Object.freeze({ foo: 'bar' }); + // @ts-ignore + const actualReturnValue = await client.addToNamespaces(type, id, namespaces, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.addToNamespaces).toHaveBeenCalledWith(type, id, namespaces, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + + describe('#deleteFromNamespaces', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = await createSpacesSavedObjectsClient(); + + await expect( + // @ts-ignore + client.deleteFromNamespaces(null, null, null, { namespace: 'bar' }) + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = createMockResponse(); + baseClient.deleteFromNamespaces.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const type = Symbol(); + const id = Symbol(); + const namespaces = Symbol(); + const options = Object.freeze({ foo: 'bar' }); + // @ts-ignore + const actualReturnValue = await client.deleteFromNamespaces(type, id, namespaces, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.deleteFromNamespaces).toHaveBeenCalledWith(type, id, namespaces, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); }); }); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index f216d5743cf89..e31bc7cef6900 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -13,6 +13,8 @@ import { SavedObjectsCreateOptions, SavedObjectsFindOptions, SavedObjectsUpdateOptions, + SavedObjectsAddToNamespacesOptions, + SavedObjectsDeleteFromNamespacesOptions, ISavedObjectTypeRegistry, } from 'src/core/server'; import { SpacesServiceSetup } from '../spaces_service/spaces_service'; @@ -213,6 +215,50 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } + /** + * Adds namespaces to a SavedObject + * + * @param type + * @param id + * @param namespaces + * @param options + */ + public async addToNamespaces( + type: string, + id: string, + namespaces: string[], + options: SavedObjectsAddToNamespacesOptions = {} + ) { + throwErrorIfNamespaceSpecified(options); + + return await this.client.addToNamespaces(type, id, namespaces, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } + + /** + * Removes namespaces from a SavedObject + * + * @param type + * @param id + * @param namespaces + * @param options + */ + public async deleteFromNamespaces( + type: string, + id: string, + namespaces: string[], + options: SavedObjectsDeleteFromNamespacesOptions = {} + ) { + throwErrorIfNamespaceSpecified(options); + + return await this.client.deleteFromNamespaces(type, id, namespaces, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } + /** * Updates an array of objects by id * diff --git a/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts b/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts index 3863fdaf9da62..ebb72c3ed36d6 100644 --- a/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts +++ b/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts @@ -10,7 +10,7 @@ import { fillPool } from './fill_pool'; import { TaskPoolRunResult } from '../task_pool'; describe('fillPool', () => { - test('stops filling when there are no more tasks in the store', async () => { + test('stops filling when pool runs all claimed tasks, even if there is more capacity', async () => { const tasks = [ [1, 2, 3], [4, 5], @@ -22,7 +22,7 @@ describe('fillPool', () => { await fillPool(fetchAvailableTasks, converter, run); - expect(_.flattenDeep(run.args)).toEqual([1, 2, 3, 4, 5]); + expect(_.flattenDeep(run.args)).toEqual([1, 2, 3]); }); test('stops filling when the pool has no more capacity', async () => { diff --git a/x-pack/plugins/task_manager/server/lib/fill_pool.ts b/x-pack/plugins/task_manager/server/lib/fill_pool.ts index 60470b22c00a9..9e4894587203d 100644 --- a/x-pack/plugins/task_manager/server/lib/fill_pool.ts +++ b/x-pack/plugins/task_manager/server/lib/fill_pool.ts @@ -5,12 +5,12 @@ */ import { performance } from 'perf_hooks'; -import { after } from 'lodash'; import { TaskPoolRunResult } from '../task_pool'; export enum FillPoolResult { NoTasksClaimed = 'NoTasksClaimed', RanOutOfCapacity = 'RanOutOfCapacity', + PoolFilled = 'PoolFilled', } type BatchRun = (tasks: T[]) => Promise; @@ -35,33 +35,28 @@ export async function fillPool( run: BatchRun ): Promise { performance.mark('fillPool.start'); - const markClaimedTasksOnRerunCycle = after(2, () => - performance.mark('fillPool.claimedOnRerunCycle') - ); - while (true) { - const instances = await fetchAvailableTasks(); + const instances = await fetchAvailableTasks(); - if (!instances.length) { - performance.mark('fillPool.bailNoTasks'); - performance.measure( - 'fillPool.activityDurationUntilNoTasks', - 'fillPool.start', - 'fillPool.bailNoTasks' - ); - return FillPoolResult.NoTasksClaimed; - } - markClaimedTasksOnRerunCycle(); - const tasks = instances.map(converter); + if (!instances.length) { + performance.mark('fillPool.bailNoTasks'); + performance.measure( + 'fillPool.activityDurationUntilNoTasks', + 'fillPool.start', + 'fillPool.bailNoTasks' + ); + return FillPoolResult.NoTasksClaimed; + } + const tasks = instances.map(converter); - if ((await run(tasks)) === TaskPoolRunResult.RanOutOfCapacity) { - performance.mark('fillPool.bailExhaustedCapacity'); - performance.measure( - 'fillPool.activityDurationUntilExhaustedCapacity', - 'fillPool.start', - 'fillPool.bailExhaustedCapacity' - ); - return FillPoolResult.RanOutOfCapacity; - } - performance.mark('fillPool.cycle'); + if ((await run(tasks)) === TaskPoolRunResult.RanOutOfCapacity) { + performance.mark('fillPool.bailExhaustedCapacity'); + performance.measure( + 'fillPool.activityDurationUntilExhaustedCapacity', + 'fillPool.start', + 'fillPool.bailExhaustedCapacity' + ); + return FillPoolResult.RanOutOfCapacity; } + performance.mark('fillPool.cycle'); + return FillPoolResult.PoolFilled; } diff --git a/x-pack/plugins/task_manager/server/lib/is_task_not_found_error.test.ts b/x-pack/plugins/task_manager/server/lib/is_task_not_found_error.test.ts new file mode 100644 index 0000000000000..65922ea8e6de7 --- /dev/null +++ b/x-pack/plugins/task_manager/server/lib/is_task_not_found_error.test.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 { isTaskSavedObjectNotFoundError } from './is_task_not_found_error'; +import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; +import uuid from 'uuid'; + +describe('isTaskSavedObjectNotFoundError', () => { + test('identifies SavedObjects Not Found errors', () => { + const id = uuid.v4(); + // ensure the error created by SO parses as a string with the format we expect + expect( + `${SavedObjectsErrorHelpers.createGenericNotFoundError('task', id)}`.includes(`task/${id}`) + ).toBe(true); + + const errorBySavedObjectsHelper = SavedObjectsErrorHelpers.createGenericNotFoundError( + 'task', + id + ); + + expect(isTaskSavedObjectNotFoundError(errorBySavedObjectsHelper, id)).toBe(true); + }); + + test('identifies generic errors', () => { + const id = uuid.v4(); + expect(isTaskSavedObjectNotFoundError(new Error(`not found`), id)).toBe(false); + }); +}); diff --git a/x-pack/plugins/task_manager/server/lib/is_task_not_found_error.ts b/x-pack/plugins/task_manager/server/lib/is_task_not_found_error.ts new file mode 100644 index 0000000000000..8cc1c08f2a967 --- /dev/null +++ b/x-pack/plugins/task_manager/server/lib/is_task_not_found_error.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 { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; + +export function isTaskSavedObjectNotFoundError(err: Error, taskId: string) { + return SavedObjectsErrorHelpers.isNotFoundError(err) && `${err}`.includes(taskId); +} diff --git a/x-pack/plugins/task_manager/server/plugin.ts b/x-pack/plugins/task_manager/server/plugin.ts index fdfe0c068afcf..e837fcd9c0dec 100644 --- a/x-pack/plugins/task_manager/server/plugin.ts +++ b/x-pack/plugins/task_manager/server/plugin.ts @@ -38,17 +38,16 @@ export class TaskManagerPlugin public setup(core: CoreSetup, plugins: any): TaskManagerSetupContract { const logger = this.initContext.logger.get('taskManager'); const config$ = this.initContext.config.create(); - const elasticsearch = core.elasticsearch.adminClient; return { registerLegacyAPI: once((__LEGACY: PluginLegacyDependencies) => { config$.subscribe(async config => { - const [{ savedObjects }] = await core.getStartServices(); + const [{ savedObjects, elasticsearch }] = await core.getStartServices(); const savedObjectsRepository = savedObjects.createInternalRepository(['task']); this.legacyTaskManager$.next( createTaskManager(core, { logger, config, - elasticsearch, + elasticsearch: elasticsearch.legacy.client, savedObjectsRepository, savedObjectsSerializer: savedObjects.createSerializer(), }) diff --git a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts index b0d9dc61c9667..8f7cc47f936b2 100644 --- a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts +++ b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts @@ -11,7 +11,7 @@ import { RangeFilter, mustBeAllOf, MustCondition, - MustNotCondition, + BoolClauseWithAnyCondition, } from './query_clauses'; export const TaskWithSchedule: ExistsFilter = { @@ -54,15 +54,16 @@ export const IdleTaskWithExpiredRunAt: MustCondition = }, }; -export const InactiveTasks: MustNotCondition = { +// TODO: Fix query clauses to support this +export const InactiveTasks: BoolClauseWithAnyCondition = { bool: { must_not: [ { bool: { should: [{ term: { 'task.status': 'running' } }, { term: { 'task.status': 'claiming' } }], + must: { range: { 'task.retryAt': { gt: 'now' } } }, }, }, - { range: { 'task.retryAt': { gt: 'now' } } }, ], }, }; diff --git a/x-pack/plugins/task_manager/server/task_manager.ts b/x-pack/plugins/task_manager/server/task_manager.ts index c3f24a4aae88a..a7c67d190e72e 100644 --- a/x-pack/plugins/task_manager/server/task_manager.ts +++ b/x-pack/plugins/task_manager/server/task_manager.ts @@ -9,7 +9,7 @@ import { filter } from 'rxjs/operators'; import { performance } from 'perf_hooks'; import { pipe } from 'fp-ts/lib/pipeable'; -import { Option, none, some, map as mapOptional } from 'fp-ts/lib/Option'; +import { Option, some, map as mapOptional } from 'fp-ts/lib/Option'; import { SavedObjectsSerializer, IScopedClusterClient, @@ -156,8 +156,8 @@ export class TaskManager { this.events$.next(event); }; - private attemptToRun(task: Option = none) { - this.claimRequests$.next(task); + private attemptToRun(task: string) { + this.claimRequests$.next(some(task)); } private createTaskRunnerForTask = (instance: ConcreteTaskInstance) => { @@ -280,9 +280,7 @@ export class TaskManager { ...options, taskInstance: ensureDeprecatedFieldsAreCorrected(taskInstance, this.logger), }); - const result = await this.store.schedule(modifiedTask); - this.attemptToRun(); - return result; + return await this.store.schedule(modifiedTask); } /** @@ -298,7 +296,7 @@ export class TaskManager { .then(resolve) .catch(reject); - this.attemptToRun(some(taskId)); + this.attemptToRun(taskId); }); } diff --git a/x-pack/plugins/task_manager/server/task_pool.test.ts b/x-pack/plugins/task_manager/server/task_pool.test.ts index a827c1436c9bd..fb87b6290a3da 100644 --- a/x-pack/plugins/task_manager/server/task_pool.test.ts +++ b/x-pack/plugins/task_manager/server/task_pool.test.ts @@ -8,6 +8,7 @@ import sinon from 'sinon'; import { TaskPool, TaskPoolRunResult } from './task_pool'; import { mockLogger, resolvable, sleep } from './test_utils'; import { asOk } from './lib/result_type'; +import { SavedObjectsErrorHelpers } from '../../../../src/core/server'; describe('TaskPool', () => { test('occupiedWorkers are a sum of running tasks', async () => { @@ -101,6 +102,30 @@ describe('TaskPool', () => { expect(result).toEqual(TaskPoolRunResult.RunningAllClaimedTasks); }); + test('should not log when running a Task fails due to the Task SO having been deleted while in flight', async () => { + const logger = mockLogger(); + const pool = new TaskPool({ + maxWorkers: 3, + logger, + }); + + const taskFailedToRun = mockTask(); + taskFailedToRun.run.mockImplementation(async () => { + throw SavedObjectsErrorHelpers.createGenericNotFoundError('task', taskFailedToRun.id); + }); + + const result = await pool.run([mockTask(), taskFailedToRun, mockTask()]); + + expect(logger.debug.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Task TaskType \\"shooooo\\" failed in attempt to run: Saved object [task/foo] not found", + ] + `); + expect(logger.warn).not.toHaveBeenCalled(); + + expect(result).toEqual(TaskPoolRunResult.RunningAllClaimedTasks); + }); + test('Running a task which fails still takes up capacity', async () => { const logger = mockLogger(); const pool = new TaskPool({ diff --git a/x-pack/plugins/task_manager/server/task_pool.ts b/x-pack/plugins/task_manager/server/task_pool.ts index 90f1880a159da..8999fb48680ce 100644 --- a/x-pack/plugins/task_manager/server/task_pool.ts +++ b/x-pack/plugins/task_manager/server/task_pool.ts @@ -11,6 +11,7 @@ import { performance } from 'perf_hooks'; import { Logger } from './types'; import { TaskRunner } from './task_runner'; +import { isTaskSavedObjectNotFoundError } from './lib/is_task_not_found_error'; interface Opts { maxWorkers: number; @@ -125,7 +126,17 @@ export class TaskPool { taskRunner .run() .catch(err => { - this.logger.warn(`Task ${taskRunner.toString()} failed in attempt to run: ${err.message}`); + // If a task Saved Object can't be found by an in flight task runner + // we asssume the underlying task has been deleted while it was running + // so we will log this as a debug, rather than a warn + const errorLogLine = `Task ${taskRunner.toString()} failed in attempt to run: ${ + err.message + }`; + if (isTaskSavedObjectNotFoundError(err, taskRunner.id)) { + this.logger.debug(errorLogLine); + } else { + this.logger.warn(errorLogLine); + } }) .then(() => this.running.delete(taskRunner)); } 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 97794311fb3d2..4ecefcb7984eb 100644 --- a/x-pack/plugins/task_manager/server/task_store.test.ts +++ b/x-pack/plugins/task_manager/server/task_store.test.ts @@ -407,9 +407,9 @@ describe('TaskStore', () => { { term: { 'task.status': 'running' } }, { term: { 'task.status': 'claiming' } }, ], + must: { range: { 'task.retryAt': { gt: 'now' } } }, }, }, - { range: { 'task.retryAt': { gt: 'now' } } }, ], }, }, @@ -553,9 +553,9 @@ describe('TaskStore', () => { { term: { 'task.status': 'running' } }, { term: { 'task.status': 'claiming' } }, ], + must: { range: { 'task.retryAt': { gt: 'now' } } }, }, }, - { range: { 'task.retryAt': { gt: 'now' } } }, ], }, }, diff --git a/x-pack/plugins/transform/public/app/hooks/use_x_json_mode.ts b/x-pack/plugins/transform/public/app/hooks/use_x_json_mode.ts deleted file mode 100644 index 1017ce198ff29..0000000000000 --- a/x-pack/plugins/transform/public/app/hooks/use_x_json_mode.ts +++ /dev/null @@ -1,20 +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 { useState } from 'react'; -import { collapseLiteralStrings, expandLiteralStrings, XJsonMode } from '../../shared_imports'; - -export const xJsonMode = new XJsonMode(); - -export const useXJsonMode = (json: string) => { - const [xJson, setXJson] = useState(expandLiteralStrings(json)); - - return { - xJson, - setXJson, - xJsonMode, - convertToJson: collapseLiteralStrings, - }; -}; 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 5e0eb7ee08361..320e405b5d437 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 @@ -33,11 +33,12 @@ import { QueryStringInput, } from '../../../../../../../../../src/plugins/data/public'; +import { useXJsonMode } from '../../../../../../../../../src/plugins/es_ui_shared/static/ace_x_json/hooks'; + import { PivotPreview } from '../../../../components/pivot_preview'; import { useDocumentationLinks } from '../../../../hooks/use_documentation_links'; import { SavedSearchQuery, SearchItems } from '../../../../hooks/use_search_items'; -import { useXJsonMode, xJsonMode } from '../../../../hooks/use_x_json_mode'; import { useToastNotifications } from '../../../../app_dependencies'; import { TransformPivotConfig } from '../../../../common'; import { dictionaryToArray, Dictionary } from '../../../../../../common/types/common'; @@ -432,6 +433,7 @@ export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange, convertToJson, setXJson: setAdvancedEditorConfig, xJson: advancedEditorConfig, + xJsonMode, } = useXJsonMode(stringifiedPivotConfig); useEffect(() => { diff --git a/x-pack/plugins/transform/public/shared_imports.ts b/x-pack/plugins/transform/public/shared_imports.ts index 8eb42ad677c0f..494b6db6aafe0 100644 --- a/x-pack/plugins/transform/public/shared_imports.ts +++ b/x-pack/plugins/transform/public/shared_imports.ts @@ -5,11 +5,11 @@ */ export { createSavedSearchesLoader } from '../../../../src/plugins/discover/public'; -export { XJsonMode } from '../../es_ui_shared/console_lang/ace/modes/x_json'; export { + XJsonMode, collapseLiteralStrings, expandLiteralStrings, -} from '../../../../src/plugins/es_ui_shared/console_lang/lib'; +} from '../../../../src/plugins/es_ui_shared/public'; export { UseRequestConfig, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 687834a683c4d..72b55121bbec0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -130,6 +130,14 @@ "charts.colormaps.greysText": "グレー", "charts.colormaps.redsText": "赤", "charts.colormaps.yellowToRedText": "黄色から赤", + "charts.controls.colorRanges.errorText": "各範囲は前の範囲よりも大きくなければなりません。", + "charts.controls.colorSchema.colorSchemaLabel": "配色", + "charts.controls.colorSchema.howToChangeColorsDescription": "それぞれの色は凡例で変更できます。", + "charts.controls.colorSchema.resetColorsButtonLabel": "色をリセット", + "charts.controls.colorSchema.reverseColorSchemaLabel": "図表を反転", + "charts.controls.rangeErrorMessage": "値は {min} と {max} の間でなければなりません", + "charts.controls.vislibBasicOptions.legendPositionLabel": "凡例位置", + "charts.controls.vislibBasicOptions.showTooltipLabel": "ツールヒントを表示", "common.ui.errorAutoCreateIndex.breadcrumbs.errorText": "エラー", "common.ui.errorAutoCreateIndex.errorDescription": "Elasticsearch クラスターの {autoCreateIndexActionConfig} 設定が原因で、Kibana が保存されたオブジェクトを格納するインデックスを自動的に作成できないようです。Kibana は、保存されたオブジェクトインデックスが適切なマッピング/スキーマを使用し Kibana から Elasticsearch へのポーリングの回数を減らすための最適な手段であるため、この Elasticsearch の機能を使用します。", "common.ui.errorAutoCreateIndex.errorDisclaimer": "申し訳ございませんが、この問題が解決されるまで Kibana で何も保存することができません。", @@ -311,8 +319,6 @@ "common.ui.stateManagement.unableToStoreHistoryInSessionErrorMessage": "セッションがいっぱいで安全に削除できるアイテムが見つからないため、Kibana は履歴アイテムを保存できません。\n\nこれは大抵新規タブに移動することで解決されますが、より大きな問題が原因である可能性もあります。このメッセージが定期的に表示される場合は、{gitHubIssuesUrl} で問題を報告してください。", "common.ui.url.replacementFailedErrorMessage": "置換に失敗、未解決の表現式: {expr}", "common.ui.url.savedObjectIsMissingNotificationMessage": "保存されたオブジェクトがありません", - "common.ui.vis.kibanaMap.leaflet.fitDataBoundsAriaLabel": "データバウンドを合わせる", - "common.ui.vis.kibanaMap.zoomWarning": "ズームレベルが最大に達しました。完全にズームインするには、Elasticsearch と Kibana の {defaultDistribution} にアップグレードしてください。{ems} でより多くのズームレベルが利用できます。または、独自のマップサーバーを構成できます。詳細は { wms } または { configSettings} をご覧ください。", "console.autocomplete.addMethodMetaText": "メソド", "console.consoleDisplayName": "コンソール", "console.consoleMenu.copyAsCurlMessage": "リクエストが URL としてコピーされました", @@ -792,8 +798,6 @@ "data.search.searchBar.savedQueryDescriptionLabelText": "説明", "data.search.searchBar.savedQueryDescriptionText": "再度使用するクエリテキストとフィルターを保存します。", "data.search.searchBar.savedQueryForm.titleConflictText": "タイトルが既に保存されているクエリに使用されています", - "data.search.searchBar.savedQueryForm.titleMissingText": "名前が必要です", - "data.search.searchBar.savedQueryForm.whitespaceErrorText": "タイトルの始めと終わりにはスペースを使用できません", "data.search.searchBar.savedQueryFormCancelButtonText": "キャンセル", "data.search.searchBar.savedQueryFormSaveButtonText": "保存", "data.search.searchBar.savedQueryFormTitle": "クエリを保存", @@ -2000,10 +2004,8 @@ "kbn.context.reloadPageDescription.selectValidAnchorDocumentTextMessage": "にアクセスして有効な別のドキュメントを選択してください。", "kbn.context.unableToLoadAnchorDocumentDescription": "別のドキュメントが読み込めません", "kbn.context.unableToLoadDocumentDescription": "ドキュメントが読み込めません", - "kbn.dashboard.listing.table.descriptionColumnName": "説明", "kbn.dashboard.panel.unableToMigratePanelDataForSixOneZeroErrorMessage": "「6.1.0」のダッシュボードの互換性のため、パネルデータを移行できませんでした。パネルには想定された列または行フィールドがありません", "kbn.dashboard.panel.unableToMigratePanelDataForSixThreeZeroErrorMessage": "「6.3.0」のダッシュボードの互換性のため、パネルデータを移行できませんでした。パネルに必要なフィールドがありません: {key}", - "kbn.dashboard.savedDashboardsTitle": "ダッシュボード", "kbn.dashboardTitle": "ダッシュボード", "kbn.devToolsTitle": "開発ツール", "kbn.discover.backToTopLinkText": "最上部へ戻る。", @@ -2273,9 +2275,6 @@ "kbn.management.editIndexPattern.timeFilterLabel.mappingAPILink": "マッピング API", "kbn.management.editIndexPattern.timeFilterLabel.timeFilterDetail": "このページは {indexPatternTitle} インデックス内のすべてのフィールドと、Elasticsearch に記録された各フィールドのコアタイプを一覧表示します。フィールドタイプを変更するには Elasticsearch を使用します", "kbn.management.editIndexPatternLiveRegionAriaLabel": "インデックスパターン", - "kbn.management.indexPattern.confirmOverwriteButton": "上書き", - "kbn.management.indexPattern.confirmOverwriteLabel": "「{title}」に上書きしてよろしいですか?", - "kbn.management.indexPattern.confirmOverwriteTitle": "{type} を上書きしますか?", "kbn.management.indexPattern.goToPatternButtonLabel": "既存のパターンに移動", "kbn.management.indexPattern.sectionsHeader": "インデックスパターン", "kbn.management.indexPattern.titleExistsLabel": "「{title}」というタイトルのインデックスパターンが既に存在します。", @@ -2298,167 +2297,129 @@ "kbn.management.indexPatternTable.title": "インデックスパターン", "kbn.management.landing.header": "Kibana {version} 管理", "kbn.management.landing.subhead": "インデックス、インデックスパターン、保存されたオブジェクト、Kibana の設定、その他を管理します。", - "kbn.management.landing.text": "アプリの一覧は左側のメニューにあります。", - "kbn.management.objects.confirmModalOptions.deleteButtonLabel": "削除", - "kbn.management.objects.confirmModalOptions.modalDescription": "このアクションはオブジェクトを Kibana から永久に削除します。", - "kbn.management.objects.confirmModalOptions.modalTitle": "「{title}」を削除しますか?", - "kbn.management.objects.deleteSavedObjectsConfirmModalDescription": "この操作は次の保存されたオブジェクトを削除します:", - "kbn.management.objects.field.offLabel": "オフ", - "kbn.management.objects.field.onLabel": "オン", - "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.cancelButtonLabel": "キャンセル", - "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.deleteButtonLabel": "削除", - "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.deleteProcessButtonLabel": "削除中…", - "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.idColumnName": "ID", - "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName": "タイトル", - "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.typeColumnName": "タイプ", - "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModalTitle": "保存されたオブジェクトの削除", - "kbn.management.objects.objectsTable.export.dangerNotification": "エクスポートを生成できません", - "kbn.management.objects.objectsTable.export.successNotification": "ファイルはバックグラウンドでダウンロード中です", - "kbn.management.objects.objectsTable.export.successWithMissingRefsNotification": "ファイルはバックグラウンドでダウンロード中です。一部の関連オブジェクトが見つかりませんでした。足りないオブジェクトの一覧は、エクスポートされたファイルの最後の行をご覧ください。", - "kbn.management.objects.objectsTable.exportObjectsConfirmModal.cancelButtonLabel": "キャンセル", - "kbn.management.objects.objectsTable.exportObjectsConfirmModal.exportAllButtonLabel": "すべてエクスポート:", - "kbn.management.objects.objectsTable.exportObjectsConfirmModal.exportOptionsLabel": "オプション", - "kbn.management.objects.objectsTable.exportObjectsConfirmModal.includeReferencesDeepLabel": "関連オブジェクトを含める", - "kbn.management.objects.objectsTable.exportObjectsConfirmModalDescription": "エクスポートするタイプを選択してください", - "kbn.management.objects.objectsTable.exportObjectsConfirmModalTitle": "{filteredItemCount, plural, one{# オブジェクト} other {# オブジェクト}}をエクスポート", - "kbn.management.objects.objectsTable.flyout.confirmLegacyImport.resolvingConflictsLoadingMessage": "矛盾を解決中…", - "kbn.management.objects.objectsTable.flyout.confirmLegacyImport.retryingFailedObjectsLoadingMessage": "失敗したオブジェクトを再試行中…", - "kbn.management.objects.objectsTable.flyout.confirmLegacyImport.savedSearchAreLinkedProperlyLoadingMessage": "保存された検索が正しくリンクされていることを確認してください…", - "kbn.management.objects.objectsTable.flyout.confirmLegacyImport.savingConflictsLoadingMessage": "矛盾を保存中…", - "kbn.management.objects.objectsTable.flyout.confirmOverwriteBody": "{title} を上書きしてよろしいですか?", - "kbn.management.objects.objectsTable.flyout.confirmOverwriteCancelButtonText": "キャンセル", - "kbn.management.objects.objectsTable.flyout.confirmOverwriteOverwriteButtonText": "上書き", - "kbn.management.objects.objectsTable.flyout.confirmOverwriteTitle": "{type} を上書きしますか?", - "kbn.management.objects.objectsTable.flyout.errorCalloutTitle": "申し訳ございませんが、エラーが発生しました", - "kbn.management.objects.objectsTable.flyout.import.cancelButtonLabel": "キャンセル", - "kbn.management.objects.objectsTable.flyout.import.confirmButtonLabel": "インポート", - "kbn.management.objects.objectsTable.flyout.importFailedDescription": "{totalImportCount} 個中 {failedImportCount} 個のオブジェクトのインポートに失敗しました。インポート失敗", - "kbn.management.objects.objectsTable.flyout.importFailedMissingReference": "{type} [id={id}] は {refType} [id={refId}] を見つけられませんでした", - "kbn.management.objects.objectsTable.flyout.importFailedTitle": "インポート失敗", - "kbn.management.objects.objectsTable.flyout.importFailedUnsupportedType": "{type} [id={id}] サポートされていないタイプ", - "kbn.management.objects.objectsTable.flyout.importFileErrorMessage": "ファイルを処理できませんでした。", - "kbn.management.objects.objectsTable.flyout.importLegacyFileErrorMessage": "ファイルを処理できませんでした。", - "kbn.management.objects.objectsTable.flyout.importPromptText": "インポート", - "kbn.management.objects.objectsTable.flyout.importSavedObjectTitle": "保存されたオブジェクトのインポート", - "kbn.management.objects.objectsTable.flyout.importSuccessful.confirmAllChangesButtonLabel": "すべての変更を確定", - "kbn.management.objects.objectsTable.flyout.importSuccessful.confirmButtonLabel": "完了", - "kbn.management.objects.objectsTable.flyout.importSuccessfulCallout.noObjectsImportedTitle": "オブジェクトがインポートされませんでした", - "kbn.management.objects.objectsTable.flyout.importSuccessfulDescription": "{importCount} 個のオブジェクトがインポートされました。", - "kbn.management.objects.objectsTable.flyout.importSuccessfulTitle": "インポート成功", - "kbn.management.objects.objectsTable.flyout.indexPatternConflictsCalloutLinkText": "新規インデックスパターンを作成", - "kbn.management.objects.objectsTable.flyout.indexPatternConflictsDescription": "次の保存されたオブジェクトは、存在しないインデックスパターンを使用しています。別のデックスパターンを選択してください。必要に応じて {indexPatternLink} できます。", - "kbn.management.objects.objectsTable.flyout.indexPatternConflictsTitle": "インデックスパターンの矛盾", - "kbn.management.objects.objectsTable.flyout.invalidFormatOfImportedFileErrorMessage": "保存されたオブジェクトのファイル形式が無効なため、インポートできません。", - "kbn.management.objects.objectsTable.flyout.legacyFileUsedBody": "最新のレポートで NDJSON ファイルを作成すれば完了です。", - "kbn.management.objects.objectsTable.flyout.legacyFileUsedTitle": "JSON ファイルのサポートが終了します", - "kbn.management.objects.objectsTable.flyout.overwriteSavedObjectsLabel": "すべての保存されたオブジェクトを自動的に上書きしますか?", - "kbn.management.objects.objectsTable.flyout.renderConflicts.columnCountDescription": "影響されるオブジェクトの数です", - "kbn.management.objects.objectsTable.flyout.renderConflicts.columnCountName": "カウント", - "kbn.management.objects.objectsTable.flyout.renderConflicts.columnIdDescription": "インデックスパターンの ID です", - "kbn.management.objects.objectsTable.flyout.renderConflicts.columnIdName": "ID", - "kbn.management.objects.objectsTable.flyout.renderConflicts.columnNewIndexPatternName": "新規インデックスパターン", - "kbn.management.objects.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsDescription": "影響されるオブジェクトのサンプルです", - "kbn.management.objects.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsName": "影響されるオブジェクトのサンプル", - "kbn.management.objects.objectsTable.flyout.resolveImportErrorsFileErrorMessage": "ファイルを処理できませんでした。", - "kbn.management.objects.objectsTable.flyout.selectFileToImportFormRowLabel": "インポートするファイルを選択してください", - "kbn.management.objects.objectsTable.header.exportButtonLabel": "{filteredCount, plural, one{# オブジェクト} other {# オブジェクト}}をエクスポート", - "kbn.management.objects.objectsTable.header.importButtonLabel": "インポート", - "kbn.management.objects.objectsTable.header.refreshButtonLabel": "更新", - "kbn.management.objects.objectsTable.header.savedObjectsTitle": "保存されたオブジェクト", - "kbn.management.objects.objectsTable.howToDeleteSavedObjectsDescription": "ここから保存された検索などの保存されたオブジェクトを削除できます。保存されたオブジェクトの生データを編集することもできます。通常、オブジェクトは関連アプリケーションでのみ編集され、こn画面で編集するよりもそちらのほうが賢明です。", - "kbn.management.objects.objectsTable.relationships.columnActions.inspectActionDescription": "この保存されたオブジェクトを確認してください", - "kbn.management.objects.objectsTable.relationships.columnActions.inspectActionName": "検査", - "kbn.management.objects.objectsTable.relationships.columnActionsName": "アクション", - "kbn.management.objects.objectsTable.relationships.columnRelationship.childAsValue": "子", - "kbn.management.objects.objectsTable.relationships.columnRelationship.parentAsValue": "ペアレント", - "kbn.management.objects.objectsTable.relationships.columnRelationshipName": "直接関係", - "kbn.management.objects.objectsTable.relationships.columnTitleDescription": "保存されたオブジェクトのタイトルです", - "kbn.management.objects.objectsTable.relationships.columnTitleName": "タイトル", - "kbn.management.objects.objectsTable.relationships.columnTypeDescription": "保存されたオブジェクトのタイプです", - "kbn.management.objects.objectsTable.relationships.columnTypeName": "タイプ", - "kbn.management.objects.objectsTable.relationships.relationshipsTitle": "{title} に関連する保存されたオブジェクトはこちらです。この {type} を削除すると、親オブジェクトに影響がありますが、子オブジェクトには影響はありません。", - "kbn.management.objects.objectsTable.relationships.renderErrorMessage": "エラー", - "kbn.management.objects.objectsTable.relationships.search.filters.relationship.childAsValue.view": "子", - "kbn.management.objects.objectsTable.relationships.search.filters.relationship.name": "直接関係", - "kbn.management.objects.objectsTable.relationships.search.filters.relationship.parentAsValue.view": "親", - "kbn.management.objects.objectsTable.relationships.search.filters.type.name": "タイプ", - "kbn.management.objects.objectsTable.searchBar.unableToParseQueryErrorMessage": "クエリをパースできません", - "kbn.management.objects.objectsTable.table.columnActions.inspectActionDescription": "この保存されたオブジェクトを確認してください", - "kbn.management.objects.objectsTable.table.columnActions.inspectActionName": "検査", - "kbn.management.objects.objectsTable.table.columnActions.viewRelationshipsActionDescription": "この保存されたオブジェクトと他の保存されたオブジェクトとの関係性を表示します", - "kbn.management.objects.objectsTable.table.columnActions.viewRelationshipsActionName": "関係性", - "kbn.management.objects.objectsTable.table.columnActionsName": "アクション", - "kbn.management.objects.objectsTable.table.columnTitleDescription": "保存されたオブジェクトのタイトルです", - "kbn.management.objects.objectsTable.table.columnTitleName": "タイトル", - "kbn.management.objects.objectsTable.table.columnTypeDescription": "保存されたオブジェクトのタイプです", - "kbn.management.objects.objectsTable.table.columnTypeName": "タイプ", - "kbn.management.objects.objectsTable.table.deleteButtonLabel": "削除", - "kbn.management.objects.objectsTable.table.deleteButtonTitle": "保存されたオブジェクトを削除できません", - "kbn.management.objects.objectsTable.table.exportButtonLabel": "エクスポート", - "kbn.management.objects.objectsTable.table.exportPopoverButtonLabel": "エクスポート", - "kbn.management.objects.objectsTable.table.typeFilterName": "タイプ", - "kbn.management.objects.objectsTable.unableFindSavedObjectsNotificationMessage": "保存されたオブジェクトが見つかりません", - "kbn.management.objects.parsingFieldErrorMessage": "{fieldName} をインデックスパターン {indexName} 用にパース中にエラーが発生しました: {errorMessage}", - "kbn.management.objects.savedObjectsSectionLabel": "保存されたオブジェクト", - "kbn.management.objects.view.cancelButtonAriaLabel": "キャンセル", - "kbn.management.objects.view.cancelButtonLabel": "キャンセル", - "kbn.management.objects.view.deleteItemButtonLabel": "{title} を削除", - "kbn.management.objects.view.editItemTitle": "{title} の編集", - "kbn.management.objects.view.fieldDoesNotExistErrorMessage": "このオブジェクトに関連付けられたフィールドは、現在このインデックスパターンに存在しません。", - "kbn.management.objects.view.howToFixErrorDescription": "このエラーの原因がわかる場合は修正してください。わからない場合は上の削除ボタンをクリックしてください。", - "kbn.management.objects.view.howToModifyObjectDescription": "オブジェクトの編集は上級ユーザー向けです。オブジェクトのプロパティが検証されておらず、無効なオブジェクトはエラー、データ損失、またはそれ以上の問題の原因となります。コードを熟知した人に指示されていない限り、この設定は変更しない方が無難です。", - "kbn.management.objects.view.howToModifyObjectTitle": "十分ご注意ください!", - "kbn.management.objects.view.indexPatternDoesNotExistErrorMessage": "このオブジェクトに関連付けられたインデックスパターンは現在存在しません。", - "kbn.management.objects.view.saveButtonAriaLabel": "{ title } オブジェクトを保存", - "kbn.management.objects.view.saveButtonLabel": "{ title } オブジェクトを保存", - "kbn.management.objects.view.savedObjectProblemErrorMessage": "この保存されたオブジェクトに問題があります", - "kbn.management.objects.view.savedSearchDoesNotExistErrorMessage": "このオブジェクトに関連付けられた保存された検索は現在存在しません。", - "kbn.management.objects.view.viewItemButtonLabel": "{title} を表示", - "kbn.management.objects.view.viewItemTitle": "{title} を表示", - "kbn.management.savedObjects.editBreadcrumb": "{savedObjectType} を編集", - "kbn.management.savedObjects.indexBreadcrumb": "保存されたオブジェクト", + "kbn.management.landing.text": "すべてのツールの一覧は、左のメニューにあります。", + "savedObjectsManagement.indexPattern.confirmOverwriteButton": "上書き", + "savedObjectsManagement.indexPattern.confirmOverwriteLabel": "「{title}」に上書きしてよろしいですか?", + "savedObjectsManagement.indexPattern.confirmOverwriteTitle": "{type} を上書きしますか?", + "savedObjectsManagement.deleteConfirm.modalDeleteButtonLabel": "削除", + "savedObjectsManagement.deleteConfirm.modalDescription": "このアクションはオブジェクトを Kibana から永久に削除します。", + "savedObjectsManagement.deleteConfirm.modalTitle": "「{title}」を削除しますか?", + "savedObjectsManagement.deleteSavedObjectsConfirmModalDescription": "この操作は次の保存されたオブジェクトを削除します:", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.cancelButtonLabel": "キャンセル", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.deleteButtonLabel": "削除", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.deleteProcessButtonLabel": "削除中…", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.idColumnName": "ID", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName": "タイトル", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.typeColumnName": "タイプ", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModalTitle": "保存されたオブジェクトの削除", + "savedObjectsManagement.objectsTable.export.dangerNotification": "エクスポートを生成できません", + "savedObjectsManagement.objectsTable.export.successNotification": "ファイルはバックグラウンドでダウンロード中です", + "savedObjectsManagement.objectsTable.export.successWithMissingRefsNotification": "ファイルはバックグラウンドでダウンロード中です。一部の関連オブジェクトが見つかりませんでした。足りないオブジェクトの一覧は、エクスポートされたファイルの最後の行をご覧ください。", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModal.cancelButtonLabel": "キャンセル", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModal.exportAllButtonLabel": "すべてエクスポート:", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModal.exportOptionsLabel": "オプション", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModal.includeReferencesDeepLabel": "関連オブジェクトを含める", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModalDescription": "エクスポートするタイプを選択してください", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModalTitle": "{filteredItemCount, plural, one{# オブジェクト} other {# オブジェクト}}をエクスポート", + "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.resolvingConflictsLoadingMessage": "矛盾を解決中…", + "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.retryingFailedObjectsLoadingMessage": "失敗したオブジェクトを再試行中…", + "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savedSearchAreLinkedProperlyLoadingMessage": "保存された検索が正しくリンクされていることを確認してください…", + "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savingConflictsLoadingMessage": "矛盾を保存中…", + "savedObjectsManagement.objectsTable.flyout.confirmOverwriteBody": "{title} を上書きしてよろしいですか?", + "savedObjectsManagement.objectsTable.flyout.confirmOverwriteCancelButtonText": "キャンセル", + "savedObjectsManagement.objectsTable.flyout.confirmOverwriteOverwriteButtonText": "上書き", + "savedObjectsManagement.objectsTable.flyout.confirmOverwriteTitle": "{type} を上書きしますか?", + "savedObjectsManagement.objectsTable.flyout.errorCalloutTitle": "申し訳ございませんが、エラーが発生しました", + "savedObjectsManagement.objectsTable.flyout.import.cancelButtonLabel": "キャンセル", + "savedObjectsManagement.objectsTable.flyout.import.confirmButtonLabel": "インポート", + "savedObjectsManagement.objectsTable.flyout.importFailedDescription": "{totalImportCount} 個中 {failedImportCount} 個のオブジェクトのインポートに失敗しました。インポート失敗", + "savedObjectsManagement.objectsTable.flyout.importFailedMissingReference": "{type} [id={id}] は {refType} [id={refId}] を見つけられませんでした", + "savedObjectsManagement.objectsTable.flyout.importFailedTitle": "インポート失敗", + "savedObjectsManagement.objectsTable.flyout.importFailedUnsupportedType": "{type} [id={id}] サポートされていないタイプ", + "savedObjectsManagement.objectsTable.flyout.importFileErrorMessage": "ファイルを処理できませんでした。", + "savedObjectsManagement.objectsTable.flyout.importLegacyFileErrorMessage": "ファイルを処理できませんでした。", + "savedObjectsManagement.objectsTable.flyout.importPromptText": "インポート", + "savedObjectsManagement.objectsTable.flyout.importSavedObjectTitle": "保存されたオブジェクトのインポート", + "savedObjectsManagement.objectsTable.flyout.importSuccessful.confirmAllChangesButtonLabel": "すべての変更を確定", + "savedObjectsManagement.objectsTable.flyout.importSuccessful.confirmButtonLabel": "完了", + "savedObjectsManagement.objectsTable.flyout.importSuccessfulCallout.noObjectsImportedTitle": "オブジェクトがインポートされませんでした", + "savedObjectsManagement.objectsTable.flyout.importSuccessfulDescription": "{importCount} 個のオブジェクトがインポートされました。", + "savedObjectsManagement.objectsTable.flyout.importSuccessfulTitle": "インポート成功", + "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsCalloutLinkText": "新規インデックスパターンを作成", + "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsDescription": "次の保存されたオブジェクトは、存在しないインデックスパターンを使用しています。別のデックスパターンを選択してください。必要に応じて {indexPatternLink} できます。", + "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsTitle": "インデックスパターンの矛盾", + "savedObjectsManagement.objectsTable.flyout.invalidFormatOfImportedFileErrorMessage": "保存されたオブジェクトのファイル形式が無効なため、インポートできません。", + "savedObjectsManagement.objectsTable.flyout.legacyFileUsedBody": "最新のレポートで NDJSON ファイルを作成すれば完了です。", + "savedObjectsManagement.objectsTable.flyout.legacyFileUsedTitle": "JSON ファイルのサポートが終了します", + "savedObjectsManagement.objectsTable.flyout.overwriteSavedObjectsLabel": "すべての保存されたオブジェクトを自動的に上書きしますか?", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountDescription": "影響されるオブジェクトの数です", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountName": "カウント", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdDescription": "インデックスパターンの ID です", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdName": "ID", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnNewIndexPatternName": "新規インデックスパターン", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsDescription": "影響されるオブジェクトのサンプルです", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsName": "影響されるオブジェクトのサンプル", + "savedObjectsManagement.objectsTable.flyout.resolveImportErrorsFileErrorMessage": "ファイルを処理できませんでした。", + "savedObjectsManagement.objectsTable.flyout.selectFileToImportFormRowLabel": "インポートするファイルを選択してください", + "savedObjectsManagement.objectsTable.header.exportButtonLabel": "{filteredCount, plural, one{# オブジェクト} other {# オブジェクト}}をエクスポート", + "savedObjectsManagement.objectsTable.header.importButtonLabel": "インポート", + "savedObjectsManagement.objectsTable.header.refreshButtonLabel": "更新", + "savedObjectsManagement.objectsTable.header.savedObjectsTitle": "保存されたオブジェクト", + "savedObjectsManagement.objectsTable.howToDeleteSavedObjectsDescription": "ここから保存された検索などの保存されたオブジェクトを削除できます。保存されたオブジェクトの生データを編集することもできます。通常、オブジェクトは関連アプリケーションでのみ編集され、こn画面で編集するよりもそちらのほうが賢明です。", + "savedObjectsManagement.objectsTable.relationships.columnActions.inspectActionDescription": "この保存されたオブジェクトを確認してください", + "savedObjectsManagement.objectsTable.relationships.columnActions.inspectActionName": "検査", + "savedObjectsManagement.objectsTable.relationships.columnActionsName": "アクション", + "savedObjectsManagement.objectsTable.relationships.columnRelationship.childAsValue": "子", + "savedObjectsManagement.objectsTable.relationships.columnRelationship.parentAsValue": "ペアレント", + "savedObjectsManagement.objectsTable.relationships.columnRelationshipName": "直接関係", + "savedObjectsManagement.objectsTable.relationships.columnTitleDescription": "保存されたオブジェクトのタイトルです", + "savedObjectsManagement.objectsTable.relationships.columnTitleName": "タイトル", + "savedObjectsManagement.objectsTable.relationships.columnTypeDescription": "保存されたオブジェクトのタイプです", + "savedObjectsManagement.objectsTable.relationships.columnTypeName": "タイプ", + "savedObjectsManagement.objectsTable.relationships.relationshipsTitle": "{title} に関連する保存されたオブジェクトはこちらです。この {type} を削除すると、親オブジェクトに影響がありますが、子オブジェクトには影響はありません。", + "savedObjectsManagement.objectsTable.relationships.renderErrorMessage": "エラー", + "savedObjectsManagement.objectsTable.relationships.search.filters.relationship.childAsValue.view": "子", + "savedObjectsManagement.objectsTable.relationships.search.filters.relationship.name": "直接関係", + "savedObjectsManagement.objectsTable.relationships.search.filters.relationship.parentAsValue.view": "親", + "savedObjectsManagement.objectsTable.relationships.search.filters.type.name": "タイプ", + "savedObjectsManagement.objectsTable.searchBar.unableToParseQueryErrorMessage": "クエリをパースできません", + "savedObjectsManagement.objectsTable.table.columnActions.inspectActionDescription": "この保存されたオブジェクトを確認してください", + "savedObjectsManagement.objectsTable.table.columnActions.inspectActionName": "検査", + "savedObjectsManagement.objectsTable.table.columnActions.viewRelationshipsActionDescription": "この保存されたオブジェクトと他の保存されたオブジェクトとの関係性を表示します", + "savedObjectsManagement.objectsTable.table.columnActions.viewRelationshipsActionName": "関係性", + "savedObjectsManagement.objectsTable.table.columnActionsName": "アクション", + "savedObjectsManagement.objectsTable.table.columnTitleDescription": "保存されたオブジェクトのタイトルです", + "savedObjectsManagement.objectsTable.table.columnTitleName": "タイトル", + "savedObjectsManagement.objectsTable.table.columnTypeDescription": "保存されたオブジェクトのタイプです", + "savedObjectsManagement.objectsTable.table.columnTypeName": "タイプ", + "savedObjectsManagement.objectsTable.table.deleteButtonLabel": "削除", + "savedObjectsManagement.objectsTable.table.deleteButtonTitle": "保存されたオブジェクトを削除できません", + "savedObjectsManagement.objectsTable.table.exportButtonLabel": "エクスポート", + "savedObjectsManagement.objectsTable.table.exportPopoverButtonLabel": "エクスポート", + "savedObjectsManagement.objectsTable.table.typeFilterName": "タイプ", + "savedObjectsManagement.objectsTable.unableFindSavedObjectsNotificationMessage": "保存されたオブジェクトが見つかりません", + "savedObjectsManagement.parsingFieldErrorMessage": "{fieldName} をインデックスパターン {indexName} 用にパース中にエラーが発生しました: {errorMessage}", + "savedObjectsManagement.managementSectionLabel": "保存されたオブジェクト", + "savedObjectsManagement.view.cancelButtonAriaLabel": "キャンセル", + "savedObjectsManagement.view.cancelButtonLabel": "キャンセル", + "savedObjectsManagement.view.deleteItemButtonLabel": "{title} を削除", + "savedObjectsManagement.view.editItemTitle": "{title} の編集", + "savedObjectsManagement.view.fieldDoesNotExistErrorMessage": "このオブジェクトに関連付けられたフィールドは、現在このインデックスパターンに存在しません。", + "savedObjectsManagement.view.howToFixErrorDescription": "このエラーの原因がわかる場合は修正してください。わからない場合は上の削除ボタンをクリックしてください。", + "savedObjectsManagement.view.howToModifyObjectDescription": " オブジェクトの編集は上級ユーザー向けです。オブジェクトのプロパティが検証されておらず、無効なオブジェクトはエラー、データ損失、またはそれ以上の問題の原因となります。コードを熟知した人に指示されていない限り、この設定は変更しない方が無難です。", + "savedObjectsManagement.view.howToModifyObjectTitle": "十分ご注意ください!", + "savedObjectsManagement.view.indexPatternDoesNotExistErrorMessage": "このオブジェクトに関連付けられたインデックスパターンは現在存在しません。", + "savedObjectsManagement.view.saveButtonAriaLabel": "{ title } オブジェクトを保存", + "savedObjectsManagement.view.saveButtonLabel": "{ title } オブジェクトを保存", + "savedObjectsManagement.view.savedObjectProblemErrorMessage": "この保存されたオブジェクトに問題があります", + "savedObjectsManagement.view.savedSearchDoesNotExistErrorMessage": "このオブジェクトに関連付けられた保存された検索は現在存在しません。", + "savedObjectsManagement.view.viewItemButtonLabel": "{title} を表示", + "savedObjectsManagement.view.viewItemTitle": "{title} を表示", + "savedObjectsManagement.breadcrumb.edit": "{savedObjectType} を編集", + "savedObjectsManagement.breadcrumb.index": "保存されたオブジェクト", + "savedObjectsManagement.field.offLabel": "オフ", + "savedObjectsManagement.field.onLabel": "オン", "kbn.managementTitle": "管理", - "kbn.topNavMenu.openInspectorButtonLabel": "検査", - "kbn.topNavMenu.refreshButtonLabel": "更新", - "kbn.topNavMenu.saveVisualizationButtonLabel": "保存", - "kbn.topNavMenu.shareVisualizationButtonLabel": "共有", - "kbn.visualize.badge.readOnly.text": "読み込み専用", - "kbn.visualize.badge.readOnly.tooltip": "ビジュアライゼーションを保存できません", - "kbn.visualize.createVisualization.noIndexPatternOrSavedSearchIdErrorMessage": "indexPattern または savedSearchId が必要です", - "kbn.visualize.editor.createBreadcrumb": "作成", - "kbn.visualize.experimentalVisInfoText": "このビジュアライゼーションは実験的なものです。", - "kbn.visualize.helpMenu.appName": "可視化", - "kbn.visualize.linkedToSearch.unlinkSuccessNotificationText": "保存された検索「{searchTitle}」からリンクが解除されました", - "kbn.visualize.listing.betaTitle": "ベータ", - "kbn.visualize.listing.betaTooltip": "このビジュアライゼーションはベータ段階で、変更される可能性があります。デザインとコードはオフィシャル GA 機能よりも完成度が低く、現状のまま保証なしで提供されています。ベータ機能にはオフィシャル GA 機能の SLA が適用されません", - "kbn.visualize.listing.breadcrumb": "可視化", - "kbn.visualize.listing.createNew.createButtonLabel": "新規ビジュアライゼーションを追加", - "kbn.visualize.listing.createNew.description": "データに基づき異なるビジュアライゼーションを作成できます。", - "kbn.visualize.listing.createNew.title": "最初のビジュアライゼーションの作成", - "kbn.visualize.listing.experimentalTitle": "実験的", - "kbn.visualize.listing.experimentalTooltip": "このビジュアライゼーションは今後のリリースで変更または削除される可能性があり、SLA のサポート対象になりません。", - "kbn.visualize.listing.noItemsMessage": "ビジュアライゼーションがないようです。", - "kbn.visualize.listing.table.entityName": "ビジュアライゼーション", - "kbn.visualize.listing.table.entityNamePlural": "ビジュアライゼーション", - "kbn.visualize.listing.table.listTitle": "ビジュアライゼーション", - "kbn.visualize.listing.table.titleColumnName": "タイトル", - "kbn.visualize.listing.table.typeColumnName": "タイプ", - "kbn.visualize.pageHeading": "{chartName} {chartType} ビジュアライゼーション", - "kbn.visualize.saveDialog.saveAndAddToDashboardButtonLabel": "保存してダッシュボードに追加", - "kbn.visualize.topNavMenu.openInspectorButtonAriaLabel": "ビジュアライゼーションのインスペクターを開く", - "kbn.visualize.topNavMenu.openInspectorDisabledButtonTooltip": "このビジュアライゼーションはインスペクターをサポートしていません。", - "kbn.visualize.topNavMenu.refreshButtonAriaLabel": "更新", - "kbn.visualize.topNavMenu.saveVisualization.failureNotificationText": "「{visTitle}」の保存中にエラーが発生しました", - "kbn.visualize.topNavMenu.saveVisualization.successNotificationText": "「{visTitle}」が保存されました", - "kbn.visualize.topNavMenu.saveVisualizationButtonAriaLabel": "ビジュアライゼーションを保存", - "kbn.visualize.topNavMenu.saveVisualizationDisabledButtonTooltip": "保存する前に変更を適用または破棄", - "kbn.visualize.topNavMenu.shareVisualizationButtonAriaLabel": "ビジュアライゼーションを共有", - "kbn.visualize.visualizationTypeInvalidNotificationMessage": "無効なビジュアライゼーションタイプ", - "kbn.visualize.visualizeDescription": "ビジュアライゼーションを作成して Elasticsearch インデックスに保存されたデータを集約します。", - "kbn.visualize.visualizeListingBreadcrumbsTitle": "可視化", - "kbn.visualize.visualizeListingDeleteErrorTitle": "ビジュアライゼーションの削除中にエラーが発生", - "kbn.visualize.wizard.step1Breadcrumb": "作成", - "kbn.visualize.wizard.step2Breadcrumb": "作成", "kbn.visualizeTitle": "可視化", "kibana_legacy.bigUrlWarningNotificationMessage": "{advancedSettingsLink}で{storeInSessionStorageParam}オプションを有効にするか、オンスクリーンビジュアルを簡素化してください。", "kibana_legacy.bigUrlWarningNotificationMessage.advancedSettingsLinkText": "高度な設定", @@ -3831,11 +3792,6 @@ "visTypeVislib.chartTypes.areaText": "エリア", "visTypeVislib.chartTypes.barText": "バー", "visTypeVislib.chartTypes.lineText": "折れ線", - "visTypeVislib.controls.colorRanges.errorText": "各範囲は前の範囲よりも大きくなければなりません。", - "visTypeVislib.controls.colorSchema.colorSchemaLabel": "配色", - "visTypeVislib.controls.colorSchema.howToChangeColorsDescription": "それぞれの色は凡例で変更できます。", - "visTypeVislib.controls.colorSchema.resetColorsButtonLabel": "色をリセット", - "visTypeVislib.controls.colorSchema.reverseColorSchemaLabel": "図表を反転", "visTypeVislib.controls.gaugeOptions.alignmentLabel": "アラインメント", "visTypeVislib.controls.gaugeOptions.autoExtendRangeLabel": "範囲を自動拡張", "visTypeVislib.controls.gaugeOptions.displayWarningsLabel": "警告を表示", @@ -3902,10 +3858,7 @@ "visTypeVislib.controls.pointSeries.valueAxes.toggleCustomExtendsAriaLabel": "カスタム範囲を切り替える", "visTypeVislib.controls.pointSeries.valueAxes.toggleOptionsAriaLabel": "{axisName} オプションを切り替える", "visTypeVislib.controls.pointSeries.valueAxes.yAxisTitle": "Y 軸", - "visTypeVislib.controls.rangeErrorMessage": "値は {min} と {max} の間でなければなりません", "visTypeVislib.controls.truncateLabel": "切り捨て", - "visTypeVislib.controls.vislibBasicOptions.legendPositionLabel": "凡例位置", - "visTypeVislib.controls.vislibBasicOptions.showTooltipLabel": "ツールヒントを表示", "visTypeVislib.editors.heatmap.basicSettingsTitle": "基本設定", "visTypeVislib.editors.heatmap.heatmapSettingsTitle": "ヒートマップ設定", "visTypeVislib.editors.heatmap.highlightLabel": "ハイライト範囲", @@ -4028,6 +3981,46 @@ "visualizations.newVisWizard.visTypeAliasDescription": "Visualize 外で Kibana アプリケーションを開きます。", "visualizations.newVisWizard.visTypeAliasTitle": "Kibana アプリケーション", "visualizations.savedObjectName": "ビジュアライゼーション", + "visualize.listing.table.descriptionColumnName": "説明", + "visualize.topNavMenu.openInspectorButtonLabel": "検査", + "visualize.topNavMenu.saveVisualizationButtonLabel": "保存", + "visualize.topNavMenu.shareVisualizationButtonLabel": "共有", + "visualize.badge.readOnly.text": "読み込み専用", + "visualize.badge.readOnly.tooltip": "ビジュアライゼーションを保存できません", + "visualize.createVisualization.noIndexPatternOrSavedSearchIdErrorMessage": "indexPattern または savedSearchId が必要です", + "visualize.editor.createBreadcrumb": "作成", + "visualize.experimentalVisInfoText": "このビジュアライゼーションは実験的なものです。", + "visualize.helpMenu.appName": "可視化", + "visualize.linkedToSearch.unlinkSuccessNotificationText": "保存された検索「{searchTitle}」からリンクが解除されました", + "visualize.listing.betaTitle": "ベータ", + "visualize.listing.betaTooltip": "このビジュアライゼーションはベータ段階で、変更される可能性があります。デザインとコードはオフィシャル GA 機能よりも完成度が低く、現状のまま保証なしで提供されています。ベータ機能にはオフィシャル GA 機能の SLA が適用されません", + "visualize.listing.breadcrumb": "可視化", + "visualize.listing.createNew.createButtonLabel": "新規ビジュアライゼーションを追加", + "visualize.listing.createNew.description": "データに基づき異なるビジュアライゼーションを作成できます。", + "visualize.listing.createNew.title": "最初のビジュアライゼーションの作成", + "visualize.listing.experimentalTitle": "実験的", + "visualize.listing.experimentalTooltip": "このビジュアライゼーションは今後のリリースで変更または削除される可能性があり、SLA のサポート対象になりません。", + "visualize.listing.noItemsMessage": "ビジュアライゼーションがないようです。", + "visualize.listing.table.entityName": "ビジュアライゼーション", + "visualize.listing.table.entityNamePlural": "ビジュアライゼーション", + "visualize.listing.table.listTitle": "ビジュアライゼーション", + "visualize.listing.table.titleColumnName": "タイトル", + "visualize.listing.table.typeColumnName": "タイプ", + "visualize.pageHeading": "{chartName} {chartType} ビジュアライゼーション", + "visualize.saveDialog.saveAndAddToDashboardButtonLabel": "保存してダッシュボードに追加", + "visualize.topNavMenu.openInspectorButtonAriaLabel": "ビジュアライゼーションのインスペクターを開く", + "visualize.topNavMenu.openInspectorDisabledButtonTooltip": "このビジュアライゼーションはインスペクターをサポートしていません。", + "visualize.topNavMenu.saveVisualization.failureNotificationText": "「{visTitle}」の保存中にエラーが発生しました", + "visualize.topNavMenu.saveVisualization.successNotificationText": "「{visTitle}」が保存されました", + "visualize.topNavMenu.saveVisualizationButtonAriaLabel": "ビジュアライゼーションを保存", + "visualize.topNavMenu.saveVisualizationDisabledButtonTooltip": "保存する前に変更を適用または破棄", + "visualize.topNavMenu.shareVisualizationButtonAriaLabel": "ビジュアライゼーションを共有", + "visualize.visualizationTypeInvalidNotificationMessage": "無効なビジュアライゼーションタイプ", + "visualize.visualizeDescription": "ビジュアライゼーションを作成して Elasticsearch インデックスに保存されたデータを集約します。", + "visualize.visualizeListingBreadcrumbsTitle": "可視化", + "visualize.visualizeListingDeleteErrorTitle": "ビジュアライゼーションの削除中にエラーが発生", + "visualize.wizard.step1Breadcrumb": "作成", + "visualize.wizard.step2Breadcrumb": "作成", "xpack.actions.actionTypeRegistry.get.missingActionTypeErrorMessage": "アクションタイプ \"{id}\" は登録されていません。", "xpack.actions.actionTypeRegistry.register.duplicateActionTypeErrorMessage": "アクションタイプ \"{id}\" は既に登録されています。", "xpack.actions.appName": "アクション", @@ -7459,9 +7452,6 @@ "xpack.indexLifecycleMgmt.activePhaseMessage": "アクティブ", "xpack.indexLifecycleMgmt.addLifecyclePolicyActionButtonLabel": "ライフサイクルポリシーを追加", "xpack.indexLifecycleMgmt.appTitle": "インデックスライフサイクルポリシー", - "xpack.indexLifecycleMgmt.checkLicense.errorExpiredMessage": "{licenseType} ライセンスが期限切れのため {pluginName} を使用できません。", - "xpack.indexLifecycleMgmt.checkLicense.errorUnavailableMessage": "現在ライセンス情報が利用できないため {pluginName} を使用できません。", - "xpack.indexLifecycleMgmt.checkLicense.errorUnsupportedMessage": "ご使用の {licenseType} ライセンスは {pluginName} をサポートしていません。ライセンスをアップグレードしてください。", "xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel": "インデックスを凍結", "xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasLabel": "複製の数", "xpack.indexLifecycleMgmt.coldPhase.replicaCountHelpText": "デフォルトで、複製の数は同じままになります。", @@ -8287,7 +8277,6 @@ "xpack.ingestManager.agentConfigForm.systemMonitoringFieldLabel": "オプション", "xpack.ingestManager.agentConfigForm.systemMonitoringText": "システムメトリックを収集", "xpack.ingestManager.agentConfigForm.systemMonitoringTooltipText": "このオプションを有効にすると、システムメトリックと情報を収集するデータソースで構成をブートストラップできます。", - "xpack.ingestManager.agentConfigInfo.yamlTabName": "YAML", "xpack.ingestManager.agentConfigList.actionsColumnTitle": "アクション", "xpack.ingestManager.agentConfigList.actionsMenuText": "開く", "xpack.ingestManager.agentConfigList.addButton": "エージェント構成を作成", @@ -8425,21 +8414,14 @@ "xpack.ingestManager.createAgentConfig.flyoutTitleDescription": "エージェント構成は、エージェントのグループ全体にわたる設定を管理する目的で使用されます。エージェント構成にデータソースを追加すると、エージェントで収集するデータを指定できます。エージェント構成の編集時には、フリートを使用して、指定したエージェントのグループに更新をデプロイできます。", "xpack.ingestManager.createAgentConfig.submitButtonLabel": "エージェント構成を作成", "xpack.ingestManager.createAgentConfig.successNotificationTitle": "エージェント構成「{name}」を作成しました", - "xpack.ingestManager.createDatasource.addDatasourceButtonText": "データソースに構成を追加", "xpack.ingestManager.createDatasource.agentConfigurationNameLabel": "構成", "xpack.ingestManager.createDatasource.cancelLinkText": "キャンセル", - "xpack.ingestManager.createDatasource.changeConfigLinkText": "構成を変更", - "xpack.ingestManager.createDatasource.changePackageLinkText": "パッケージを変更", - "xpack.ingestManager.createDatasource.continueButtonText": "続行", - "xpack.ingestManager.createDatasource.editDatasourceLinkText": "データソースを編集", "xpack.ingestManager.createDatasource.packageNameLabel": "パッケージ", "xpack.ingestManager.createDatasource.pageTitle": "データソースを作成", "xpack.ingestManager.createDatasource.stepConfigure.advancedOptionsToggleLinkText": "高度なオプション", - "xpack.ingestManager.createDatasource.stepConfigure.chooseDataTitle": "収集したいデータを選択してください", "xpack.ingestManager.createDatasource.stepConfigure.datasourceDescriptionInputLabel": "説明", "xpack.ingestManager.createDatasource.stepConfigure.datasourceNameInputLabel": "データソース名", "xpack.ingestManager.createDatasource.stepConfigure.datasourceNamespaceInputLabel": "名前空間", - "xpack.ingestManager.createDatasource.stepConfigure.defineDatasourceTitle": "データソースを定義する", "xpack.ingestManager.createDatasource.stepConfigure.hideStreamsAriaLabel": "{type} ストリームを隠す", "xpack.ingestManager.createDatasource.stepConfigure.inputSettingsDescription": "次の設定はすべてのストリームに適用されます。", "xpack.ingestManager.createDatasource.stepConfigure.inputSettingsTitle": "設定", @@ -8448,27 +8430,15 @@ "xpack.ingestManager.createDatasource.stepConfigure.showStreamsAriaLabel": "{type} ストリームを表示", "xpack.ingestManager.createDatasource.stepConfigure.streamsEnabledCountText": "{count} / {total, plural, one {# ストリーム} other {# ストリーム}}が有効です", "xpack.ingestManager.createDatasource.stepConfigure.toggleAdvancedOptionsButtonText": "高度なオプション", - "xpack.ingestManager.createDatasource.stepConfigureDatasourceLabel": "構成データソース", - "xpack.ingestManager.createDatasource.stepReview.agentsAffectedCalloutText": "選択されたエージェント構成 {configName} が一部のエージェントで既に使用されていることをフリートが検出しました。このアクションの結果として、フリートはこの構成に登録されているすべてのエージェントを更新します。", - "xpack.ingestManager.createDatasource.stepReview.agentsAffectedCalloutTitle": "このアクションは {count, plural, one {# エージェント} other {# エージェント}}に影響します", - "xpack.ingestManager.createDatasource.stepReview.confirmAgentDisclaimerText": "このアクションによって、この構成に登録されているすべてのエージェントが更新されることを理解しています。", - "xpack.ingestManager.createDatasource.stepReview.confirmAgentDisclaimerTitle": "意思決定を確認", - "xpack.ingestManager.createDatasource.stepReview.reviewTitle": "変更の見直し", - "xpack.ingestManager.createDatasource.stepReviewLabel": "見直し", "xpack.ingestManager.createDatasource.StepSelectConfig.agentConfigAgentsCountText": "{count, plural, one {# エージェント} other {# エージェント}}", - "xpack.ingestManager.createDatasource.StepSelectConfig.createNewConfigButtonText": "新しい構成を作成", "xpack.ingestManager.createDatasource.StepSelectConfig.errorLoadingAgentConfigsTitle": "エージェント構成の読み込みエラー", "xpack.ingestManager.createDatasource.StepSelectConfig.errorLoadingPackageTitle": "パッケージ情報の読み込みエラー", "xpack.ingestManager.createDatasource.StepSelectConfig.errorLoadingSelectedAgentConfigTitle": "選択したエージェント構成の読み込みエラー", "xpack.ingestManager.createDatasource.StepSelectConfig.filterAgentConfigsInputPlaceholder": "エージェント構成の検索", - "xpack.ingestManager.createDatasource.StepSelectConfig.selectAgentConfigTitle": "エージェント構成を選択する", - "xpack.ingestManager.createDatasource.stepSelectConfigLabel": "構成を選択", "xpack.ingestManager.createDatasource.stepSelectPackage.errorLoadingConfigTitle": "エージェント構成情報の読み込みエラー", "xpack.ingestManager.createDatasource.stepSelectPackage.errorLoadingPackagesTitle": "パッケージの読み込みエラー", "xpack.ingestManager.createDatasource.stepSelectPackage.errorLoadingSelectedPackageTitle": "選択したパッケージの読み込みエラー", "xpack.ingestManager.createDatasource.stepSelectPackage.filterPackagesInputPlaceholder": "パッケージの検索", - "xpack.ingestManager.createDatasource.stepSelectPackage.selectPackageTitle": "パッケージを選択する", - "xpack.ingestManager.createDatasource.stepSelectPackageLabel": "パッケージを選択", "xpack.ingestManager.deleteAgentConfigs.confirmModal.affectedAgentsMessage": "{agentsCount, plural, one {# エージェントを} other {# エージェントを}}{agentConfigsCount, plural, one {このエージェント構成に} other {これらのエージェント構成に}}割り当てました。 {agentsCount, plural, one {このエージェント} other {これらのエージェント}}の登録が解除されます。", "xpack.ingestManager.deleteAgentConfigs.confirmModal.cancelButtonLabel": "キャンセル", "xpack.ingestManager.deleteAgentConfigs.confirmModal.confirmAndReassignButtonLabel": "{agentConfigsCount, plural, one {エージェント構成} other {エージェント構成}} and unenroll {agentsCount, plural, one {エージェント} other {エージェント}} を削除", @@ -9605,8 +9575,6 @@ "xpack.ml.dataframe.analytics.regressionExploration.generalError": "データの読み込み中にエラーが発生しました。", "xpack.ml.dataframe.analytics.regressionExploration.generalizationDocsCount": "{docsCount, plural, one {# doc} other {# docs}}が評価されました", "xpack.ml.dataframe.analytics.regressionExploration.generalizationErrorTitle": "一般化エラー", - "xpack.ml.dataframe.analytics.regressionExploration.indexArrayBadgeContent": "配列", - "xpack.ml.dataframe.analytics.regressionExploration.indexArrayToolTipContent": "この配列ベースの列の完全なコンテンツは表示できません。", "xpack.ml.dataframe.analytics.regressionExploration.indexError": "インデックスデータの読み込み中にエラーが発生しました。", "xpack.ml.dataframe.analytics.regressionExploration.isTestingLabel": "テスト", "xpack.ml.dataframe.analytics.regressionExploration.isTrainingLabel": "トレーニング", @@ -12459,7 +12427,6 @@ "xpack.reporting.screenCapturePanelContent.optimizeForPrintingLabel": "印刷用に最適化", "xpack.reporting.selfCheck.ok": "レポートプラグイン自己チェックOK!", "xpack.reporting.selfCheck.warning": "レポートプラグイン自己チェックで警告が発生しました: {err}", - "xpack.reporting.selfCheckEncryptionKey.warning": "{setting}のランダムキーを生成しています。保留中のレポートの再開が失敗しないように、kibana.ymlで{setting}を設定してください", "xpack.reporting.shareContextMenu.csvReportsButtonLabel": "CSV レポート", "xpack.reporting.shareContextMenu.pdfReportsButtonLabel": "PDF レポート", "xpack.reporting.shareContextMenu.pngReportsButtonLabel": "PNG レポート", @@ -16105,7 +16072,6 @@ "xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel": "ユーザー名", "xpack.triggersActionsUI.sections.editConnectorForm.betaBadgeTooltipContent": "{pluginName} はベータ段階で、変更される可能性があります。デザインとコードはオフィシャル GA 機能よりも完成度が低く、現状のまま保証なしで提供されています。ベータ機能にはオフィシャル GA 機能の SLA が適用されません。", "xpack.triggersActionsUI.sections.editConnectorForm.cancelButtonLabel": "キャンセル", - "xpack.triggersActionsUI.sections.editConnectorForm.flyoutTitle": "コネクターを編集", "xpack.triggersActionsUI.sections.editConnectorForm.saveButtonLabel": "保存", "xpack.triggersActionsUI.sections.editConnectorForm.updateErrorNotificationText": "コネクターを更新できません。", "xpack.triggersActionsUI.sections.editConnectorForm.updateSuccessNotificationText": "「{connectorName}」を更新しました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 58905787da8d5..c1b4663da91e7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -130,6 +130,14 @@ "charts.colormaps.greysText": "灰色", "charts.colormaps.redsText": "红色", "charts.colormaps.yellowToRedText": "黄到红", + "charts.controls.colorRanges.errorText": "每个范围应大于前一范围。", + "charts.controls.colorSchema.colorSchemaLabel": "颜色模式", + "charts.controls.colorSchema.howToChangeColorsDescription": "可以更改图例中的各个颜色。", + "charts.controls.colorSchema.resetColorsButtonLabel": "重置颜色", + "charts.controls.colorSchema.reverseColorSchemaLabel": "反转模式", + "charts.controls.rangeErrorMessage": "值必须是在 {min} 到 {max} 的范围内", + "charts.controls.vislibBasicOptions.legendPositionLabel": "图例位置", + "charts.controls.vislibBasicOptions.showTooltipLabel": "显示工具提示", "common.ui.errorAutoCreateIndex.breadcrumbs.errorText": "错误", "common.ui.errorAutoCreateIndex.errorDescription": "似乎 Elasticsearch 集群的 {autoCreateIndexActionConfig} 设置使 Kibana 无法自动创建用于存储已保存对象的索引。Kibana 将使用此 Elasticsearch 功能,因为这是确保已保存对象索引使用正确映射/架构的最好方式,而且其允许 Kibana 较少地轮询 Elasticsearch。", "common.ui.errorAutoCreateIndex.errorDisclaimer": "但是,只有解决了此问题后,您才能在 Kibana 保存内容。", @@ -311,8 +319,6 @@ "common.ui.stateManagement.unableToStoreHistoryInSessionErrorMessage": "Kibana 无法将历史记录项存储在您的会话中,因为其已满,并且似乎没有任何可安全删除的项。\n\n通常可通过移至新的标签页来解决此问题,但这会导致更大的问题。如果您有规律地看到此消息,请在 {gitHubIssuesUrl} 提交问题。", "common.ui.url.replacementFailedErrorMessage": "替换失败,未解析的表达式:{expr}", "common.ui.url.savedObjectIsMissingNotificationMessage": "已保存对象缺失", - "common.ui.vis.kibanaMap.leaflet.fitDataBoundsAriaLabel": "适应数据边界", - "common.ui.vis.kibanaMap.zoomWarning": "已达到缩放级别最大数目。要一直放大,请升级到 Elasticsearch 和 Kibana 的 {defaultDistribution}。您可以通过 {ems} 免费使用其他缩放级别。或者,您可以配置自己的地图服务器。请前往 { wms } 或 { configSettings} 以获取详细信息。", "console.autocomplete.addMethodMetaText": "方法", "console.consoleDisplayName": "控制台", "console.consoleMenu.copyAsCurlMessage": "请求已复制为 cURL", @@ -793,8 +799,6 @@ "data.search.searchBar.savedQueryDescriptionLabelText": "描述", "data.search.searchBar.savedQueryDescriptionText": "保存想要再次使用的查询文本和筛选。", "data.search.searchBar.savedQueryForm.titleConflictText": "标题与现有已保存查询有冲突", - "data.search.searchBar.savedQueryForm.titleMissingText": "“名称”必填", - "data.search.searchBar.savedQueryForm.whitespaceErrorText": "标题不能包含前导或尾随空格", "data.search.searchBar.savedQueryFormCancelButtonText": "取消", "data.search.searchBar.savedQueryFormSaveButtonText": "保存", "data.search.searchBar.savedQueryFormTitle": "保存查询", @@ -2001,10 +2005,8 @@ "kbn.context.reloadPageDescription.selectValidAnchorDocumentTextMessage": "以选择有效地定位点文档。", "kbn.context.unableToLoadAnchorDocumentDescription": "无法加载该定位点文档", "kbn.context.unableToLoadDocumentDescription": "无法加载文档", - "kbn.dashboard.listing.table.descriptionColumnName": "描述", "kbn.dashboard.panel.unableToMigratePanelDataForSixOneZeroErrorMessage": "无法迁移用于“6.1.0”向后兼容的面板数据,面板不包含所需的列和/或行字段", "kbn.dashboard.panel.unableToMigratePanelDataForSixThreeZeroErrorMessage": "无法迁移用于“6.3.0”向后兼容的面板数据,面板不包含预期字段:{key}", - "kbn.dashboard.savedDashboardsTitle": "仪表板", "kbn.dashboardTitle": "仪表板", "kbn.devToolsTitle": "开发工具", "kbn.discover.backToTopLinkText": "返至顶部。", @@ -2274,9 +2276,6 @@ "kbn.management.editIndexPattern.timeFilterLabel.mappingAPILink": "映射 API", "kbn.management.editIndexPattern.timeFilterLabel.timeFilterDetail": "此页根据 Elasticsearch 的记录列出“{indexPatternTitle}”索引中的每个字段以及字段的关联核心类型。要更改字段类型,请使用 Elasticsearch", "kbn.management.editIndexPatternLiveRegionAriaLabel": "索引模式", - "kbn.management.indexPattern.confirmOverwriteButton": "覆盖", - "kbn.management.indexPattern.confirmOverwriteLabel": "确定要覆盖 “{title}”?", - "kbn.management.indexPattern.confirmOverwriteTitle": "覆盖“{type}”?", "kbn.management.indexPattern.goToPatternButtonLabel": "前往现有模式", "kbn.management.indexPattern.sectionsHeader": "索引模式", "kbn.management.indexPattern.titleExistsLabel": "具有标题 “{title}” 的索引模式已存在。", @@ -2300,166 +2299,128 @@ "kbn.management.landing.header": "Kibana {version} 管理", "kbn.management.landing.subhead": "管理您的索引、索引模式、已保存对象、Kibana 设置等等。", "kbn.management.landing.text": "应用的完整列表位于左侧菜单中。", - "kbn.management.objects.confirmModalOptions.deleteButtonLabel": "删除", - "kbn.management.objects.confirmModalOptions.modalDescription": "此操作会将对象从 Kibana 永久移除。", - "kbn.management.objects.confirmModalOptions.modalTitle": "删除“{title}”?", - "kbn.management.objects.deleteSavedObjectsConfirmModalDescription": "此操作将删除以下已保存对象:", - "kbn.management.objects.field.offLabel": "关闭", - "kbn.management.objects.field.onLabel": "开启", - "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.cancelButtonLabel": "取消", - "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.deleteButtonLabel": "删除", - "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.deleteProcessButtonLabel": "正在删除……", - "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.idColumnName": "ID", - "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName": "标题", - "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.typeColumnName": "类型", - "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModalTitle": "删除已保存对象", - "kbn.management.objects.objectsTable.export.dangerNotification": "无法生成报告", - "kbn.management.objects.objectsTable.export.successNotification": "您的文件正在后台下载", - "kbn.management.objects.objectsTable.export.successWithMissingRefsNotification": "您的文件正在后台下载。找不到某些相关对象。有关缺失对象列表,请查看导出文件的最后一行。", - "kbn.management.objects.objectsTable.exportObjectsConfirmModal.cancelButtonLabel": "取消", - "kbn.management.objects.objectsTable.exportObjectsConfirmModal.exportAllButtonLabel": "全部导出", - "kbn.management.objects.objectsTable.exportObjectsConfirmModal.exportOptionsLabel": "选项", - "kbn.management.objects.objectsTable.exportObjectsConfirmModal.includeReferencesDeepLabel": "包括相关对象", - "kbn.management.objects.objectsTable.exportObjectsConfirmModalDescription": "选择要导出的类型。", - "kbn.management.objects.objectsTable.exportObjectsConfirmModalTitle": "导出 {filteredItemCount, plural, one{# 个对象} other {# 个对象}}", - "kbn.management.objects.objectsTable.flyout.confirmLegacyImport.resolvingConflictsLoadingMessage": "正在解决冲突……", - "kbn.management.objects.objectsTable.flyout.confirmLegacyImport.retryingFailedObjectsLoadingMessage": "正在重试失败的对象……", - "kbn.management.objects.objectsTable.flyout.confirmLegacyImport.savedSearchAreLinkedProperlyLoadingMessage": "确保已保存搜索已正确链接……", - "kbn.management.objects.objectsTable.flyout.confirmLegacyImport.savingConflictsLoadingMessage": "正在保存冲突……", - "kbn.management.objects.objectsTable.flyout.confirmOverwriteBody": "确定要覆盖 “{title}”?", - "kbn.management.objects.objectsTable.flyout.confirmOverwriteCancelButtonText": "取消", - "kbn.management.objects.objectsTable.flyout.confirmOverwriteOverwriteButtonText": "覆盖", - "kbn.management.objects.objectsTable.flyout.confirmOverwriteTitle": "覆盖“{type}”?", - "kbn.management.objects.objectsTable.flyout.errorCalloutTitle": "抱歉,出现了错误", - "kbn.management.objects.objectsTable.flyout.import.cancelButtonLabel": "取消", - "kbn.management.objects.objectsTable.flyout.import.confirmButtonLabel": "导入", - "kbn.management.objects.objectsTable.flyout.importFailedDescription": "无法导入 {failedImportCount} 个对象,共 {totalImportCount} 个。导入失败", - "kbn.management.objects.objectsTable.flyout.importFailedMissingReference": "{type} [id={id}] 无法找到 {refType} [id={refId}]", - "kbn.management.objects.objectsTable.flyout.importFailedTitle": "导入失败", - "kbn.management.objects.objectsTable.flyout.importFailedUnsupportedType": "{type} [id={id}] 不受支持的类型", - "kbn.management.objects.objectsTable.flyout.importFileErrorMessage": "无法处理该文件。", - "kbn.management.objects.objectsTable.flyout.importLegacyFileErrorMessage": "无法处理该文件。", - "kbn.management.objects.objectsTable.flyout.importPromptText": "导入", - "kbn.management.objects.objectsTable.flyout.importSavedObjectTitle": "导入已保存对象", - "kbn.management.objects.objectsTable.flyout.importSuccessful.confirmAllChangesButtonLabel": "确认所有更改", - "kbn.management.objects.objectsTable.flyout.importSuccessful.confirmButtonLabel": "完成", - "kbn.management.objects.objectsTable.flyout.importSuccessfulCallout.noObjectsImportedTitle": "未导入任何对象", - "kbn.management.objects.objectsTable.flyout.importSuccessfulDescription": "已成功导入 {importCount} 个对象。", - "kbn.management.objects.objectsTable.flyout.importSuccessfulTitle": "导入成功", - "kbn.management.objects.objectsTable.flyout.indexPatternConflictsCalloutLinkText": "创建新的索引模式", - "kbn.management.objects.objectsTable.flyout.indexPatternConflictsDescription": "以下已保存对象使用不存在的索引模式。请选择要重新关联的索引模式。必要时,您可以{indexPatternLink}。", - "kbn.management.objects.objectsTable.flyout.indexPatternConflictsTitle": "索引模式冲突", - "kbn.management.objects.objectsTable.flyout.invalidFormatOfImportedFileErrorMessage": "已保存对象文件格式无效,无法导入。", - "kbn.management.objects.objectsTable.flyout.legacyFileUsedBody": "只需使用更新的导出功能生成 NDJSON 文件,便万事俱备。", - "kbn.management.objects.objectsTable.flyout.legacyFileUsedTitle": "将不再支持 JSON 文件", - "kbn.management.objects.objectsTable.flyout.overwriteSavedObjectsLabel": "自动覆盖所有已保存对象?", - "kbn.management.objects.objectsTable.flyout.renderConflicts.columnCountDescription": "受影响对象数目", - "kbn.management.objects.objectsTable.flyout.renderConflicts.columnCountName": "计数", - "kbn.management.objects.objectsTable.flyout.renderConflicts.columnIdDescription": "索引模式的 ID", - "kbn.management.objects.objectsTable.flyout.renderConflicts.columnIdName": "ID", - "kbn.management.objects.objectsTable.flyout.renderConflicts.columnNewIndexPatternName": "新建索引模式", - "kbn.management.objects.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsDescription": "受影响对象样例", - "kbn.management.objects.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsName": "受影响对象样例", - "kbn.management.objects.objectsTable.flyout.resolveImportErrorsFileErrorMessage": "无法处理该文件。", - "kbn.management.objects.objectsTable.flyout.selectFileToImportFormRowLabel": "请选择要导入的文件", - "kbn.management.objects.objectsTable.header.exportButtonLabel": "导出 {filteredCount, plural, one{# 个对象} other {# 个对象}}", - "kbn.management.objects.objectsTable.header.importButtonLabel": "导入", - "kbn.management.objects.objectsTable.header.refreshButtonLabel": "刷新", - "kbn.management.objects.objectsTable.header.savedObjectsTitle": "已保存对象", - "kbn.management.objects.objectsTable.howToDeleteSavedObjectsDescription": "从这里您可以删除已保存对象,如已保存搜索。还可以编辑已保存对象的原始数据。通常,对象只能通过其关联的应用程序进行修改;或许您应该遵循这一原则,而非使用此屏幕进行修改。", - "kbn.management.objects.objectsTable.relationships.columnActions.inspectActionDescription": "检查此已保存对象", - "kbn.management.objects.objectsTable.relationships.columnActions.inspectActionName": "检查", - "kbn.management.objects.objectsTable.relationships.columnActionsName": "操作", - "kbn.management.objects.objectsTable.relationships.columnRelationship.childAsValue": "子项", - "kbn.management.objects.objectsTable.relationships.columnRelationship.parentAsValue": "父项", - "kbn.management.objects.objectsTable.relationships.columnRelationshipName": "直接关系", - "kbn.management.objects.objectsTable.relationships.columnTitleDescription": "已保存对象的标题", - "kbn.management.objects.objectsTable.relationships.columnTitleName": "标题", - "kbn.management.objects.objectsTable.relationships.columnTypeDescription": "已保存对象的类型", - "kbn.management.objects.objectsTable.relationships.columnTypeName": "类型", - "kbn.management.objects.objectsTable.relationships.relationshipsTitle": "以下是与 {title} 相关的已保存对象。删除此{type}将影响其父级对象,但不会影响其子级对象。", - "kbn.management.objects.objectsTable.relationships.renderErrorMessage": "错误", - "kbn.management.objects.objectsTable.relationships.search.filters.relationship.childAsValue.view": "子项", - "kbn.management.objects.objectsTable.relationships.search.filters.relationship.name": "直接关系", - "kbn.management.objects.objectsTable.relationships.search.filters.relationship.parentAsValue.view": "父项", - "kbn.management.objects.objectsTable.relationships.search.filters.type.name": "类型", - "kbn.management.objects.objectsTable.searchBar.unableToParseQueryErrorMessage": "无法解析查询", - "kbn.management.objects.objectsTable.table.columnActions.inspectActionDescription": "检查此已保存对象", - "kbn.management.objects.objectsTable.table.columnActions.inspectActionName": "检查", - "kbn.management.objects.objectsTable.table.columnActions.viewRelationshipsActionDescription": "查看此已保存对象与其他已保存对象的关系", - "kbn.management.objects.objectsTable.table.columnActions.viewRelationshipsActionName": "关系", - "kbn.management.objects.objectsTable.table.columnActionsName": "操作", - "kbn.management.objects.objectsTable.table.columnTitleDescription": "已保存对象的标题", - "kbn.management.objects.objectsTable.table.columnTitleName": "标题", - "kbn.management.objects.objectsTable.table.columnTypeDescription": "已保存对象的类型", - "kbn.management.objects.objectsTable.table.columnTypeName": "类型", - "kbn.management.objects.objectsTable.table.deleteButtonLabel": "删除", - "kbn.management.objects.objectsTable.table.deleteButtonTitle": "无法删除已保存对象", - "kbn.management.objects.objectsTable.table.exportButtonLabel": "导出", - "kbn.management.objects.objectsTable.table.exportPopoverButtonLabel": "导出", - "kbn.management.objects.objectsTable.table.typeFilterName": "类型", - "kbn.management.objects.objectsTable.unableFindSavedObjectsNotificationMessage": "找不到已保存对象", - "kbn.management.objects.parsingFieldErrorMessage": "为索引模式 “{indexName}” 解析 “{fieldName}” 时发生错误:{errorMessage}", - "kbn.management.objects.savedObjectsSectionLabel": "已保存对象", - "kbn.management.objects.view.cancelButtonAriaLabel": "取消", - "kbn.management.objects.view.cancelButtonLabel": "取消", - "kbn.management.objects.view.deleteItemButtonLabel": "删除“{title}”", - "kbn.management.objects.view.editItemTitle": "编辑“{title}", - "kbn.management.objects.view.fieldDoesNotExistErrorMessage": "与此对象关联的字段在该索引模式中已不存在。", - "kbn.management.objects.view.howToFixErrorDescription": "如果您清楚此错误的含义,请修复该错误 — 否则单击上面的删除按钮。", - "kbn.management.objects.view.howToModifyObjectDescription": "修改对象仅适用于高级用户。对象属性未得到验证,无效的对象可能会导致错误、数据丢失或更坏的情况发生。除非熟悉该代码的人让您来这里,否则您可能不应该来这里。", - "kbn.management.objects.view.howToModifyObjectTitle": "谨慎操作!", - "kbn.management.objects.view.indexPatternDoesNotExistErrorMessage": "与此对象关联的索引模式已不存在。", - "kbn.management.objects.view.saveButtonAriaLabel": "保存 { title } 对象", - "kbn.management.objects.view.saveButtonLabel": "保存 { title } 对象", - "kbn.management.objects.view.savedObjectProblemErrorMessage": "此已保存对象有问题", - "kbn.management.objects.view.savedSearchDoesNotExistErrorMessage": "与此对象关联的已保存搜索已不存在。", - "kbn.management.objects.view.viewItemButtonLabel": "查看“{title}”", - "kbn.management.objects.view.viewItemTitle": "查看“{title}”", - "kbn.management.savedObjects.editBreadcrumb": "编辑 {savedObjectType}", - "kbn.management.savedObjects.indexBreadcrumb": "已保存对象", + "savedObjectsManagement.indexPattern.confirmOverwriteButton": "覆盖", + "savedObjectsManagement.indexPattern.confirmOverwriteLabel": "确定要覆盖 “{title}”?", + "savedObjectsManagement.indexPattern.confirmOverwriteTitle": "覆盖“{type}”?", + "savedObjectsManagement.deleteConfirm.modalDeleteButtonLabel": "删除", + "savedObjectsManagement.deleteConfirm.modalDescription": "此操作会将对象从 Kibana 永久移除。", + "savedObjectsManagement.deleteConfirm.modalTitle": "删除“{title}”?", + "savedObjectsManagement.deleteSavedObjectsConfirmModalDescription": "此操作将删除以下已保存对象:", + "savedObjectsManagement.field.offLabel": "关闭", + "savedObjectsManagement.field.onLabel": "开启", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.cancelButtonLabel": "取消", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.deleteButtonLabel": "删除", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.deleteProcessButtonLabel": "正在删除……", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.idColumnName": "ID", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName": "标题", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.typeColumnName": "类型", + "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModalTitle": "删除已保存对象", + "savedObjectsManagement.objectsTable.export.dangerNotification": "无法生成报告", + "savedObjectsManagement.objectsTable.export.successNotification": "您的文件正在后台下载", + "savedObjectsManagement.objectsTable.export.successWithMissingRefsNotification": "您的文件正在后台下载。找不到某些相关对象。有关缺失对象列表,请查看导出文件的最后一行。", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModal.cancelButtonLabel": "取消", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModal.exportAllButtonLabel": "全部导出", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModal.exportOptionsLabel": "选项", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModal.includeReferencesDeepLabel": "包括相关对象", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModalDescription": "选择要导出的类型。", + "savedObjectsManagement.objectsTable.exportObjectsConfirmModalTitle": "导出 {filteredItemCount, plural, one{# 个对象} other {# 个对象}}", + "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.resolvingConflictsLoadingMessage": "正在解决冲突……", + "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.retryingFailedObjectsLoadingMessage": "正在重试失败的对象……", + "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savedSearchAreLinkedProperlyLoadingMessage": "确保已保存搜索已正确链接……", + "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savingConflictsLoadingMessage": "正在保存冲突……", + "savedObjectsManagement.objectsTable.flyout.confirmOverwriteBody": "确定要覆盖 “{title}”?", + "savedObjectsManagement.objectsTable.flyout.confirmOverwriteCancelButtonText": "取消", + "savedObjectsManagement.objectsTable.flyout.confirmOverwriteOverwriteButtonText": "覆盖", + "savedObjectsManagement.objectsTable.flyout.confirmOverwriteTitle": "覆盖“{type}”?", + "savedObjectsManagement.objectsTable.flyout.errorCalloutTitle": "抱歉,出现了错误", + "savedObjectsManagement.objectsTable.flyout.import.cancelButtonLabel": "取消", + "savedObjectsManagement.objectsTable.flyout.import.confirmButtonLabel": "导入", + "savedObjectsManagement.objectsTable.flyout.importFailedDescription": "无法导入 {failedImportCount} 个对象,共 {totalImportCount} 个。导入失败", + "savedObjectsManagement.objectsTable.flyout.importFailedMissingReference": "{type} [id={id}] 无法找到 {refType} [id={refId}]", + "savedObjectsManagement.objectsTable.flyout.importFailedTitle": "导入失败", + "savedObjectsManagement.objectsTable.flyout.importFailedUnsupportedType": "{type} [id={id}] 不受支持的类型", + "savedObjectsManagement.objectsTable.flyout.importFileErrorMessage": "无法处理该文件。", + "savedObjectsManagement.objectsTable.flyout.importLegacyFileErrorMessage": "无法处理该文件。", + "savedObjectsManagement.objectsTable.flyout.importPromptText": "导入", + "savedObjectsManagement.objectsTable.flyout.importSavedObjectTitle": "导入已保存对象", + "savedObjectsManagement.objectsTable.flyout.importSuccessful.confirmAllChangesButtonLabel": "确认所有更改", + "savedObjectsManagement.objectsTable.flyout.importSuccessful.confirmButtonLabel": "完成", + "savedObjectsManagement.objectsTable.flyout.importSuccessfulCallout.noObjectsImportedTitle": "未导入任何对象", + "savedObjectsManagement.objectsTable.flyout.importSuccessfulDescription": "已成功导入 {importCount} 个对象。", + "savedObjectsManagement.objectsTable.flyout.importSuccessfulTitle": "导入成功", + "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsCalloutLinkText": "创建新的索引模式", + "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsDescription": "以下已保存对象使用不存在的索引模式。请选择要重新关联的索引模式。必要时,您可以{indexPatternLink}。", + "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsTitle": "索引模式冲突", + "savedObjectsManagement.objectsTable.flyout.invalidFormatOfImportedFileErrorMessage": "已保存对象文件格式无效,无法导入。", + "savedObjectsManagement.objectsTable.flyout.legacyFileUsedBody": "只需使用更新的导出功能生成 NDJSON 文件,便万事俱备。", + "savedObjectsManagement.objectsTable.flyout.legacyFileUsedTitle": "将不再支持 JSON 文件", + "savedObjectsManagement.objectsTable.flyout.overwriteSavedObjectsLabel": "自动覆盖所有已保存对象?", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountDescription": "受影响对象数目", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountName": "计数", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdDescription": "索引模式的 ID", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdName": "ID", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnNewIndexPatternName": "新建索引模式", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsDescription": "受影响对象样例", + "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsName": "受影响对象样例", + "savedObjectsManagement.objectsTable.flyout.resolveImportErrorsFileErrorMessage": "无法处理该文件。", + "savedObjectsManagement.objectsTable.flyout.selectFileToImportFormRowLabel": "请选择要导入的文件", + "savedObjectsManagement.objectsTable.header.exportButtonLabel": "导出 {filteredCount, plural, one{# 个对象} other {# 个对象}}", + "savedObjectsManagement.objectsTable.header.importButtonLabel": "导入", + "savedObjectsManagement.objectsTable.header.refreshButtonLabel": "刷新", + "savedObjectsManagement.objectsTable.header.savedObjectsTitle": "已保存对象", + "savedObjectsManagement.objectsTable.howToDeleteSavedObjectsDescription": "从这里您可以删除已保存对象,如已保存搜索。还可以编辑已保存对象的原始数据。通常,对象只能通过其关联的应用程序进行修改;或许您应该遵循这一原则,而非使用此屏幕进行修改。", + "savedObjectsManagement.objectsTable.relationships.columnActions.inspectActionDescription": "检查此已保存对象", + "savedObjectsManagement.objectsTable.relationships.columnActions.inspectActionName": "检查", + "savedObjectsManagement.objectsTable.relationships.columnActionsName": "操作", + "savedObjectsManagement.objectsTable.relationships.columnRelationship.childAsValue": "子项", + "savedObjectsManagement.objectsTable.relationships.columnRelationship.parentAsValue": "父项", + "savedObjectsManagement.objectsTable.relationships.columnRelationshipName": "直接关系", + "savedObjectsManagement.objectsTable.relationships.columnTitleDescription": "已保存对象的标题", + "savedObjectsManagement.objectsTable.relationships.columnTitleName": "标题", + "savedObjectsManagement.objectsTable.relationships.columnTypeDescription": "已保存对象的类型", + "savedObjectsManagement.objectsTable.relationships.columnTypeName": "类型", + "savedObjectsManagement.objectsTable.relationships.relationshipsTitle": "以下是与 {title} 相关的已保存对象。删除此{type}将影响其父级对象,但不会影响其子级对象。", + "savedObjectsManagement.objectsTable.relationships.renderErrorMessage": "错误", + "savedObjectsManagement.objectsTable.relationships.search.filters.relationship.childAsValue.view": "子项", + "savedObjectsManagement.objectsTable.relationships.search.filters.relationship.name": "直接关系", + "savedObjectsManagement.objectsTable.relationships.search.filters.relationship.parentAsValue.view": "父项", + "savedObjectsManagement.objectsTable.relationships.search.filters.type.name": "类型", + "savedObjectsManagement.objectsTable.searchBar.unableToParseQueryErrorMessage": "无法解析查询", + "savedObjectsManagement.objectsTable.table.columnActions.inspectActionDescription": "检查此已保存对象", + "savedObjectsManagement.objectsTable.table.columnActions.inspectActionName": "检查", + "savedObjectsManagement.objectsTable.table.columnActions.viewRelationshipsActionDescription": "查看此已保存对象与其他已保存对象的关系", + "savedObjectsManagement.objectsTable.table.columnActions.viewRelationshipsActionName": "关系", + "savedObjectsManagement.objectsTable.table.columnActionsName": "操作", + "savedObjectsManagement.objectsTable.table.columnTitleDescription": "已保存对象的标题", + "savedObjectsManagement.objectsTable.table.columnTitleName": "标题", + "savedObjectsManagement.objectsTable.table.columnTypeDescription": "已保存对象的类型", + "savedObjectsManagement.objectsTable.table.columnTypeName": "类型", + "savedObjectsManagement.objectsTable.table.deleteButtonLabel": "删除", + "savedObjectsManagement.objectsTable.table.deleteButtonTitle": "无法删除已保存对象", + "savedObjectsManagement.objectsTable.table.exportButtonLabel": "导出", + "savedObjectsManagement.objectsTable.table.exportPopoverButtonLabel": "导出", + "savedObjectsManagement.objectsTable.table.typeFilterName": "类型", + "savedObjectsManagement.objectsTable.unableFindSavedObjectsNotificationMessage": "找不到已保存对象", + "savedObjectsManagement.parsingFieldErrorMessage": "为索引模式 “{indexName}” 解析 “{fieldName}” 时发生错误:{errorMessage}", + "savedObjectsManagement.managementSectionLabel": "已保存对象", + "savedObjectsManagement.view.cancelButtonAriaLabel": "取消", + "savedObjectsManagement.view.cancelButtonLabel": "取消", + "savedObjectsManagement.view.deleteItemButtonLabel": "删除“{title}”", + "savedObjectsManagement.view.editItemTitle": "编辑“{title}", + "savedObjectsManagement.view.fieldDoesNotExistErrorMessage": "与此对象关联的字段在该索引模式中已不存在。", + "savedObjectsManagement.view.howToFixErrorDescription": "如果您清楚此错误的含义,请修复该错误 — 否则单击上面的删除按钮。", + "savedObjectsManagement.view.howToModifyObjectDescription": " 修改对象仅适用于高级用户。对象属性未得到验证,无效的对象可能会导致错误、数据丢失或更坏的情况发生。除非熟悉该代码的人让您来这里,否则您可能不应该来这里。", + "savedObjectsManagement.view.howToModifyObjectTitle": "谨慎操作!", + "savedObjectsManagement.view.indexPatternDoesNotExistErrorMessage": "与此对象关联的索引模式已不存在。", + "savedObjectsManagement.view.saveButtonAriaLabel": "保存 { title } 对象", + "savedObjectsManagement.view.saveButtonLabel": "保存 { title } 对象", + "savedObjectsManagement.view.savedObjectProblemErrorMessage": "此已保存对象有问题", + "savedObjectsManagement.view.savedSearchDoesNotExistErrorMessage": "与此对象关联的已保存搜索已不存在。", + "savedObjectsManagement.view.viewItemButtonLabel": "查看“{title}”", + "savedObjectsManagement.view.viewItemTitle": "查看“{title}”", + "savedObjectsManagement.breadcrumb.edit": "编辑 {savedObjectType}", + "savedObjectsManagement.breadcrumb.index": "已保存对象", "kbn.managementTitle": "管理", - "kbn.topNavMenu.openInspectorButtonLabel": "检查", - "kbn.topNavMenu.refreshButtonLabel": "刷新", - "kbn.topNavMenu.saveVisualizationButtonLabel": "保存", - "kbn.topNavMenu.shareVisualizationButtonLabel": "共享", - "kbn.visualize.badge.readOnly.text": "只读", - "kbn.visualize.badge.readOnly.tooltip": "无法保存可视化", - "kbn.visualize.createVisualization.noIndexPatternOrSavedSearchIdErrorMessage": "必须提供 indexPattern 或 savedSearchId", - "kbn.visualize.editor.createBreadcrumb": "创建", - "kbn.visualize.experimentalVisInfoText": "此可视化标记为“实验”。", - "kbn.visualize.helpMenu.appName": "Visualize", - "kbn.visualize.linkedToSearch.unlinkSuccessNotificationText": "取消与已保存搜索 “{searchTitle}” 的链接", - "kbn.visualize.listing.betaTitle": "公测版", - "kbn.visualize.listing.betaTooltip": "此可视化为公测版,可能会进行更改。设计和代码相对于正式发行版功能还不够成熟,将按原样提供,且不提供任何保证。公测版功能不受正式发行版功能支持 SLA 的约束", - "kbn.visualize.listing.breadcrumb": "可视化", - "kbn.visualize.listing.createNew.createButtonLabel": "新建可视化", - "kbn.visualize.listing.createNew.description": "可以根据您的数据创建不同的可视化。", - "kbn.visualize.listing.createNew.title": "创建首个可视化", - "kbn.visualize.listing.experimentalTitle": "实验性", - "kbn.visualize.listing.experimentalTooltip": "未来版本可能会更改或删除此可视化,其不受支持 SLA 的约束。", - "kbn.visualize.listing.noItemsMessage": "看起来您还没有任何可视化。", - "kbn.visualize.listing.table.entityName": "可视化", - "kbn.visualize.listing.table.entityNamePlural": "可视化", - "kbn.visualize.listing.table.listTitle": "可视化", - "kbn.visualize.listing.table.titleColumnName": "标题", - "kbn.visualize.listing.table.typeColumnName": "类型", - "kbn.visualize.pageHeading": "{chartName} {chartType}可视化", - "kbn.visualize.saveDialog.saveAndAddToDashboardButtonLabel": "保存并添加到仪表板", - "kbn.visualize.topNavMenu.openInspectorButtonAriaLabel": "打开检查器查看可视化", - "kbn.visualize.topNavMenu.openInspectorDisabledButtonTooltip": "此可视化不支持任何检查器。", - "kbn.visualize.topNavMenu.refreshButtonAriaLabel": "刷新", - "kbn.visualize.topNavMenu.saveVisualization.failureNotificationText": "保存 “{visTitle}” 时出错", - "kbn.visualize.topNavMenu.saveVisualization.successNotificationText": "已保存“{visTitle}”", - "kbn.visualize.topNavMenu.saveVisualizationButtonAriaLabel": "保存可视化", - "kbn.visualize.topNavMenu.saveVisualizationDisabledButtonTooltip": "应用或放弃所做更改,然后保存", - "kbn.visualize.topNavMenu.shareVisualizationButtonAriaLabel": "共享可视化", - "kbn.visualize.visualizationTypeInvalidNotificationMessage": "无效的可视化类型", - "kbn.visualize.visualizeDescription": "创建可视化并聚合存储在 Elasticsearch 索引中的数据。", - "kbn.visualize.visualizeListingBreadcrumbsTitle": "可视化", - "kbn.visualize.visualizeListingDeleteErrorTitle": "删除可视化时出错", - "kbn.visualize.wizard.step1Breadcrumb": "创建", - "kbn.visualize.wizard.step2Breadcrumb": "创建", "kbn.visualizeTitle": "可视化", "kibana_legacy.bigUrlWarningNotificationMessage": "在{advancedSettingsLink}中启用“{storeInSessionStorageParam}”选项或简化屏幕视觉效果。", "kibana_legacy.bigUrlWarningNotificationMessage.advancedSettingsLinkText": "高级设置", @@ -3832,11 +3793,6 @@ "visTypeVislib.chartTypes.areaText": "面积图", "visTypeVislib.chartTypes.barText": "条形图", "visTypeVislib.chartTypes.lineText": "折线图", - "visTypeVislib.controls.colorRanges.errorText": "每个范围应大于前一范围。", - "visTypeVislib.controls.colorSchema.colorSchemaLabel": "颜色模式", - "visTypeVislib.controls.colorSchema.howToChangeColorsDescription": "可以更改图例中的各个颜色。", - "visTypeVislib.controls.colorSchema.resetColorsButtonLabel": "重置颜色", - "visTypeVislib.controls.colorSchema.reverseColorSchemaLabel": "反转模式", "visTypeVislib.controls.gaugeOptions.alignmentLabel": "对齐方式", "visTypeVislib.controls.gaugeOptions.autoExtendRangeLabel": "自动扩展范围", "visTypeVislib.controls.gaugeOptions.displayWarningsLabel": "显示警告", @@ -3903,10 +3859,7 @@ "visTypeVislib.controls.pointSeries.valueAxes.toggleCustomExtendsAriaLabel": "切换定制范围", "visTypeVislib.controls.pointSeries.valueAxes.toggleOptionsAriaLabel": "切换 {axisName} 选项", "visTypeVislib.controls.pointSeries.valueAxes.yAxisTitle": "Y 轴", - "visTypeVislib.controls.rangeErrorMessage": "值必须是在 {min} 到 {max} 的范围内", "visTypeVislib.controls.truncateLabel": "截断", - "visTypeVislib.controls.vislibBasicOptions.legendPositionLabel": "图例位置", - "visTypeVislib.controls.vislibBasicOptions.showTooltipLabel": "显示工具提示", "visTypeVislib.editors.heatmap.basicSettingsTitle": "基本设置", "visTypeVislib.editors.heatmap.heatmapSettingsTitle": "热图设置", "visTypeVislib.editors.heatmap.highlightLabel": "高亮范围", @@ -4029,6 +3982,46 @@ "visualizations.newVisWizard.visTypeAliasDescription": "打开 Visualize 外部的 Kibana 应用程序。", "visualizations.newVisWizard.visTypeAliasTitle": "Kibana 应用程序", "visualizations.savedObjectName": "可视化", + "visualize.listing.table.descriptionColumnName": "描述", + "visualize.topNavMenu.openInspectorButtonLabel": "检查", + "visualize.topNavMenu.saveVisualizationButtonLabel": "保存", + "visualize.topNavMenu.shareVisualizationButtonLabel": "共享", + "visualize.badge.readOnly.text": "只读", + "visualize.badge.readOnly.tooltip": "无法保存可视化", + "visualize.createVisualization.noIndexPatternOrSavedSearchIdErrorMessage": "必须提供 indexPattern 或 savedSearchId", + "visualize.editor.createBreadcrumb": "创建", + "visualize.experimentalVisInfoText": "此可视化标记为“实验”。", + "visualize.helpMenu.appName": "Visualize", + "visualize.linkedToSearch.unlinkSuccessNotificationText": "取消与已保存搜索 “{searchTitle}” 的链接", + "visualize.listing.betaTitle": "公测版", + "visualize.listing.betaTooltip": "此可视化为公测版,可能会进行更改。设计和代码相对于正式发行版功能还不够成熟,将按原样提供,且不提供任何保证。公测版功能不受正式发行版功能支持 SLA 的约束", + "visualize.listing.breadcrumb": "可视化", + "visualize.listing.createNew.createButtonLabel": "新建可视化", + "visualize.listing.createNew.description": "可以根据您的数据创建不同的可视化。", + "visualize.listing.createNew.title": "创建首个可视化", + "visualize.listing.experimentalTitle": "实验性", + "visualize.listing.experimentalTooltip": "未来版本可能会更改或删除此可视化,其不受支持 SLA 的约束。", + "visualize.listing.noItemsMessage": "看起来您还没有任何可视化。", + "visualize.listing.table.entityName": "可视化", + "visualize.listing.table.entityNamePlural": "可视化", + "visualize.listing.table.listTitle": "可视化", + "visualize.listing.table.titleColumnName": "标题", + "visualize.listing.table.typeColumnName": "类型", + "visualize.pageHeading": "{chartName} {chartType}可视化", + "visualize.saveDialog.saveAndAddToDashboardButtonLabel": "保存并添加到仪表板", + "visualize.topNavMenu.openInspectorButtonAriaLabel": "打开检查器查看可视化", + "visualize.topNavMenu.openInspectorDisabledButtonTooltip": "此可视化不支持任何检查器。", + "visualize.topNavMenu.saveVisualization.failureNotificationText": "保存 “{visTitle}” 时出错", + "visualize.topNavMenu.saveVisualization.successNotificationText": "已保存“{visTitle}”", + "visualize.topNavMenu.saveVisualizationButtonAriaLabel": "保存可视化", + "visualize.topNavMenu.saveVisualizationDisabledButtonTooltip": "应用或放弃所做更改,然后保存", + "visualize.topNavMenu.shareVisualizationButtonAriaLabel": "共享可视化", + "visualize.visualizationTypeInvalidNotificationMessage": "无效的可视化类型", + "visualize.visualizeDescription": "创建可视化并聚合存储在 Elasticsearch 索引中的数据。", + "visualize.visualizeListingBreadcrumbsTitle": "可视化", + "visualize.visualizeListingDeleteErrorTitle": "删除可视化时出错", + "visualize.wizard.step1Breadcrumb": "创建", + "visualize.wizard.step2Breadcrumb": "创建", "xpack.actions.actionTypeRegistry.get.missingActionTypeErrorMessage": "未注册操作类型“{id}”。", "xpack.actions.actionTypeRegistry.register.duplicateActionTypeErrorMessage": "操作类型“{id}”已注册。", "xpack.actions.appName": "操作", @@ -7462,9 +7455,6 @@ "xpack.indexLifecycleMgmt.activePhaseMessage": "有效", "xpack.indexLifecycleMgmt.addLifecyclePolicyActionButtonLabel": "添加生命周期策略", "xpack.indexLifecycleMgmt.appTitle": "索引生命周期策略", - "xpack.indexLifecycleMgmt.checkLicense.errorExpiredMessage": "您不能使用 {pluginName},因为您的 {licenseType} 许可已过期。", - "xpack.indexLifecycleMgmt.checkLicense.errorUnavailableMessage": "您不能使用 {pluginName},因为许可证信息当前不可用。", - "xpack.indexLifecycleMgmt.checkLicense.errorUnsupportedMessage": "您的 {licenseType} 许可证不支持 {pluginName}。请升级您的许可。", "xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel": "冻结索引", "xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasLabel": "副本分片数目", "xpack.indexLifecycleMgmt.coldPhase.replicaCountHelpText": "默认情况下,副本分片数目仍一样。", @@ -8290,7 +8280,6 @@ "xpack.ingestManager.agentConfigForm.systemMonitoringFieldLabel": "可选", "xpack.ingestManager.agentConfigForm.systemMonitoringText": "收集系统指标", "xpack.ingestManager.agentConfigForm.systemMonitoringTooltipText": "启用此选项可使用收集系统指标和信息的数据源启动您的配置。", - "xpack.ingestManager.agentConfigInfo.yamlTabName": "YAML", "xpack.ingestManager.agentConfigList.actionsColumnTitle": "操作", "xpack.ingestManager.agentConfigList.actionsMenuText": "打开", "xpack.ingestManager.agentConfigList.addButton": "创建代理配置", @@ -8428,21 +8417,14 @@ "xpack.ingestManager.createAgentConfig.flyoutTitleDescription": "代理配置用于管理整个代理组的设置。您可以将数据源添加到代理配置以指定代理收集的数据。编辑代理配置时,可以使用 Fleet 将更新部署到指定的代理组。", "xpack.ingestManager.createAgentConfig.submitButtonLabel": "创建代理配置", "xpack.ingestManager.createAgentConfig.successNotificationTitle": "代理配置“{name}”已创建", - "xpack.ingestManager.createDatasource.addDatasourceButtonText": "将数据源添加到配置", "xpack.ingestManager.createDatasource.agentConfigurationNameLabel": "配置", "xpack.ingestManager.createDatasource.cancelLinkText": "取消", - "xpack.ingestManager.createDatasource.changeConfigLinkText": "更改配置", - "xpack.ingestManager.createDatasource.changePackageLinkText": "更改软件包", - "xpack.ingestManager.createDatasource.continueButtonText": "继续", - "xpack.ingestManager.createDatasource.editDatasourceLinkText": "编辑数据源", "xpack.ingestManager.createDatasource.packageNameLabel": "软件包", "xpack.ingestManager.createDatasource.pageTitle": "创建数据源", "xpack.ingestManager.createDatasource.stepConfigure.advancedOptionsToggleLinkText": "高级选项", - "xpack.ingestManager.createDatasource.stepConfigure.chooseDataTitle": "选择要收集的数据", "xpack.ingestManager.createDatasource.stepConfigure.datasourceDescriptionInputLabel": "描述", "xpack.ingestManager.createDatasource.stepConfigure.datasourceNameInputLabel": "数据源名称", "xpack.ingestManager.createDatasource.stepConfigure.datasourceNamespaceInputLabel": "命名空间", - "xpack.ingestManager.createDatasource.stepConfigure.defineDatasourceTitle": "定义您的数据源", "xpack.ingestManager.createDatasource.stepConfigure.hideStreamsAriaLabel": "隐藏 {type} 流", "xpack.ingestManager.createDatasource.stepConfigure.inputSettingsDescription": "以下设置适用于所有流。", "xpack.ingestManager.createDatasource.stepConfigure.inputSettingsTitle": "设置", @@ -8451,27 +8433,15 @@ "xpack.ingestManager.createDatasource.stepConfigure.showStreamsAriaLabel": "显示 {type} 流", "xpack.ingestManager.createDatasource.stepConfigure.streamsEnabledCountText": "{count} / {total, plural, one {# 个流} other {# 个流}}已启用", "xpack.ingestManager.createDatasource.stepConfigure.toggleAdvancedOptionsButtonText": "高级选项", - "xpack.ingestManager.createDatasource.stepConfigureDatasourceLabel": "配置数据源", - "xpack.ingestManager.createDatasource.stepReview.agentsAffectedCalloutText": "Fleet 检测到所选代理配置 {configName} 已由您的部分代理使用。此操作的结果是,Fleet 将更新用此配置进行注册的所有代理。", - "xpack.ingestManager.createDatasource.stepReview.agentsAffectedCalloutTitle": "此操作将影响 {count, plural, one {# 个代理} other {# 个代理}}", - "xpack.ingestManager.createDatasource.stepReview.confirmAgentDisclaimerText": "我理解此操作将更新注册到此配置的所有代理。", - "xpack.ingestManager.createDatasource.stepReview.confirmAgentDisclaimerTitle": "确认您的决定", - "xpack.ingestManager.createDatasource.stepReview.reviewTitle": "复查更改", - "xpack.ingestManager.createDatasource.stepReviewLabel": "复查", "xpack.ingestManager.createDatasource.StepSelectConfig.agentConfigAgentsCountText": "{count, plural, one {# 个代理} other {# 个代理}}", - "xpack.ingestManager.createDatasource.StepSelectConfig.createNewConfigButtonText": "创建新配置", "xpack.ingestManager.createDatasource.StepSelectConfig.errorLoadingAgentConfigsTitle": "加载代理配置时出错", "xpack.ingestManager.createDatasource.StepSelectConfig.errorLoadingPackageTitle": "加载软件包信息时出错", "xpack.ingestManager.createDatasource.StepSelectConfig.errorLoadingSelectedAgentConfigTitle": "加载选定代理配置时出错", "xpack.ingestManager.createDatasource.StepSelectConfig.filterAgentConfigsInputPlaceholder": "搜索代理配置", - "xpack.ingestManager.createDatasource.StepSelectConfig.selectAgentConfigTitle": "选择代理配置", - "xpack.ingestManager.createDatasource.stepSelectConfigLabel": "选择配置", "xpack.ingestManager.createDatasource.stepSelectPackage.errorLoadingConfigTitle": "加载代理配置信息时出错", "xpack.ingestManager.createDatasource.stepSelectPackage.errorLoadingPackagesTitle": "加载软件包时出错", "xpack.ingestManager.createDatasource.stepSelectPackage.errorLoadingSelectedPackageTitle": "加载选定软件包时出错", "xpack.ingestManager.createDatasource.stepSelectPackage.filterPackagesInputPlaceholder": "搜索软件包", - "xpack.ingestManager.createDatasource.stepSelectPackage.selectPackageTitle": "选择软件包", - "xpack.ingestManager.createDatasource.stepSelectPackageLabel": "选择软件包", "xpack.ingestManager.deleteAgentConfigs.confirmModal.affectedAgentsMessage": "{agentsCount, plural, one {# 个代理} other {# 个代理}}已分配{agentConfigsCount, plural, one {给此代理配置} other {给这些代理配置}}。将取消注册{agentsCount, plural, one {此代理} other {这些代理}}。", "xpack.ingestManager.deleteAgentConfigs.confirmModal.cancelButtonLabel": "取消", "xpack.ingestManager.deleteAgentConfigs.confirmModal.confirmAndReassignButtonLabel": "删除{agentConfigsCount, plural, one {代理配置} other {代理配置}}并取消注册{agentsCount, plural, one {代理} other {代理}}", @@ -9608,8 +9578,6 @@ "xpack.ml.dataframe.analytics.regressionExploration.generalError": "加载数据时出错。", "xpack.ml.dataframe.analytics.regressionExploration.generalizationDocsCount": "{docsCount, plural, one {# 个文档} other {# 个文档}}已评估", "xpack.ml.dataframe.analytics.regressionExploration.generalizationErrorTitle": "泛化误差", - "xpack.ml.dataframe.analytics.regressionExploration.indexArrayBadgeContent": "数组", - "xpack.ml.dataframe.analytics.regressionExploration.indexArrayToolTipContent": "此基于数组的列的完整内容无法显示。", "xpack.ml.dataframe.analytics.regressionExploration.indexError": "加载索引数据时出错。", "xpack.ml.dataframe.analytics.regressionExploration.isTestingLabel": "测试", "xpack.ml.dataframe.analytics.regressionExploration.isTrainingLabel": "培训", @@ -12463,7 +12431,6 @@ "xpack.reporting.screenCapturePanelContent.optimizeForPrintingLabel": "打印优化", "xpack.reporting.selfCheck.ok": "Reporting 插件自检正常!", "xpack.reporting.selfCheck.warning": "Reporting 插件自检生成警告:{err}", - "xpack.reporting.selfCheckEncryptionKey.warning": "正在为 {setting} 生成随机密钥。要防止待处理报告在重新启动时失败,请在 kibana.yml 中设置 {setting}", "xpack.reporting.shareContextMenu.csvReportsButtonLabel": "CSV 报告", "xpack.reporting.shareContextMenu.pdfReportsButtonLabel": "PDF 报告", "xpack.reporting.shareContextMenu.pngReportsButtonLabel": "PNG 报告", @@ -16110,7 +16077,6 @@ "xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel": "用户名", "xpack.triggersActionsUI.sections.editConnectorForm.betaBadgeTooltipContent": "{pluginName} 为公测版,可能会进行更改。设计和代码相对于正式发行版功能还不够成熟,将按原样提供,且不提供任何保证。公测版功能不受正式发行版功能支持 SLA 的约束。", "xpack.triggersActionsUI.sections.editConnectorForm.cancelButtonLabel": "取消", - "xpack.triggersActionsUI.sections.editConnectorForm.flyoutTitle": "编辑连接器", "xpack.triggersActionsUI.sections.editConnectorForm.saveButtonLabel": "保存", "xpack.triggersActionsUI.sections.editConnectorForm.updateErrorNotificationText": "无法更新连接器。", "xpack.triggersActionsUI.sections.editConnectorForm.updateSuccessNotificationText": "已更新“{connectorName}”", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx index ab9b5c2586c17..957c79a5c5123 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx @@ -22,9 +22,10 @@ export const AddMessageVariables: React.FunctionComponent = ({ const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState(false); const getMessageVariables = () => - messageVariables?.map((variable: string) => ( + messageVariables?.map((variable: string, i: number) => ( { onSelectEventHandler(variable); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx index 9bd6a39d216e3..15f68e6a9f441 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx @@ -17,6 +17,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { useXJsonMode } from '../../../../../../../src/plugins/es_ui_shared/static/ace_x_json/hooks'; import { ActionTypeModel, ActionConnectorFieldsProps, @@ -31,7 +32,6 @@ import { getIndexOptions, getIndexPatterns, } from '../../../common/index_controls'; -import { useXJsonMode } from '../../lib/use_x_json_mode'; import { AddMessageVariables } from '../add_message_variables'; export function getActionType(): ActionTypeModel { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx index 5bbec1221a3ac..7557b17f5b67d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx @@ -328,6 +328,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent { const description = queryByRole(/banner/i); expect(description!.textContent).toMatchInlineSnapshot( - `"To create an alert, set a value for xpack.encrypted_saved_objects.encryptionKey in your kibana.yml file. Learn how."` + `"To create an alert, set a value for xpack.encryptedSavedObjects.encryptionKey in your kibana.yml file. Learn how."` ); const action = queryByText(/Learn/i); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx index c967cf5de0771..afd5e08f52f25 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx @@ -132,7 +132,7 @@ const EncryptionError = ({ defaultMessage: 'To create an alert, set a value for ', } )} - {'xpack.encrypted_saved_objects.encryptionKey'} + {'xpack.encryptedSavedObjects.encryptionKey'} {i18n.translate( 'xpack.triggersActionsUI.components.healthCheck.encryptionErrorAfterKey', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/use_x_json_mode.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/use_x_json_mode.ts deleted file mode 100644 index b677924724ffe..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/use_x_json_mode.ts +++ /dev/null @@ -1,25 +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 { useState } from 'react'; -import { XJsonMode } from '../../../../../../src/plugins/es_ui_shared/console_lang/ace/modes/x_json'; -import { - collapseLiteralStrings, - expandLiteralStrings, -} from '../../../../../../src/plugins/es_ui_shared/console_lang/lib'; -// @ts-ignore -export const xJsonMode = new XJsonMode(); -export const useXJsonMode = (json: Record | null) => { - const [xJson, setXJson] = useState( - json === null ? '' : expandLiteralStrings(JSON.stringify(json, null, 2)) - ); - - return { - xJson, - setXJson, - xJsonMode, - convertToJson: collapseLiteralStrings, - }; -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index 41564146bb84d..488db42f4e843 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -87,6 +87,14 @@ describe('action_form', () => { config: {}, isPreconfigured: false, }, + { + secrets: {}, + id: 'test2', + actionTypeId: actionType.id, + name: 'Test connector 2', + config: {}, + isPreconfigured: true, + }, ]); const mockes = coreMock.createSetup(); deps = { @@ -100,6 +108,7 @@ describe('action_form', () => { disabledByLicenseActionType, ]); actionTypeRegistry.has.mockReturnValue(true); + actionTypeRegistry.get.mockReturnValue(actionType); const initialAlert = ({ name: 'test', @@ -206,6 +215,29 @@ describe('action_form', () => { expect(actionOption.exists()).toBeFalsy(); }); + it(`renders available connectors for the selected action type`, async () => { + await setup(); + const actionOption = wrapper.find( + `[data-test-subj="${actionType.id}-ActionTypeSelectOption"]` + ); + actionOption.first().simulate('click'); + const combobox = wrapper.find(`[data-test-subj="selectActionConnector"]`); + expect((combobox.first().props() as any).options).toMatchInlineSnapshot(` + Array [ + Object { + "id": "test", + "key": "test", + "label": "Test connector ", + }, + Object { + "id": "test2", + "key": "test2", + "label": "Test connector 2 (pre-configured)", + }, + ] + `); + }); + it('renders action types disabled by license', async () => { await setup(); const actionOption = wrapper.find( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 6b011ac84bc6f..3f964cc167c25 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -24,6 +24,7 @@ import { EuiToolTip, EuiIconTip, EuiLink, + EuiCallOut, } from '@elastic/eui'; import { HttpSetup, ToastsApi } from 'kibana/public'; import { loadActionTypes, loadAllActions } from '../../lib/action_connector_api'; @@ -85,8 +86,10 @@ export const ActionForm = ({ ); const [isAddActionPanelOpen, setIsAddActionPanelOpen] = useState(true); const [connectors, setConnectors] = useState([]); + const [isLoadingConnectors, setIsLoadingConnectors] = useState(false); const [isLoadingActionTypes, setIsLoadingActionTypes] = useState(false); const [actionTypesIndex, setActionTypesIndex] = useState(undefined); + const [emptyActionsIds, setEmptyActionsIds] = useState([]); // load action types useEffect(() => { @@ -128,6 +131,7 @@ export const ActionForm = ({ async function loadConnectors() { try { + setIsLoadingConnectors(true); const actionsResponse = await loadAllActions({ http }); setConnectors(actionsResponse); } catch (e) { @@ -139,18 +143,28 @@ export const ActionForm = ({ } ), }); + } finally { + setIsLoadingConnectors(false); } } + const preconfiguredMessage = i18n.translate( + 'xpack.triggersActionsUI.sections.actionForm.preconfiguredTitleMessage', + { + defaultMessage: '(pre-configured)', + } + ); const getSelectedOptions = (actionItemId: string) => { const val = connectors.find(connector => connector.id === actionItemId); if (!val) { return []; } + const optionTitle = `${val.name} ${val.isPreconfigured ? preconfiguredMessage : ''}`; return [ { - label: val.name, - value: val.name, + label: optionTitle, + value: optionTitle, id: actionItemId, + 'data-test-subj': 'itemActionConnector', }, ]; }; @@ -164,13 +178,9 @@ export const ActionForm = ({ index: number ) => { const optionsList = connectors - .filter( - connectorItem => - connectorItem.actionTypeId === actionItem.actionTypeId && - connectorItem.id === actionItem.id - ) - .map(({ name, id }) => ({ - label: name, + .filter(connectorItem => connectorItem.actionTypeId === actionItem.actionTypeId) + .map(({ name, id, isPreconfigured }) => ({ + label: `${name} ${isPreconfigured ? preconfiguredMessage : ''}`, key: id, id, })); @@ -201,6 +211,7 @@ export const ActionForm = ({ labelAppend={ { setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); setAddModalVisibility(true); @@ -217,6 +228,8 @@ export const ActionForm = ({ fullWidth singleSelection={{ asPlainText: true }} options={optionsList} + id={`selectActionConnector-${actionItem.id}`} + data-test-subj="selectActionConnector" selectedOptions={getSelectedOptions(actionItem.id)} onChange={selectedOptions => { setActionIdByIndex(selectedOptions[0].id ?? '', index); @@ -264,7 +277,9 @@ export const ActionForm = ({ defaultMessage="{actionConnectorName}" id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle" values={{ - actionConnectorName: actionConnector.name, + actionConnectorName: `${actionConnector.name} ${ + actionConnector.isPreconfigured ? preconfiguredMessage : '' + }`, }} /> @@ -378,13 +393,25 @@ export const ActionForm = ({ > + emptyActionsIds.find((emptyId: string) => actionItem.id === emptyId) ? ( + + ) : ( + + ) } actions={[ field.actionTypeId === actionTypeModel.id ); + if (actionTypeConnectors.length > 0) { actions.push({ id: '', @@ -439,6 +467,7 @@ export const ActionForm = ({ params: {}, }); setActionIdByIndex(actions.length.toString(), actions.length - 1); + setEmptyActionsIds([...emptyActionsIds, actions.length.toString()]); } } @@ -483,81 +512,94 @@ export const ActionForm = ({ }); } - return ( - - {actions.map((actionItem: AlertAction, index: number) => { - const actionConnector = connectors.find(field => field.id === actionItem.id); - // connectors doesn't exists - if (!actionConnector) { - return getAddConnectorsForm(actionItem, index); - } + const alertActionsList = actions.map((actionItem: AlertAction, index: number) => { + const actionConnector = connectors.find(field => field.id === actionItem.id); + // connectors doesn't exists + if (!actionConnector) { + return getAddConnectorsForm(actionItem, index); + } - const actionErrors: { errors: IErrorObject } = actionTypeRegistry - .get(actionItem.actionTypeId) - ?.validateParams(actionItem.params); + const actionErrors: { errors: IErrorObject } = actionTypeRegistry + .get(actionItem.actionTypeId) + ?.validateParams(actionItem.params); - return getActionTypeForm(actionItem, actionConnector, actionErrors, index); - })} - - {isAddActionPanelOpen === false ? ( - setIsAddActionPanelOpen(true)} - > + return getActionTypeForm(actionItem, actionConnector, actionErrors, index); + }); + + return ( + + {isLoadingConnectors ? ( + - - ) : null} - {isAddActionPanelOpen ? ( + + ) : ( - - - -

- -
- - - {hasDisabledByLicenseActionTypes && ( - - -
- + {alertActionsList} + + {isAddActionPanelOpen === false ? ( + setIsAddActionPanelOpen(true)} + > + + + ) : null} + {isAddActionPanelOpen ? ( + + + + +
- -
-
-
- )} -
- - - {isLoadingActionTypes ? ( - - - - ) : ( - actionTypeNodes - )} - +
+
+
+ {hasDisabledByLicenseActionTypes && ( + + +
+ + + +
+
+
+ )} +
+ + + {isLoadingActionTypes ? ( + + + + ) : ( + actionTypeNodes + )} + + + ) : null} - ) : null} + )} {actionTypesIndex && activeActionItem ? ( { expect(connectorNameField.exists()).toBeTruthy(); expect(connectorNameField.first().prop('value')).toBe('action-connector'); }); + + test('if pre-configured connector rendered correct in the edit form', () => { + const connector = { + secrets: {}, + id: 'test', + actionTypeId: 'test-action-type-id', + actionType: 'test-action-type-name', + name: 'pre-configured-connector', + isPreconfigured: true, + referencedByCount: 0, + config: {}, + }; + + const actionType = { + id: 'test-action-type-id', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; + actionTypeRegistry.get.mockReturnValue(actionType); + actionTypeRegistry.has.mockReturnValue(true); + + const wrapper = mountWithIntl( + + { + return new Promise(() => {}); + }, + }} + > + {}} + /> + + + ); + + const preconfiguredBadge = wrapper.find('[data-test-subj="preconfiguredBadge"]'); + expect(preconfiguredBadge.exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="saveEditedActionButton"]').exists()).toBeFalsy(); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index ed8811d26331b..a81d6c285f460 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -3,7 +3,7 @@ * 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, { useCallback, useReducer, useState } from 'react'; +import React, { useCallback, useReducer, useState, Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -17,6 +17,8 @@ import { EuiButtonEmpty, EuiButton, EuiBetaBadge, + EuiText, + EuiLink, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; @@ -91,8 +93,77 @@ export const ConnectorEditFlyout = ({ return undefined; }); + const flyoutTitle = connector.isPreconfigured ? ( + + +

+ +   + +   + +

+
+ + + +
+ ) : ( + +

+ +   + +

+
+ ); + return ( - + {actionTypeModel ? ( @@ -100,41 +171,37 @@ export const ConnectorEditFlyout = ({ ) : null} - - -

- -   - -

-
-
+ {flyoutTitle}
- + {!connector.isPreconfigured ? ( + + ) : ( + + + {i18n.translate( + 'xpack.triggersActionsUI.sections.editConnectorForm.descriptionText', + { + defaultMessage: 'This connector is readonly.', + } + )} + + + + + + )} @@ -148,7 +215,7 @@ export const ConnectorEditFlyout = ({ )} - {canSave && actionTypeModel ? ( + {canSave && actionTypeModel && !connector.isPreconfigured ? ( { id: '1', actionTypeId: 'test', description: 'My test', + isPreconfigured: false, referencedByCount: 1, config: {}, }, @@ -119,6 +120,15 @@ describe('actions_connectors_list component with items', () => { actionTypeId: 'test2', description: 'My test 2', referencedByCount: 1, + isPreconfigured: false, + config: {}, + }, + { + id: '3', + actionTypeId: 'test2', + description: 'My preconfigured test 2', + referencedByCount: 1, + isPreconfigured: true, config: {}, }, ]); @@ -185,7 +195,11 @@ describe('actions_connectors_list component with items', () => { it('renders table of connectors', () => { expect(wrapper.find('EuiInMemoryTable')).toHaveLength(1); - expect(wrapper.find('EuiTableRow')).toHaveLength(2); + expect(wrapper.find('EuiTableRow')).toHaveLength(3); + }); + + it('renders table with preconfigured connectors', () => { + expect(wrapper.find('[data-test-subj="preConfiguredTitleMessage"]')).toHaveLength(2); }); test('if select item for edit should render ConnectorEditFlyout', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index 47e058f473946..043a644489d82 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -15,6 +15,9 @@ import { EuiIconTip, EuiFlexGroup, EuiFlexItem, + EuiBetaBadge, + EuiToolTip, + EuiButtonIcon, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -200,31 +203,58 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { }, }, { - field: '', + field: 'isPreconfigured', name: '', - actions: [ - { - enabled: () => canDelete, - 'data-test-subj': 'deleteConnector', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionName', - { defaultMessage: 'Delete' } - ), - description: canDelete - ? i18n.translate( - 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDescription', - { defaultMessage: 'Delete this connector' } - ) - : i18n.translate( - 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDisabledDescription', - { defaultMessage: 'Unable to delete connectors' } - ), - type: 'icon', - icon: 'trash', - color: 'danger', - onClick: (item: ActionConnectorTableItem) => setConnectorsToDelete([item.id]), - }, - ], + render: (value: number, item: ActionConnectorTableItem) => { + if (item.isPreconfigured) { + return ( + + + + + + ); + } + return ( + + + + setConnectorsToDelete([item.id])} + iconType={'trash'} + /> + + + + ); + }, }, ]; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/group_by_over.tsx b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/group_by_over.tsx index 6ad52a5416163..619d85d99719b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/group_by_over.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/group_by_over.tsx @@ -146,16 +146,13 @@ export const GroupByExpression = ({ {groupByTypes[groupBy].sizeRequired ? ( - 0 && termSize !== undefined} - error={errors.termSize} - > + 0} error={errors.termSize}> 0 && termSize !== undefined} - value={termSize} + isInvalid={errors.termSize.length > 0} + value={termSize || ''} onChange={e => { const { value } = e.target; - const termSizeVal = value !== '' ? parseFloat(value) : MIN_TERM_SIZE; + const termSizeVal = value !== '' ? parseFloat(value) : undefined; onChangeSelectedTermSize(termSizeVal); }} min={MIN_TERM_SIZE} diff --git a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.test.ts index a4833d9a3d7fe..2af1a9ac38e44 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.test.ts @@ -58,7 +58,7 @@ describe('Upgrade Assistant Usage Collector', () => { }), }, elasticsearch: { - adminClient: clusterClient, + legacy: { client: clusterClient }, }, }; }); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.ts b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.ts index 79d6e53c64ec0..9c2946db7f084 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.ts @@ -7,7 +7,7 @@ import { set } from 'lodash'; import { APICaller, - ElasticsearchServiceSetup, + ElasticsearchServiceStart, ISavedObjectsRepository, SavedObjectsServiceStart, } from 'src/core/server'; @@ -51,7 +51,7 @@ async function getDeprecationLoggingStatusValue(callAsCurrentUser: APICaller): P } export async function fetchUpgradeAssistantMetrics( - { adminClient }: ElasticsearchServiceSetup, + { legacy: { client: esClient } }: ElasticsearchServiceStart, savedObjects: SavedObjectsServiceStart ): Promise { const savedObjectsRepository = savedObjects.createInternalRepository(); @@ -60,7 +60,7 @@ export async function fetchUpgradeAssistantMetrics( UPGRADE_ASSISTANT_TYPE, UPGRADE_ASSISTANT_DOC_ID ); - const callAsInternalUser = adminClient.callAsInternalUser.bind(adminClient); + const callAsInternalUser = esClient.callAsInternalUser.bind(esClient); const deprecationLoggingStatusValue = await getDeprecationLoggingStatusValue(callAsInternalUser); const getTelemetrySavedObject = ( @@ -107,7 +107,7 @@ export async function fetchUpgradeAssistantMetrics( } interface Dependencies { - elasticsearch: ElasticsearchServiceSetup; + elasticsearch: ElasticsearchServiceStart; savedObjects: SavedObjectsServiceStart; usageCollection: UsageCollectionSetup; } diff --git a/x-pack/plugins/upgrade_assistant/server/plugin.ts b/x-pack/plugins/upgrade_assistant/server/plugin.ts index 6ccd073a9e020..bdca506cc7338 100644 --- a/x-pack/plugins/upgrade_assistant/server/plugin.ts +++ b/x-pack/plugins/upgrade_assistant/server/plugin.ts @@ -11,7 +11,6 @@ import { CoreStart, PluginInitializerContext, Logger, - ElasticsearchServiceSetup, SavedObjectsClient, SavedObjectsServiceStart, } from '../../../../src/core/server'; @@ -40,7 +39,6 @@ export class UpgradeAssistantServerPlugin implements Plugin { // Properties set at setup private licensing?: LicensingPluginSetup; - private elasticSearchService?: ElasticsearchServiceSetup; // Properties set at start private savedObjectsServiceStart?: SavedObjectsServiceStart; @@ -59,10 +57,9 @@ export class UpgradeAssistantServerPlugin implements Plugin { } setup( - { http, elasticsearch, getStartServices, capabilities }: CoreSetup, + { http, getStartServices, capabilities }: CoreSetup, { usageCollection, cloud, licensing }: PluginsSetup ) { - this.elasticSearchService = elasticsearch; this.licensing = licensing; const router = http.createRouter(); @@ -88,13 +85,13 @@ export class UpgradeAssistantServerPlugin implements Plugin { registerTelemetryRoutes(dependencies); if (usageCollection) { - getStartServices().then(([{ savedObjects }]) => { + getStartServices().then(([{ savedObjects, elasticsearch }]) => { registerUpgradeAssistantUsageCollector({ elasticsearch, usageCollection, savedObjects }); }); } } - start({ savedObjects }: CoreStart) { + start({ savedObjects, elasticsearch }: CoreStart) { this.savedObjectsServiceStart = savedObjects; // The ReindexWorker uses a map of request headers that contain the authentication credentials @@ -107,7 +104,7 @@ export class UpgradeAssistantServerPlugin implements Plugin { this.worker = createReindexWorker({ credentialStore: this.credentialStore, licensing: this.licensing!, - elasticsearchService: this.elasticSearchService!, + elasticsearchService: elasticsearch, logger: this.logger, savedObjects: new SavedObjectsClient( this.savedObjectsServiceStart.createInternalRepository() diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts index 686f93b771e62..3d15916b17ff9 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts @@ -5,7 +5,7 @@ */ import { schema } from '@kbn/config-schema'; import { - ElasticsearchServiceSetup, + ElasticsearchServiceStart, kibanaResponseFactory, Logger, SavedObjectsClient, @@ -38,7 +38,7 @@ import { GetBatchQueueResponse, PostBatchResponse } from './types'; interface CreateReindexWorker { logger: Logger; - elasticsearchService: ElasticsearchServiceSetup; + elasticsearchService: ElasticsearchServiceStart; credentialStore: CredentialStore; savedObjects: SavedObjectsClient; licensing: LicensingPluginSetup; @@ -51,8 +51,8 @@ export function createReindexWorker({ savedObjects, licensing, }: CreateReindexWorker) { - const { adminClient } = elasticsearchService; - return new ReindexWorker(savedObjects, credentialStore, adminClient, logger, licensing); + const esClient = elasticsearchService.legacy.client; + return new ReindexWorker(savedObjects, credentialStore, esClient, logger, licensing); } const mapAnyErrorToKibanaHttpResponse = (e: any) => { diff --git a/x-pack/plugins/uptime/server/graphql/index.ts b/x-pack/plugins/uptime/server/graphql/index.ts index 49ba5583b417b..a88afd7ac769c 100644 --- a/x-pack/plugins/uptime/server/graphql/index.ts +++ b/x-pack/plugins/uptime/server/graphql/index.ts @@ -5,14 +5,13 @@ */ import { createMonitorStatesResolvers, monitorStatesSchema } from './monitor_states'; -import { createPingsResolvers, pingsSchema } from './pings'; +import { pingsSchema } from './pings'; import { CreateUMGraphQLResolvers } from './types'; import { unsignedIntegerResolverFunctions, unsignedIntegerSchema } from './unsigned_int_scalar'; export { DEFAULT_GRAPHQL_PATH } from './constants'; export const resolvers: CreateUMGraphQLResolvers[] = [ createMonitorStatesResolvers, - createPingsResolvers, unsignedIntegerResolverFunctions, ]; export const typeDefs: any[] = [pingsSchema, unsignedIntegerSchema, monitorStatesSchema]; diff --git a/x-pack/plugins/uptime/server/graphql/monitor_states/schema.gql.ts b/x-pack/plugins/uptime/server/graphql/monitor_states/schema.gql.ts index 6ab564fdeb532..040ad99ac0c64 100644 --- a/x-pack/plugins/uptime/server/graphql/monitor_states/schema.gql.ts +++ b/x-pack/plugins/uptime/server/graphql/monitor_states/schema.gql.ts @@ -169,7 +169,7 @@ export const monitorStatesSchema = gql` DESC } - extend type Query { + type Query { "Fetches the current state of Uptime monitors for the given parameters." getMonitorStates( dateRangeStart: String! diff --git a/x-pack/plugins/uptime/server/graphql/pings/index.ts b/x-pack/plugins/uptime/server/graphql/pings/index.ts index 57ec3242a7aa9..100ce29c398ec 100644 --- a/x-pack/plugins/uptime/server/graphql/pings/index.ts +++ b/x-pack/plugins/uptime/server/graphql/pings/index.ts @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { createPingsResolvers } from './resolvers'; export { pingsSchema } from './schema.gql'; diff --git a/x-pack/plugins/uptime/server/graphql/pings/resolvers.ts b/x-pack/plugins/uptime/server/graphql/pings/resolvers.ts deleted file mode 100644 index 2bb1e13bc4b1f..0000000000000 --- a/x-pack/plugins/uptime/server/graphql/pings/resolvers.ts +++ /dev/null @@ -1,59 +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 { UMResolver } from '../../../../../legacy/plugins/uptime/common/graphql/resolver_types'; -import { - AllPingsQueryArgs, - PingResults, -} from '../../../../../legacy/plugins/uptime/common/graphql/types'; -import { UMServerLibs } from '../../lib/lib'; -import { UMContext } from '../types'; -import { CreateUMGraphQLResolvers } from '../types'; -import { savedObjectsAdapter } from '../../lib/saved_objects'; - -export type UMAllPingsResolver = UMResolver< - PingResults | Promise, - any, - AllPingsQueryArgs, - UMContext ->; - -export interface UMPingResolver { - allPings: () => PingResults; -} - -export const createPingsResolvers: CreateUMGraphQLResolvers = ( - libs: UMServerLibs -): { - Query: { - allPings: UMAllPingsResolver; - }; -} => ({ - Query: { - async allPings( - _resolver, - { monitorId, sort, size, status, dateRangeStart, dateRangeEnd, location, page }, - { APICaller, savedObjectsClient } - ): Promise { - const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings( - savedObjectsClient - ); - - return await libs.requests.getPings({ - callES: APICaller, - dynamicSettings, - dateRangeStart, - dateRangeEnd, - monitorId, - status, - sort, - size, - location, - page, - }); - }, - }, -}); diff --git a/x-pack/plugins/uptime/server/graphql/pings/schema.gql.ts b/x-pack/plugins/uptime/server/graphql/pings/schema.gql.ts index 25767fb544104..179bca53db161 100644 --- a/x-pack/plugins/uptime/server/graphql/pings/schema.gql.ts +++ b/x-pack/plugins/uptime/server/graphql/pings/schema.gql.ts @@ -7,10 +7,6 @@ import gql from 'graphql-tag'; export const pingsSchema = gql` - schema { - query: Query - } - type PingResults { "Total number of matching pings" total: UnsignedInteger! @@ -20,28 +16,6 @@ export const pingsSchema = gql` pings: [Ping!]! } - type Query { - "Get a list of all recorded pings for all monitors" - allPings( - "Optional: the direction to sort by. Accepts 'asc' and 'desc'. Defaults to 'desc'." - sort: String - "Optional: the number of results to return." - size: Int - "Optional: the monitor ID filter." - monitorId: String - "Optional: the check status to filter by." - status: String - "The lower limit of the date range." - dateRangeStart: String! - "The upper limit of the date range." - dateRangeEnd: String! - "Optional: agent location to filter by." - location: String - "Optional: current page." - page: Int - ): PingResults! - } - type ContainerImage { name: String tag: String diff --git a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts index 08a3bc75fa8bd..2cc6f23ebaae5 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts @@ -17,6 +17,7 @@ import { IRouter } from 'kibana/server'; import { UMServerLibs } from '../../lib'; import { UptimeCoreSetup } from '../../adapters'; import { defaultDynamicSettings } from '../../../../../../legacy/plugins/uptime/common/runtime_types'; +import { alertsMock, AlertServicesMock } from '../../../../../alerting/server/mocks'; /** * The alert takes some dependencies as parameters; these are things like @@ -44,16 +45,21 @@ const bootstrapDependencies = (customRequests?: any) => { */ const mockOptions = ( params = { numTimes: 5, locations: [], timerange: { from: 'now-15m', to: 'now' } }, - services = { callCluster: 'mockESFunction', savedObjectsClient: mockSavedObjectsClient }, + services = alertsMock.createAlertServices(), state = {} -): any => ({ - params, - services, - state, -}); - -const mockSavedObjectsClient = { get: jest.fn() }; -mockSavedObjectsClient.get.mockReturnValue(defaultDynamicSettings); +): any => { + services.savedObjectsClient.get.mockResolvedValue({ + id: '', + type: '', + references: [], + attributes: defaultDynamicSettings, + }); + return { + params, + services, + state, + }; +}; describe('status check alert', () => { let toISOStringSpy: jest.SpyInstance; @@ -80,8 +86,14 @@ describe('status check alert', () => { expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { - "callES": "mockESFunction", - "dynamicSettings": undefined, + "callES": [MockFunction], + "dynamicSettings": Object { + "certificatesThresholds": Object { + "errorState": 7, + "warningState": 30, + }, + "heartbeatIndices": "heartbeat-8*", + }, "locations": Array [], "numTimes": 5, "timerange": Object { @@ -112,27 +124,23 @@ describe('status check alert', () => { ]); const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); const alert = statusCheckAlertFactory(server, libs); - const mockInstanceFactory = jest.fn(); - const mockReplaceState = jest.fn(); - const mockScheduleActions = jest.fn(); - mockInstanceFactory.mockReturnValue({ - replaceState: mockReplaceState, - scheduleActions: mockScheduleActions, - }); const options = mockOptions(); - options.services = { - ...options.services, - alertInstanceFactory: mockInstanceFactory, - }; + const alertServices: AlertServicesMock = options.services; // @ts-ignore the executor can return `void`, but ours never does const state: Record = await alert.executor(options); expect(mockGetter).toHaveBeenCalledTimes(1); - expect(mockInstanceFactory).toHaveBeenCalledTimes(1); + expect(alertServices.alertInstanceFactory).toHaveBeenCalledTimes(1); expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { - "callES": "mockESFunction", - "dynamicSettings": undefined, + "callES": [MockFunction], + "dynamicSettings": Object { + "certificatesThresholds": Object { + "errorState": 7, + "warningState": 30, + }, + "heartbeatIndices": "heartbeat-8*", + }, "locations": Array [], "numTimes": 5, "timerange": Object { @@ -142,8 +150,9 @@ describe('status check alert', () => { }, ] `); - expect(mockReplaceState).toHaveBeenCalledTimes(1); - expect(mockReplaceState.mock.calls[0]).toMatchInlineSnapshot(` + const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; + expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(1); + expect(alertInstanceMock.replaceState.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { "currentTriggerStarted": "foo date string", @@ -170,8 +179,8 @@ describe('status check alert', () => { }, ] `); - expect(mockScheduleActions).toHaveBeenCalledTimes(1); - expect(mockScheduleActions.mock.calls[0]).toMatchInlineSnapshot(` + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledTimes(1); + expect(alertInstanceMock.scheduleActions.mock.calls[0]).toMatchInlineSnapshot(` Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", Object { diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/__snapshots__/get_monitor_charts.test.ts.snap b/x-pack/plugins/uptime/server/lib/requests/__tests__/__snapshots__/get_monitor_charts.test.ts.snap index 7b717949c70c5..97b97f8440758 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/__snapshots__/get_monitor_charts.test.ts.snap +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/__snapshots__/get_monitor_charts.test.ts.snap @@ -15,13 +15,6 @@ Array [ "field": "monitor.duration.us", }, }, - "status": Object { - "terms": Object { - "field": "monitor.status", - "shard_size": 2, - "size": 2, - }, - }, }, "terms": Object { "field": "observer.geo.name", @@ -55,8 +48,10 @@ Array [ }, }, Object { - "term": Object { - "monitor.status": "up", + "range": Object { + "monitor.duration.us": Object { + "gt": 0, + }, }, }, ], @@ -71,7 +66,6 @@ Array [ exports[`ElasticsearchMonitorsAdapter inserts empty buckets for missing data 1`] = ` Object { - "durationMaxValue": 0, "locationDurationLines": Array [ Object { "line": Array [ @@ -244,128 +238,5 @@ Object { "name": "us-west-4", }, ], - "status": Array [ - Object { - "down": null, - "total": 4, - "up": null, - "x": 1568411568000, - }, - Object { - "down": null, - "total": 0, - "up": null, - "x": 1568411604000, - }, - Object { - "down": null, - "total": 8, - "up": null, - "x": 1568411640000, - }, - Object { - "down": null, - "total": 8, - "up": null, - "x": 1568411784000, - }, - Object { - "down": null, - "total": 0, - "up": null, - "x": 1568411820000, - }, - Object { - "down": null, - "total": 0, - "up": null, - "x": 1568411856000, - }, - Object { - "down": null, - "total": 0, - "up": null, - "x": 1568411892000, - }, - Object { - "down": null, - "total": 4, - "up": null, - "x": 1568411928000, - }, - Object { - "down": null, - "total": 7, - "up": null, - "x": 1568411964000, - }, - Object { - "down": null, - "total": 5, - "up": null, - "x": 1568412036000, - }, - Object { - "down": null, - "total": 4, - "up": null, - "x": 1568412072000, - }, - Object { - "down": null, - "total": 3, - "up": null, - "x": 1568412108000, - }, - Object { - "down": null, - "total": 4, - "up": null, - "x": 1568412144000, - }, - Object { - "down": null, - "total": 4, - "up": null, - "x": 1568412180000, - }, - Object { - "down": null, - "total": 3, - "up": null, - "x": 1568412216000, - }, - Object { - "down": null, - "total": 1, - "up": null, - "x": 1568412252000, - }, - Object { - "down": null, - "total": 3, - "up": null, - "x": 1568412288000, - }, - Object { - "down": null, - "total": 8, - "up": null, - "x": 1568412324000, - }, - Object { - "down": null, - "total": 8, - "up": null, - "x": 1568412432000, - }, - Object { - "down": null, - "total": 1, - "up": null, - "x": 1568412468000, - }, - ], - "statusMaxCount": 0, } `; diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_latest_monitor.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_latest_monitor.test.ts index 112c8e97d4c00..cf8414a3b0a68 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_latest_monitor.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_latest_monitor.test.ts @@ -42,10 +42,16 @@ describe('getLatestMonitor', () => { hits: { hits: [ { + _id: 'fejwio32', _source: { - timestamp: 123456, + '@timestamp': '123456', monitor: { + duration: { + us: 12345, + }, id: 'testMonitor', + status: 'down', + type: 'http', }, }, }, @@ -64,7 +70,22 @@ describe('getLatestMonitor', () => { monitorId: 'testMonitor', }); - expect(result.timestamp).toBe(123456); + expect(result).toMatchInlineSnapshot(` + Object { + "@timestamp": "123456", + "docId": "fejwio32", + "monitor": Object { + "duration": Object { + "us": 12345, + }, + "id": "testMonitor", + "status": "down", + "type": "http", + }, + "timestamp": "123456", + } + `); + expect(result.timestamp).toBe('123456'); expect(result.monitor).not.toBeFalsy(); expect(result?.monitor?.id).toBe('testMonitor'); expect(mockEsClient).toHaveBeenCalledWith('search', expectedGetLatestSearchParams); diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_pings.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_pings.test.ts index 9145ccca1b6d1..fcf773db23de6 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_pings.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_pings.test.ts @@ -17,16 +17,34 @@ describe('getAll', () => { { _source: { '@timestamp': '2018-10-30T18:51:59.792Z', + monitor: { + duration: { us: 2134 }, + id: 'foo', + status: 'up', + type: 'http', + }, }, }, { _source: { '@timestamp': '2018-10-30T18:53:59.792Z', + monitor: { + duration: { us: 2131 }, + id: 'foo', + status: 'up', + type: 'http', + }, }, }, { _source: { '@timestamp': '2018-10-30T18:55:59.792Z', + monitor: { + duration: { us: 2132 }, + id: 'foo', + status: 'up', + type: 'http', + }, }, }, ]; @@ -48,7 +66,7 @@ describe('getAll', () => { body: { query: { bool: { - filter: [{ range: { '@timestamp': { gte: 'now-1h', lte: 'now' } } }], + filter: [{ range: { timestamp: { gte: 'now-1h', lte: 'now' } } }], }, }, aggregations: { @@ -60,8 +78,7 @@ describe('getAll', () => { }, }, }, - sort: [{ '@timestamp': { order: 'desc' } }], - size: 12, + sort: [{ timestamp: { order: 'desc' } }], }, }; }); @@ -72,8 +89,7 @@ describe('getAll', () => { const result = await getPings({ callES: mockEsClient, dynamicSettings: defaultDynamicSettings, - dateRangeStart: 'now-1h', - dateRangeEnd: 'now', + dateRange: { from: 'now-1h', to: 'now' }, sort: 'asc', size: 12, }); @@ -95,15 +111,54 @@ describe('getAll', () => { await getPings({ callES: mockEsClient, dynamicSettings: defaultDynamicSettings, - dateRangeStart: 'now-1h', - dateRangeEnd: 'now', + dateRange: { from: 'now-1h', to: 'now' }, sort: 'asc', size: 12, }); - set(expectedGetAllParams, 'body.sort[0]', { '@timestamp': { order: 'asc' } }); + set(expectedGetAllParams, 'body.sort[0]', { timestamp: { order: 'asc' } }); expect(mockEsClient).toHaveBeenCalledTimes(1); - expect(mockEsClient).toHaveBeenCalledWith('search', expectedGetAllParams); + expect(mockEsClient.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "search", + Object { + "body": Object { + "aggregations": Object { + "locations": Object { + "terms": Object { + "field": "observer.geo.name", + "missing": "N/A", + "size": 1000, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-1h", + "lte": "now", + }, + }, + }, + ], + }, + }, + "size": 12, + "sort": Array [ + Object { + "@timestamp": Object { + "order": "asc", + }, + }, + ], + }, + "index": "heartbeat-8*", + }, + ] + `); }); it('omits the sort param when no sort passed', async () => { @@ -112,12 +167,52 @@ describe('getAll', () => { await getPings({ callES: mockEsClient, dynamicSettings: defaultDynamicSettings, - dateRangeStart: 'now-1h', - dateRangeEnd: 'now', + dateRange: { from: 'now-1h', to: 'now' }, size: 12, }); - expect(mockEsClient).toHaveBeenCalledWith('search', expectedGetAllParams); + expect(mockEsClient).toHaveBeenCalledTimes(1); + expect(mockEsClient.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "search", + Object { + "body": Object { + "aggregations": Object { + "locations": Object { + "terms": Object { + "field": "observer.geo.name", + "missing": "N/A", + "size": 1000, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-1h", + "lte": "now", + }, + }, + }, + ], + }, + }, + "size": 12, + "sort": Array [ + Object { + "@timestamp": Object { + "order": "desc", + }, + }, + ], + }, + "index": "heartbeat-8*", + }, + ] + `); }); it('omits the size param when no size passed', async () => { @@ -126,14 +221,52 @@ describe('getAll', () => { await getPings({ callES: mockEsClient, dynamicSettings: defaultDynamicSettings, - dateRangeStart: 'now-1h', - dateRangeEnd: 'now', + dateRange: { from: 'now-1h', to: 'now' }, sort: 'desc', }); - delete expectedGetAllParams.body.size; - set(expectedGetAllParams, 'body.sort[0].@timestamp.order', 'desc'); - expect(mockEsClient).toHaveBeenCalledWith('search', expectedGetAllParams); + expect(mockEsClient).toHaveBeenCalledTimes(1); + expect(mockEsClient.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "search", + Object { + "body": Object { + "aggregations": Object { + "locations": Object { + "terms": Object { + "field": "observer.geo.name", + "missing": "N/A", + "size": 1000, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-1h", + "lte": "now", + }, + }, + }, + ], + }, + }, + "size": 25, + "sort": Array [ + Object { + "@timestamp": Object { + "order": "desc", + }, + }, + ], + }, + "index": "heartbeat-8*", + }, + ] + `); }); it('adds a filter for monitor ID', async () => { @@ -142,14 +275,57 @@ describe('getAll', () => { await getPings({ callES: mockEsClient, dynamicSettings: defaultDynamicSettings, - dateRangeStart: 'now-1h', - dateRangeEnd: 'now', + dateRange: { from: 'now-1h', to: 'now' }, monitorId: 'testmonitorid', }); - delete expectedGetAllParams.body.size; - expectedGetAllParams.body.query.bool.filter.push({ term: { 'monitor.id': 'testmonitorid' } }); - expect(mockEsClient).toHaveBeenCalledWith('search', expectedGetAllParams); + expect(mockEsClient).toHaveBeenCalledTimes(1); + expect(mockEsClient.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "search", + Object { + "body": Object { + "aggregations": Object { + "locations": Object { + "terms": Object { + "field": "observer.geo.name", + "missing": "N/A", + "size": 1000, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-1h", + "lte": "now", + }, + }, + }, + Object { + "term": Object { + "monitor.id": "testmonitorid", + }, + }, + ], + }, + }, + "size": 25, + "sort": Array [ + Object { + "@timestamp": Object { + "order": "desc", + }, + }, + ], + }, + "index": "heartbeat-8*", + }, + ] + `); }); it('adds a filter for monitor status', async () => { @@ -158,13 +334,56 @@ describe('getAll', () => { await getPings({ callES: mockEsClient, dynamicSettings: defaultDynamicSettings, - dateRangeStart: 'now-1h', - dateRangeEnd: 'now', + dateRange: { from: 'now-1h', to: 'now' }, status: 'down', }); - delete expectedGetAllParams.body.size; - expectedGetAllParams.body.query.bool.filter.push({ term: { 'monitor.status': 'down' } }); - expect(mockEsClient).toHaveBeenCalledWith('search', expectedGetAllParams); + expect(mockEsClient).toHaveBeenCalledTimes(1); + expect(mockEsClient.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "search", + Object { + "body": Object { + "aggregations": Object { + "locations": Object { + "terms": Object { + "field": "observer.geo.name", + "missing": "N/A", + "size": 1000, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-1h", + "lte": "now", + }, + }, + }, + Object { + "term": Object { + "monitor.status": "down", + }, + }, + ], + }, + }, + "size": 25, + "sort": Array [ + Object { + "@timestamp": Object { + "order": "desc", + }, + }, + ], + }, + "index": "heartbeat-8*", + }, + ] + `); }); }); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_latest_monitor.ts b/x-pack/plugins/uptime/server/lib/requests/get_latest_monitor.ts index 299e3eb6ca3cf..a8e9ccb875a08 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_latest_monitor.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_latest_monitor.ts @@ -5,7 +5,7 @@ */ import { UMElasticsearchQueryFn } from '../adapters'; -import { Ping } from '../../../../../legacy/plugins/uptime/common/graphql/types'; +import { Ping } from '../../../../../legacy/plugins/uptime/common/runtime_types'; export interface GetLatestMonitorParams { /** @member dateRangeStart timestamp bounds */ @@ -53,11 +53,9 @@ export const getLatestMonitor: UMElasticsearchQueryFn { - let up = null; - let down = null; - - buckets.forEach((bucket: any) => { - if (bucket.key === 'up') { - up = bucket.doc_count; - } else if (bucket.key === 'down') { - down = bucket.doc_count; - } - }); - - return { - x: time, - up, - down, - total: docCount, - }; -}; - /** * Fetches data used to populate monitor charts */ @@ -55,7 +35,7 @@ export const getMonitorDurationChart: UMElasticsearchQueryFn< filter: [ { range: { '@timestamp': { gte: dateStart, lte: dateEnd } } }, { term: { 'monitor.id': monitorId } }, - { term: { 'monitor.status': 'up' } }, + { range: { 'monitor.duration.us': { gt: 0 } } }, ], }, }, @@ -73,7 +53,6 @@ export const getMonitorDurationChart: UMElasticsearchQueryFn< missing: 'N/A', }, aggs: { - status: { terms: { field: 'monitor.status', size: 2, shard_size: 2 } }, duration: { stats: { field: 'monitor.duration.us' } }, }, }, @@ -94,15 +73,10 @@ export const getMonitorDurationChart: UMElasticsearchQueryFn< * * The third list is for an area chart expressing a range, and it requires an (x,y,y0) structure, * where y0 is the min value for the point and y is the max. - * - * Additionally, we supply the maximum value for duration and status, so the corresponding charts know - * what the domain size should be. */ + const monitorChartsData: MonitorDurationResult = { locationDurationLines: [], - status: [], - durationMaxValue: 0, - statusMaxCount: 0, }; /** @@ -119,9 +93,9 @@ export const getMonitorDurationChart: UMElasticsearchQueryFn< // a set of all the locations found for this result const resultLocations = new Set(); const linesByLocation: { [key: string]: LocationDurationLine } = {}; + dateHistogramBuckets.forEach(dateHistogramBucket => { const x = dateHistogramBucket.key; - const docCount = dateHistogramBucket?.doc_count ?? 0; // a set of all the locations for the current bucket const bucketLocations = new Set(); @@ -161,10 +135,6 @@ export const getMonitorDurationChart: UMElasticsearchQueryFn< } }); } - - monitorChartsData.status.push( - formatStatusBuckets(x, dateHistogramBucket?.status?.buckets ?? [], docCount) - ); }); return monitorChartsData; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts index a49e4c8931142..5a8927764ea5c 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts @@ -7,8 +7,10 @@ import { UMElasticsearchQueryFn } from '../adapters'; import { QUERY } from '../../../../../legacy/plugins/uptime/common/constants'; import { getFilterClause } from '../helper'; -import { HistogramQueryResult } from './types'; -import { HistogramResult } from '../../../../../legacy/plugins/uptime/common/types'; +import { + HistogramResult, + HistogramQueryResult, +} from '../../../../../legacy/plugins/uptime/common/runtime_types'; export interface GetPingHistogramParams { /** @member dateRangeStart timestamp bounds */ diff --git a/x-pack/plugins/uptime/server/lib/requests/get_pings.ts b/x-pack/plugins/uptime/server/lib/requests/get_pings.ts index c64b5f3ad4af4..6eccfdb13cef7 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_pings.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_pings.ts @@ -6,52 +6,28 @@ import { UMElasticsearchQueryFn } from '../adapters/framework'; import { - PingResults, + GetPingsParams, + HttpResponseBody, + PingsResponse, Ping, - HttpBody, -} from '../../../../../legacy/plugins/uptime/common/graphql/types'; +} from '../../../../../legacy/plugins/uptime/common/runtime_types'; -export interface GetPingsParams { - /** @member dateRangeStart timestamp bounds */ - dateRangeStart: string; +const DEFAULT_PAGE_SIZE = 25; - /** @member dateRangeEnd timestamp bounds */ - dateRangeEnd: string; - - /** @member monitorId optional limit by monitorId */ - monitorId?: string | null; - - /** @member status optional limit by check statuses */ - status?: string | null; - - /** @member sort optional sort by timestamp */ - sort?: string | null; - - /** @member size optional limit query size */ - size?: number | null; - - /** @member location optional location value for use in filtering*/ - location?: string | null; - - /** @member page the number to provide to Elasticsearch as the "from" parameter */ - page?: number; -} - -export const getPings: UMElasticsearchQueryFn = async ({ +export const getPings: UMElasticsearchQueryFn = async ({ callES, dynamicSettings, - dateRangeStart, - dateRangeEnd, + dateRange: { from, to }, + index, monitorId, status, sort, - size, + size: sizeParam, location, - page, }) => { + const size = sizeParam ?? DEFAULT_PAGE_SIZE; const sortParam = { sort: [{ '@timestamp': { order: sort ?? 'desc' } }] }; - const sizeParam = size ? { size } : undefined; - const filter: any[] = [{ range: { '@timestamp': { gte: dateRangeStart, lte: dateRangeEnd } } }]; + const filter: any[] = [{ range: { '@timestamp': { gte: from, lte: to } } }]; if (monitorId) { filter.push({ term: { 'monitor.id': monitorId } }); } @@ -71,7 +47,7 @@ export const getPings: UMElasticsearchQueryFn = asy ...queryContext, }, ...sortParam, - ...sizeParam, + size, aggregations: { locations: { terms: { @@ -85,8 +61,8 @@ export const getPings: UMElasticsearchQueryFn = asy }, }; - if (page) { - params.body.from = page * (size ?? 25); + if (index) { + params.body.from = index * size; } const { @@ -96,25 +72,22 @@ export const getPings: UMElasticsearchQueryFn = asy const locations = aggs?.locations ?? { buckets: [{ key: 'N/A', doc_count: 0 }] }; - const pings: Ping[] = hits.map(({ _id, _source }: any) => { - const timestamp = _source['@timestamp']; - + const pings: Ping[] = hits.map((doc: any) => { + const { _id, _source } = doc; // Calculate here the length of the content string in bytes, this is easier than in client JS, where // we don't have access to Buffer.byteLength. There are some hacky ways to do this in the // client but this is cleaner. - const httpBody: HttpBody | undefined = _source?.http?.response?.body; + const httpBody: HttpResponseBody | undefined = _source?.http?.response?.body; if (httpBody && httpBody.content) { httpBody.content_bytes = Buffer.byteLength(httpBody.content); } - return { id: _id, timestamp, ..._source }; + return { ..._source, timestamp: _source['@timestamp'], docId: _id }; }); - const results: PingResults = { + return { total: total.value, locations: locations.buckets.map((bucket: { key: string }) => bucket.key), pings, }; - - return results; }; diff --git a/x-pack/plugins/uptime/server/lib/requests/index.ts b/x-pack/plugins/uptime/server/lib/requests/index.ts index 445adc3c15a93..6317f665d377f 100644 --- a/x-pack/plugins/uptime/server/lib/requests/index.ts +++ b/x-pack/plugins/uptime/server/lib/requests/index.ts @@ -13,7 +13,7 @@ export { getMonitorLocations, GetMonitorLocationsParams } from './get_monitor_lo export { getMonitorStates, GetMonitorStatesParams } from './get_monitor_states'; export { getMonitorStatus, GetMonitorStatusParams } from './get_monitor_status'; export * from './get_monitor_status'; -export { getPings, GetPingsParams } from './get_pings'; +export { getPings } from './get_pings'; export { getPingHistogram, GetPingHistogramParams } from './get_ping_histogram'; export { UptimeRequests } from './uptime_requests'; export { getSnapshotCount, GetSnapshotCountParams } from './get_snapshot_counts'; diff --git a/x-pack/plugins/uptime/server/lib/requests/types.ts b/x-pack/plugins/uptime/server/lib/requests/types.ts deleted file mode 100644 index 53a4e989e3789..0000000000000 --- a/x-pack/plugins/uptime/server/lib/requests/types.ts +++ /dev/null @@ -1,72 +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 { Ping, PingResults } from '../../../../../legacy/plugins/uptime/common/graphql/types'; -import { UMElasticsearchQueryFn } from '../adapters'; -import { - GetPingHistogramParams, - HistogramResult, -} from '../../../../../legacy/plugins/uptime/common/types'; - -export interface GetAllParams { - /** @member dateRangeStart timestamp bounds */ - dateRangeStart: string; - - /** @member dateRangeEnd timestamp bounds */ - dateRangeEnd: string; - - /** @member monitorId optional limit by monitorId */ - monitorId?: string | null; - - /** @member status optional limit by check statuses */ - status?: string | null; - - /** @member sort optional sort by timestamp */ - sort?: string | null; - - /** @member size optional limit query size */ - size?: number | null; - - /** @member location optional location value for use in filtering*/ - location?: string | null; -} - -export interface GetLatestMonitorDocsParams { - /** @member dateRangeStart timestamp bounds */ - dateStart?: string; - - /** @member dateRangeEnd timestamp bounds */ - dateEnd?: string; - - /** @member monitorId optional limit to monitorId */ - monitorId?: string | null; -} - -/** - * Count the number of documents in heartbeat indices - */ -export interface UMPingsAdapter { - getAll: UMElasticsearchQueryFn; - - // Get the monitor meta info regardless of timestamp - getMonitor: UMElasticsearchQueryFn; - - getLatestMonitorStatus: UMElasticsearchQueryFn; - - getPingHistogram: UMElasticsearchQueryFn; -} - -export interface HistogramQueryResult { - key: number; - key_as_string: string; - doc_count: number; - down: { - doc_count: number; - }; - up: { - doc_count: number; - }; -} diff --git a/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts b/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts index 9d3fa5aa08aed..e9a7aa94dd3aa 100644 --- a/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts +++ b/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts @@ -5,7 +5,12 @@ */ import { UMElasticsearchQueryFn } from '../adapters'; -import { Ping, PingResults } from '../../../../../legacy/plugins/uptime/common/graphql/types'; +import { + HistogramResult, + Ping, + PingsResponse as PingResults, + GetPingsParams, +} from '../../../../../legacy/plugins/uptime/common/runtime_types'; import { GetFilterBarParams, GetLatestMonitorParams, @@ -13,7 +18,6 @@ import { GetMonitorDetailsParams, GetMonitorLocationsParams, GetMonitorStatesParams, - GetPingsParams, GetPingHistogramParams, GetMonitorStatusParams, GetMonitorStatusResult, @@ -27,10 +31,7 @@ import { } from '../../../../../legacy/plugins/uptime/common/runtime_types'; import { GetMonitorStatesResult } from './get_monitor_states'; import { GetSnapshotCountParams } from './get_snapshot_counts'; -import { - HistogramResult, - MonitorDurationResult, -} from '../../../../../legacy/plugins/uptime/common/types'; +import { MonitorDurationResult } from '../../../../../legacy/plugins/uptime/common/types'; type ESQ = UMElasticsearchQueryFn; diff --git a/x-pack/plugins/uptime/server/lib/saved_objects.ts b/x-pack/plugins/uptime/server/lib/saved_objects.ts index 175634ef797cc..3ccfd498c44bf 100644 --- a/x-pack/plugins/uptime/server/lib/saved_objects.ts +++ b/x-pack/plugins/uptime/server/lib/saved_objects.ts @@ -7,14 +7,10 @@ import { DynamicSettings, defaultDynamicSettings, -} from '../../../../legacy/plugins/uptime/common/runtime_types/dynamic_settings'; +} from '../../../../legacy/plugins/uptime/common/runtime_types'; import { SavedObjectsType, SavedObjectsErrorHelpers } from '../../../../../src/core/server'; import { UMSavedObjectsQueryFn } from './adapters'; -export interface UMDynamicSettingsType { - heartbeatIndices: string; -} - export interface UMSavedObjectsAdapter { getUptimeDynamicSettings: UMSavedObjectsQueryFn; setUptimeDynamicSettings: UMSavedObjectsQueryFn; @@ -26,12 +22,22 @@ export const settingsObjectId = 'uptime-dynamic-settings-singleton'; export const umDynamicSettings: SavedObjectsType = { name: settingsObjectType, hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', mappings: { properties: { heartbeatIndices: { type: 'keyword', }, + certificatesThresholds: { + properties: { + errorState: { + type: 'long', + }, + warningState: { + type: 'long', + }, + }, + }, }, }, }; diff --git a/x-pack/plugins/uptime/server/rest_api/index.ts b/x-pack/plugins/uptime/server/rest_api/index.ts index c84ea71037953..c0412e588fa93 100644 --- a/x-pack/plugins/uptime/server/rest_api/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/index.ts @@ -5,7 +5,7 @@ */ import { createGetOverviewFilters } from './overview_filters'; -import { createGetPingsRoute } from './pings'; +import { createGetPingHistogramRoute, createGetPingsRoute } from './pings'; import { createGetDynamicSettingsRoute, createPostDynamicSettingsRoute } from './dynamic_settings'; import { createLogPageViewRoute } from './telemetry'; import { createGetSnapshotCount } from './snapshot'; @@ -15,10 +15,8 @@ import { createGetMonitorLocationsRoute, createGetStatusBarRoute, } from './monitors'; -import { createGetPingHistogramRoute } from './pings/get_ping_histogram'; import { createGetMonitorDurationRoute } from './monitors/monitors_durations'; import { createGetIndexPatternRoute, createGetIndexStatusRoute } from './index_state'; - export * from './types'; export { createRouteWithAuth } from './create_route_with_auth'; export { uptimeRouteWrapper } from './uptime_route_wrapper'; diff --git a/x-pack/plugins/uptime/server/rest_api/pings/get_all.ts b/x-pack/plugins/uptime/server/rest_api/pings/get_all.ts deleted file mode 100644 index c76892103da6b..0000000000000 --- a/x-pack/plugins/uptime/server/rest_api/pings/get_all.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { UMServerLibs } from '../../lib/lib'; -import { UMRestApiRouteFactory } from '../types'; -import { API_URLS } from '../../../../../legacy/plugins/uptime/common/constants/rest_api'; - -export const createGetAllRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ - method: 'GET', - path: API_URLS.PINGS, - validate: { - query: schema.object({ - dateRangeStart: schema.string(), - dateRangeEnd: schema.string(), - location: schema.maybe(schema.string()), - monitorId: schema.maybe(schema.string()), - size: schema.maybe(schema.number()), - sort: schema.maybe(schema.string()), - status: schema.maybe(schema.string()), - }), - }, - handler: async ({ callES, dynamicSettings }, _context, request, response): Promise => { - const { dateRangeStart, dateRangeEnd, location, monitorId, size, sort, status } = request.query; - - const result = await libs.requests.getPings({ - callES, - dynamicSettings, - dateRangeStart, - dateRangeEnd, - monitorId, - status, - sort, - size, - location, - }); - - return response.ok({ - body: { - ...result, - }, - }); - }, -}); diff --git a/x-pack/plugins/uptime/server/rest_api/pings/get_pings.ts b/x-pack/plugins/uptime/server/rest_api/pings/get_pings.ts index cde9a8c4e47ea..80a887a7f64a9 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/get_pings.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/get_pings.ts @@ -5,37 +5,41 @@ */ import { schema } from '@kbn/config-schema'; +import { isLeft } from 'fp-ts/lib/Either'; +import { PathReporter } from 'io-ts/lib/PathReporter'; import { UMServerLibs } from '../../lib/lib'; import { UMRestApiRouteFactory } from '../types'; import { API_URLS } from '../../../../../legacy/plugins/uptime/common/constants/rest_api'; +import { GetPingsParamsType } from '../../../../../legacy/plugins/uptime/common/runtime_types'; export const createGetPingsRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', path: API_URLS.PINGS, validate: { query: schema.object({ - dateRangeStart: schema.string(), - dateRangeEnd: schema.string(), + from: schema.string(), + to: schema.string(), location: schema.maybe(schema.string()), monitorId: schema.maybe(schema.string()), + index: schema.maybe(schema.number()), size: schema.maybe(schema.number()), sort: schema.maybe(schema.string()), status: schema.maybe(schema.string()), }), }, handler: async ({ callES, dynamicSettings }, _context, request, response): Promise => { - const { dateRangeStart, dateRangeEnd, location, monitorId, size, sort, status } = request.query; + const { from, to, ...optional } = request.query; + const params = GetPingsParamsType.decode({ dateRange: { from, to }, ...optional }); + if (isLeft(params)) { + // eslint-disable-next-line no-console + console.error(new Error(PathReporter.report(params).join(';'))); + return response.badRequest({ body: { message: 'Received invalid request parameters.' } }); + } const result = await libs.requests.getPings({ callES, dynamicSettings, - dateRangeStart, - dateRangeEnd, - monitorId, - status, - sort, - size, - location, + ...params.right, }); return response.ok({ diff --git a/x-pack/plugins/uptime/server/rest_api/pings/index.ts b/x-pack/plugins/uptime/server/rest_api/pings/index.ts index abb7da26f994f..a10ab435e4b0a 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/index.ts @@ -5,3 +5,4 @@ */ export { createGetPingsRoute } from './get_pings'; +export { createGetPingHistogramRoute } from './get_ping_histogram'; diff --git a/x-pack/plugins/uptime/server/rest_api/types.ts b/x-pack/plugins/uptime/server/rest_api/types.ts index aecb099b7bed5..e05e7a4d7faf1 100644 --- a/x-pack/plugins/uptime/server/rest_api/types.ts +++ b/x-pack/plugins/uptime/server/rest_api/types.ts @@ -10,7 +10,7 @@ import { RouteConfig, RouteMethod, CallAPIOptions, - SavedObjectsClient, + SavedObjectsClientContract, RequestHandlerContext, KibanaRequest, KibanaResponseFactory, @@ -69,18 +69,7 @@ export interface UMRouteParams { options?: CallAPIOptions | undefined ) => Promise; dynamicSettings: DynamicSettings; - savedObjectsClient: Pick< - SavedObjectsClient, - | 'errors' - | 'create' - | 'bulkCreate' - | 'delete' - | 'find' - | 'bulkGet' - | 'get' - | 'update' - | 'bulkUpdate' - >; + savedObjectsClient: SavedObjectsClientContract; } /** diff --git a/x-pack/plugins/watcher/__jest__/client_integration/watch_create_json.test.ts b/x-pack/plugins/watcher/__jest__/client_integration/watch_create_json.test.ts index 2096c0dd61bd8..a35dd1346dc90 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/watch_create_json.test.ts +++ b/x-pack/plugins/watcher/__jest__/client_integration/watch_create_json.test.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import '../../../es_ui_shared/console_lang/mocks'; - import { act } from 'react-dom/test-utils'; import { setupEnvironment, pageHelpers, nextTick, wrapBodyResponse } from './helpers'; import { WatchCreateJsonTestBed } from './helpers/watch_create_json.helpers'; @@ -108,6 +106,7 @@ describe(' create route', () => { name: watch.name, type: watch.type, isNew: true, + isActive: true, actions: [ { id: DEFAULT_LOGGING_ACTION_ID, @@ -185,6 +184,7 @@ describe(' create route', () => { id, type, isNew: true, + isActive: true, actions: [], watch: defaultWatchJson, }; @@ -246,6 +246,7 @@ describe(' create route', () => { id, type, isNew: true, + isActive: true, actions: [], watch: defaultWatchJson, }; diff --git a/x-pack/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx b/x-pack/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx index 943233d3c14ed..89cd5207fe250 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx +++ b/x-pack/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import '../../../es_ui_shared/console_lang/mocks'; - import React from 'react'; import { act } from 'react-dom/test-utils'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; @@ -244,6 +242,7 @@ describe(' create route', () => { name: WATCH_NAME, type: WATCH_TYPES.THRESHOLD, isNew: true, + isActive: true, actions: [ { id: 'logging_1', @@ -314,6 +313,7 @@ describe(' create route', () => { name: WATCH_NAME, type: WATCH_TYPES.THRESHOLD, isNew: true, + isActive: true, actions: [ { id: 'index_1', @@ -376,6 +376,7 @@ describe(' create route', () => { name: WATCH_NAME, type: WATCH_TYPES.THRESHOLD, isNew: true, + isActive: true, actions: [ { id: 'slack_1', @@ -448,6 +449,7 @@ describe(' create route', () => { name: WATCH_NAME, type: WATCH_TYPES.THRESHOLD, isNew: true, + isActive: true, actions: [ { id: 'email_1', @@ -540,6 +542,7 @@ describe(' create route', () => { name: WATCH_NAME, type: WATCH_TYPES.THRESHOLD, isNew: true, + isActive: true, actions: [ { id: 'webhook_1', @@ -629,6 +632,7 @@ describe(' create route', () => { name: WATCH_NAME, type: WATCH_TYPES.THRESHOLD, isNew: true, + isActive: true, actions: [ { id: 'jira_1', @@ -709,6 +713,7 @@ describe(' create route', () => { name: WATCH_NAME, type: WATCH_TYPES.THRESHOLD, isNew: true, + isActive: true, actions: [ { id: 'pagerduty_1', @@ -772,6 +777,7 @@ describe(' create route', () => { name: WATCH_NAME, type: WATCH_TYPES.THRESHOLD, isNew: true, + isActive: true, actions: [], index: MATCH_INDICES, timeField: WATCH_TIME_FIELD, diff --git a/x-pack/plugins/watcher/__jest__/client_integration/watch_edit.test.ts b/x-pack/plugins/watcher/__jest__/client_integration/watch_edit.test.ts index 51285a5786b00..18acf7339580c 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/watch_edit.test.ts +++ b/x-pack/plugins/watcher/__jest__/client_integration/watch_edit.test.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 '../../../es_ui_shared/console_lang/mocks'; import { act } from 'react-dom/test-utils'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; @@ -98,6 +97,7 @@ describe('', () => { name: EDITED_WATCH_NAME, type: watch.type, isNew: false, + isActive: true, actions: [ { id: DEFAULT_LOGGING_ACTION_ID, @@ -191,6 +191,7 @@ describe('', () => { name: EDITED_WATCH_NAME, type, isNew: false, + isActive: true, actions: [], timeField, triggerIntervalSize, diff --git a/x-pack/plugins/watcher/__jest__/client_integration/watch_list.test.ts b/x-pack/plugins/watcher/__jest__/client_integration/watch_list.test.ts index 3370e42b2417f..a0327c6dfa1db 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/watch_list.test.ts +++ b/x-pack/plugins/watcher/__jest__/client_integration/watch_list.test.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 '../../../es_ui_shared/console_lang/mocks'; import { act } from 'react-dom/test-utils'; import * as fixtures from '../../test/fixtures'; diff --git a/x-pack/plugins/watcher/__jest__/client_integration/watch_status.test.ts b/x-pack/plugins/watcher/__jest__/client_integration/watch_status.test.ts index 20b7b526705c0..e9cbb2c92491a 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/watch_status.test.ts +++ b/x-pack/plugins/watcher/__jest__/client_integration/watch_status.test.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 '../../../es_ui_shared/console_lang/mocks'; import { act } from 'react-dom/test-utils'; import { setupEnvironment, pageHelpers, nextTick } from './helpers'; diff --git a/x-pack/plugins/watcher/public/application/models/watch/base_watch.js b/x-pack/plugins/watcher/public/application/models/watch/base_watch.js index 3fe4fb006d241..af2e45b35501e 100644 --- a/x-pack/plugins/watcher/public/application/models/watch/base_watch.js +++ b/x-pack/plugins/watcher/public/application/models/watch/base_watch.js @@ -32,6 +32,7 @@ export class BaseWatch { this.isSystemWatch = Boolean(get(props, 'isSystemWatch')); this.watchStatus = WatchStatus.fromUpstreamJson(get(props, 'watchStatus')); this.watchErrors = WatchErrors.fromUpstreamJson(get(props, 'watchErrors')); + this.isActive = this.watchStatus.isActive ?? true; const actions = get(props, 'actions', []); this.actions = actions.map(Action.fromUpstreamJson); @@ -115,6 +116,7 @@ export class BaseWatch { name: this.name, type: this.type, isNew: this.isNew, + isActive: this.isActive, actions: map(this.actions, action => action.upstreamJson), }; } diff --git a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_form.tsx b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_form.tsx index b3a09d3bc0e65..b5835d862000f 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_form.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_form.tsx @@ -23,14 +23,13 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { serializeJsonWatch } from '../../../../../../common/lib/serialization'; import { ErrableFormRow, SectionError, Error as ServerError } from '../../../../components'; +import { useXJsonMode } from '../../../../shared_imports'; import { onWatchSave } from '../../watch_edit_actions'; import { WatchContext } from '../../watch_context'; import { goToWatchList } from '../../../../lib/navigation'; import { RequestFlyout } from '../request_flyout'; import { useAppContext } from '../../../../app_context'; -import { useXJsonMode } from './use_x_json_mode'; - export const JsonWatchEditForm = () => { const { links: { putWatchApiUrl }, diff --git a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_simulate.tsx b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_simulate.tsx index b9fce52b480ef..fa0b9b0a2566f 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_simulate.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_simulate.tsx @@ -40,7 +40,7 @@ import { JsonWatchEditSimulateResults } from './json_watch_edit_simulate_results import { getTimeUnitLabel } from '../../../../lib/get_time_unit_label'; import { useAppContext } from '../../../../app_context'; -import { useXJsonMode } from './use_x_json_mode'; +import { useXJsonMode } from '../../../../shared_imports'; const actionModeOptions = Object.keys(ACTION_MODES).map(mode => ({ text: ACTION_MODES[mode], diff --git a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/use_x_json_mode.ts b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/use_x_json_mode.ts deleted file mode 100644 index 7aefb0554e0e8..0000000000000 --- a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/use_x_json_mode.ts +++ /dev/null @@ -1,24 +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 { useState } from 'react'; -import { XJsonMode } from '../../../../../../../es_ui_shared/console_lang'; -import { - collapseLiteralStrings, - expandLiteralStrings, -} from '../../../../../../../../../src/plugins/es_ui_shared/console_lang/lib'; - -export const xJsonMode = new XJsonMode(); - -export const useXJsonMode = (json: string) => { - const [xJson, setXJson] = useState(expandLiteralStrings(json)); - - return { - xJson, - setXJson, - xJsonMode, - convertToJson: collapseLiteralStrings, - }; -}; diff --git a/x-pack/plugins/watcher/public/application/shared_imports.ts b/x-pack/plugins/watcher/public/application/shared_imports.ts index cbc4dde7448ff..94ef7af1c28d1 100644 --- a/x-pack/plugins/watcher/public/application/shared_imports.ts +++ b/x-pack/plugins/watcher/public/application/shared_imports.ts @@ -11,3 +11,5 @@ export { sendRequest, useRequest, } from '../../../../../src/plugins/es_ui_shared/public/request/np_ready_request'; + +export { useXJsonMode } from '../../../../../src/plugins/es_ui_shared/static/ace_x_json/hooks'; diff --git a/x-pack/plugins/watcher/server/lib/elasticsearch_js_plugin.ts b/x-pack/plugins/watcher/server/lib/elasticsearch_js_plugin.ts index 240e93e160fe0..3cb8dfb623fac 100644 --- a/x-pack/plugins/watcher/server/lib/elasticsearch_js_plugin.ts +++ b/x-pack/plugins/watcher/server/lib/elasticsearch_js_plugin.ts @@ -174,6 +174,10 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) name: 'master_timeout', type: 'duration', }, + active: { + name: 'active', + type: 'boolean', + }, }, url: { fmt: '/_watcher/watch/<%=id%>', diff --git a/x-pack/plugins/watcher/server/routes/api/watch/register_save_route.ts b/x-pack/plugins/watcher/server/routes/api/watch/register_save_route.ts index 61d167bb9bbcd..10ee0c4857862 100644 --- a/x-pack/plugins/watcher/server/routes/api/watch/register_save_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/watch/register_save_route.ts @@ -5,7 +5,6 @@ */ import { schema } from '@kbn/config-schema'; -import { IScopedClusterClient } from 'kibana/server'; import { i18n } from '@kbn/i18n'; import { WATCH_TYPES } from '../../../../common/constants'; import { serializeJsonWatch, serializeThresholdWatch } from '../../../../common/lib/serialization'; @@ -21,23 +20,11 @@ const bodySchema = schema.object( { type: schema.string(), isNew: schema.boolean(), + isActive: schema.boolean(), }, { unknowns: 'allow' } ); -function fetchWatch(dataClient: IScopedClusterClient, watchId: string) { - return dataClient.callAsCurrentUser('watcher.getWatch', { - id: watchId, - }); -} - -function saveWatch(dataClient: IScopedClusterClient, id: string, body: any) { - return dataClient.callAsCurrentUser('watcher.putWatch', { - id, - body, - }); -} - export function registerSaveRoute(deps: RouteDependencies) { deps.router.put( { @@ -49,12 +36,16 @@ export function registerSaveRoute(deps: RouteDependencies) { }, licensePreRoutingFactory(deps, async (ctx, request, response) => { const { id } = request.params; - const { type, isNew, ...watchConfig } = request.body; + const { type, isNew, isActive, ...watchConfig } = request.body; + + const dataClient = ctx.watcher!.client; // For new watches, verify watch with the same ID doesn't already exist if (isNew) { try { - const existingWatch = await fetchWatch(ctx.watcher!.client, id); + const existingWatch = await dataClient.callAsCurrentUser('watcher.getWatch', { + id, + }); if (existingWatch.found) { return response.conflict({ body: { @@ -92,7 +83,11 @@ export function registerSaveRoute(deps: RouteDependencies) { try { // Create new watch return response.ok({ - body: await saveWatch(ctx.watcher!.client, id, serializedWatch), + body: await dataClient.callAsCurrentUser('watcher.putWatch', { + id, + active: isActive, + body: serializedWatch, + }), }); } catch (e) { // Case: Error from Elasticsearch JS client diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 061c9e4a0d921..974f3eb6db60f 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -4,13 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -const alwaysImportedTests = [require.resolve('../test/functional/config.js')]; +const alwaysImportedTests = [ + require.resolve('../test/functional/config.js'), + require.resolve('../test/functional_endpoint_ingest_failure/config.ts'), + require.resolve('../test/functional_endpoint/config.ts'), + require.resolve('../test/functional_with_es_ssl/config.ts'), + require.resolve('../test/functional/config_security_basic.js'), + require.resolve('../test/plugin_functional/config.ts'), +]; const onlyNotInCoverageTests = [ require.resolve('../test/reporting/configs/chromium_api.js'), require.resolve('../test/reporting/configs/chromium_functional.js'), require.resolve('../test/reporting/configs/generate_api.js'), - require.resolve('../test/functional_with_es_ssl/config.ts'), - require.resolve('../test/functional/config_security_basic.js'), require.resolve('../test/api_integration/config_security_basic.js'), require.resolve('../test/api_integration/config.js'), require.resolve('../test/alerting_api_integration/basic/config.ts'), @@ -18,7 +23,6 @@ const onlyNotInCoverageTests = [ require.resolve('../test/alerting_api_integration/security_and_spaces/config.ts'), require.resolve('../test/detection_engine_api_integration/security_and_spaces/config.ts'), require.resolve('../test/plugin_api_integration/config.ts'), - require.resolve('../test/plugin_functional/config.ts'), require.resolve('../test/kerberos_api_integration/config.ts'), require.resolve('../test/kerberos_api_integration/anonymous_access.config.ts'), require.resolve('../test/saml_api_integration/config.ts'), @@ -43,8 +47,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/licensing_plugin/config.ts'), require.resolve('../test/licensing_plugin/config.public.ts'), require.resolve('../test/licensing_plugin/config.legacy.ts'), - require.resolve('../test/functional_endpoint_ingest_failure/config.ts'), - require.resolve('../test/functional_endpoint/config.ts'), + require.resolve('../test/endpoint_api_integration_no_ingest/config.ts'), ]; require('@kbn/plugin-helpers').babelRegister(); diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 4d32a5ae9f53c..457b7621e84bd 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -100,6 +100,27 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) xyzSecret2: 'credential2', }, }, + { + id: 'preconfigured-es-index-action', + actionTypeId: '.index', + name: 'preconfigured_es_index_action', + config: { + index: 'functional-test-actions-index-preconfigured', + refresh: true, + executionTimeField: 'timestamp', + }, + }, + { + id: 'preconfigured.test.index-record', + actionTypeId: 'test.index-record', + name: 'Test:_Preconfigured_Index_Record', + config: { + unencrypted: 'ignored-but-required', + }, + secrets: { + encrypted: 'this-is-also-ignored-and-also-required', + }, + }, ])}`, ...disabledPlugins.map(key => `--xpack.${key}.enabled=false`), `--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'alerts')}`, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index_preconfigured.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index_preconfigured.ts new file mode 100644 index 0000000000000..b04bc13ffc5e4 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index_preconfigured.ts @@ -0,0 +1,68 @@ +/* + * 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 '../../../../common/ftr_provider_context'; + +// from: x-pack/test/alerting_api_integration/common/config.ts +const ACTION_ID = 'preconfigured-es-index-action'; +const ES_TEST_INDEX_NAME = 'functional-test-actions-index-preconfigured'; + +// eslint-disable-next-line import/no-default-export +export default function indexTest({ getService }: FtrProviderContext) { + const es = getService('legacyEs'); + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('preconfigured index action', () => { + after(() => esArchiver.unload('empty_kibana')); + beforeEach(() => clearTestIndex(es)); + + it('should execute successfully when expected for a single body', async () => { + const { body: result } = await supertest + .post(`/api/action/${ACTION_ID}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + documents: [{ testing: [4, 5, 6] }], + }, + }) + .expect(200); + expect(result.status).to.eql('ok'); + + const items = await getTestIndexItems(es); + expect(items.length).to.eql(1); + + // check document sans timestamp + const document = items[0]._source; + const timestamp = document.timestamp; + delete document.timestamp; + expect(document).to.eql({ testing: [4, 5, 6] }); + + // check timestamp + const timestampTime = new Date(timestamp).getTime(); + const timeNow = Date.now(); + const timeMinuteAgo = timeNow - 1000 * 60; + expect(timestampTime).to.be.within(timeMinuteAgo, timeNow); + }); + }); +} + +async function clearTestIndex(es: any) { + return await es.indices.delete({ + index: ES_TEST_INDEX_NAME, + ignoreUnavailable: true, + }); +} + +async function getTestIndexItems(es: any) { + const result = await es.search({ + index: ES_TEST_INDEX_NAME, + }); + + return result.hits.hits; +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts index 80b512f3fb5e3..0b637326d4667 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts @@ -68,6 +68,18 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { }, referencedByCount: 0, }, + { + id: 'preconfigured-es-index-action', + isPreconfigured: true, + actionTypeId: '.index', + name: 'preconfigured_es_index_action', + config: { + index: 'functional-test-actions-index-preconfigured', + refresh: true, + executionTimeField: 'timestamp', + }, + referencedByCount: 0, + }, { id: 'my-slack1', isPreconfigured: true, @@ -90,6 +102,16 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { }, referencedByCount: 0, }, + { + id: 'preconfigured.test.index-record', + isPreconfigured: true, + actionTypeId: 'test.index-record', + name: 'Test:_Preconfigured_Index_Record', + config: { + unencrypted: 'ignored-but-required', + }, + referencedByCount: 0, + }, ]); break; default: @@ -167,6 +189,18 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { }, referencedByCount: 1, }, + { + id: 'preconfigured-es-index-action', + isPreconfigured: true, + actionTypeId: '.index', + name: 'preconfigured_es_index_action', + config: { + index: 'functional-test-actions-index-preconfigured', + refresh: true, + executionTimeField: 'timestamp', + }, + referencedByCount: 0, + }, { id: 'my-slack1', isPreconfigured: true, @@ -189,6 +223,16 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { }, referencedByCount: 0, }, + { + id: 'preconfigured.test.index-record', + isPreconfigured: true, + actionTypeId: 'test.index-record', + name: 'Test:_Preconfigured_Index_Record', + config: { + unencrypted: 'ignored-but-required', + }, + referencedByCount: 0, + }, ]); break; default: @@ -232,6 +276,18 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { case 'superuser at space1': expect(response.statusCode).to.eql(200); expect(response.body).to.eql([ + { + id: 'preconfigured-es-index-action', + isPreconfigured: true, + actionTypeId: '.index', + name: 'preconfigured_es_index_action', + config: { + index: 'functional-test-actions-index-preconfigured', + refresh: true, + executionTimeField: 'timestamp', + }, + referencedByCount: 0, + }, { id: 'my-slack1', isPreconfigured: true, @@ -254,6 +310,16 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { }, referencedByCount: 0, }, + { + id: 'preconfigured.test.index-record', + isPreconfigured: true, + actionTypeId: 'test.index-record', + name: 'Test:_Preconfigured_Index_Record', + config: { + unencrypted: 'ignored-but-required', + }, + referencedByCount: 0, + }, ]); break; default: diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts index d7ec2e78ccb30..8e002bcc8d3da 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts @@ -11,6 +11,7 @@ export default function actionsTests({ loadTestFile }: FtrProviderContext) { describe('Actions', () => { loadTestFile(require.resolve('./builtin_action_types/email')); loadTestFile(require.resolve('./builtin_action_types/es_index')); + loadTestFile(require.resolve('./builtin_action_types/es_index_preconfigured')); loadTestFile(require.resolve('./builtin_action_types/pagerduty')); loadTestFile(require.resolve('./builtin_action_types/server_log')); loadTestFile(require.resolve('./builtin_action_types/servicenow')); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index 6eed28cc381dd..d8e4f808f5cd2 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -165,6 +165,100 @@ instanceStateValue: true } }); + it('should schedule task, run alert and schedule preconfigured actions when appropriate', async () => { + const testStart = new Date(); + const reference = alertUtils.generateReference(); + const response = await alertUtils.createAlwaysFiringAction({ + reference, + indexRecordActionId: 'preconfigured.test.index-record', + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + + // Wait for the action to index a document before disabling the alert and waiting for tasks to finish + await esTestIndexTool.waitForDocs('action:test.index-record', reference); + + await taskManagerUtils.waitForAllTasksIdle(testStart); + + const alertId = response.body.id; + await alertUtils.disable(alertId); + await taskManagerUtils.waitForEmpty(testStart); + + // Ensure only 1 alert executed with proper params + const alertSearchResult = await esTestIndexTool.search( + 'alert:test.always-firing', + reference + ); + expect(alertSearchResult.hits.total.value).to.eql(1); + expect(alertSearchResult.hits.hits[0]._source).to.eql({ + source: 'alert:test.always-firing', + reference, + state: {}, + params: { + index: ES_TEST_INDEX_NAME, + reference, + }, + alertInfo: { + alertId, + spaceId: space.id, + namespace: space.id, + name: 'abc', + tags: ['tag-A', 'tag-B'], + createdBy: user.fullName, + updatedBy: user.fullName, + }, + }); + + // Ensure only 1 action executed with proper params + const actionSearchResult = await esTestIndexTool.search( + 'action:test.index-record', + reference + ); + expect(actionSearchResult.hits.total.value).to.eql(1); + expect(actionSearchResult.hits.hits[0]._source).to.eql({ + config: { + unencrypted: 'ignored-but-required', + }, + secrets: { + encrypted: 'this-is-also-ignored-and-also-required', + }, + params: { + index: ES_TEST_INDEX_NAME, + reference, + message: ` +alertId: ${alertId}, +alertName: abc, +spaceId: ${space.id}, +tags: tag-A,tag-B, +alertInstanceId: 1, +instanceContextValue: true, +instanceStateValue: true +`.trim(), + }, + reference, + source: 'action:test.index-record', + }); + + await taskManagerUtils.waitForActionTaskParamsToBeCleanedUp(testStart); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + it('should pass updated alert params to executor', async () => { const testStart = new Date(); // create an alert diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts index 517c64f178af5..ec59e56b08308 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts @@ -45,6 +45,18 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { }, referencedByCount: 0, }, + { + id: 'preconfigured-es-index-action', + isPreconfigured: true, + actionTypeId: '.index', + name: 'preconfigured_es_index_action', + config: { + index: 'functional-test-actions-index-preconfigured', + refresh: true, + executionTimeField: 'timestamp', + }, + referencedByCount: 0, + }, { id: 'my-slack1', isPreconfigured: true, @@ -67,6 +79,16 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { }, referencedByCount: 0, }, + { + id: 'preconfigured.test.index-record', + isPreconfigured: true, + actionTypeId: 'test.index-record', + name: 'Test:_Preconfigured_Index_Record', + config: { + unencrypted: 'ignored-but-required', + }, + referencedByCount: 0, + }, ]); }); @@ -88,6 +110,18 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { objectRemover.add(Spaces.space1.id, createdAction.id, 'action'); await supertest.get(`${getUrlPrefix(Spaces.other.id)}/api/action/_getAll`).expect(200, [ + { + id: 'preconfigured-es-index-action', + isPreconfigured: true, + actionTypeId: '.index', + name: 'preconfigured_es_index_action', + config: { + index: 'functional-test-actions-index-preconfigured', + refresh: true, + executionTimeField: 'timestamp', + }, + referencedByCount: 0, + }, { id: 'my-slack1', isPreconfigured: true, @@ -110,6 +144,16 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { }, referencedByCount: 0, }, + { + id: 'preconfigured.test.index-record', + isPreconfigured: true, + actionTypeId: 'test.index-record', + name: 'Test:_Preconfigured_Index_Record', + config: { + unencrypted: 'ignored-but-required', + }, + referencedByCount: 0, + }, ]); }); }); diff --git a/x-pack/test/api_integration/apis/endpoint/index.ts b/x-pack/test/api_integration/apis/endpoint/index.ts index 4ffd0c3b6044b..0a5f9aa595b8a 100644 --- a/x-pack/test/api_integration/apis/endpoint/index.ts +++ b/x-pack/test/api_integration/apis/endpoint/index.ts @@ -6,9 +6,17 @@ import { FtrProviderContext } from '../../ftr_provider_context'; -export default function endpointAPIIntegrationTests({ loadTestFile }: FtrProviderContext) { +export default function endpointAPIIntegrationTests({ + loadTestFile, + getService, +}: FtrProviderContext) { describe('Endpoint plugin', function() { + const ingestManager = getService('ingestManager'); this.tags(['endpoint']); + before(async () => { + await ingestManager.setup(); + }); + loadTestFile(require.resolve('./index_pattern')); loadTestFile(require.resolve('./resolver')); loadTestFile(require.resolve('./metadata')); loadTestFile(require.resolve('./alerts')); diff --git a/x-pack/test/api_integration/apis/endpoint/index_pattern.ts b/x-pack/test/api_integration/apis/endpoint/index_pattern.ts new file mode 100644 index 0000000000000..d3ffd67defef1 --- /dev/null +++ b/x-pack/test/api_integration/apis/endpoint/index_pattern.ts @@ -0,0 +1,27 @@ +/* + * 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/expect.js'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('Endpoint index pattern API', () => { + it('should retrieve the index pattern for events', async () => { + const { body } = await supertest.get('/api/endpoint/index_pattern/events').expect(200); + expect(body.indexPattern).to.eql('events-endpoint-*'); + }); + + it('should retrieve the index pattern for metadata', async () => { + const { body } = await supertest.get('/api/endpoint/index_pattern/metadata').expect(200); + expect(body.indexPattern).to.eql('metrics-endpoint-*'); + }); + + it('should not retrieve the index pattern for an invalid key', async () => { + await supertest.get('/api/endpoint/index_pattern/blah').expect(404); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/endpoint/metadata.ts b/x-pack/test/api_integration/apis/endpoint/metadata.ts index 887be6b85b100..943782c79ada7 100644 --- a/x-pack/test/api_integration/apis/endpoint/metadata.ts +++ b/x-pack/test/api_integration/apis/endpoint/metadata.ts @@ -159,7 +159,7 @@ export default function({ getService }: FtrProviderContext) { .post('/api/endpoint/metadata') .set('kbn-xsrf', 'xxx') .send({ - filter: `host.os.variant.keyword:${variantValue}`, + filter: `host.os.variant:${variantValue}`, }) .expect(200); expect(body.total).to.eql(2); diff --git a/x-pack/test/api_integration/apis/endpoint/resolver.ts b/x-pack/test/api_integration/apis/endpoint/resolver.ts index c1e9240c09951..e8d336e875b99 100644 --- a/x-pack/test/api_integration/apis/endpoint/resolver.ts +++ b/x-pack/test/api_integration/apis/endpoint/resolver.ts @@ -17,8 +17,8 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC const esArchiver = getService('esArchiver'); describe('Resolver', () => { - before(() => esArchiver.load('endpoint/resolver/api_feature')); - after(() => esArchiver.unload('endpoint/resolver/api_feature')); + before(async () => await esArchiver.load('endpoint/resolver/api_feature')); + after(async () => await esArchiver.unload('endpoint/resolver/api_feature')); describe('related events endpoint', () => { const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; diff --git a/x-pack/test/api_integration/apis/infra/metadata.ts b/x-pack/test/api_integration/apis/infra/metadata.ts index b693881abcdf7..5187cc5e3ec26 100644 --- a/x-pack/test/api_integration/apis/infra/metadata.ts +++ b/x-pack/test/api_integration/apis/infra/metadata.ts @@ -12,6 +12,28 @@ import { } from '../../../../plugins/infra/common/http_api/metadata_api'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { DATES } from './constants'; + +const timeRange700 = { + from: DATES['7.0.0'].hosts.min, + to: DATES[`7.0.0`].hosts.max, +}; + +const timeRange660 = { + from: DATES['6.6.0'].docker.min, + to: DATES[`6.6.0`].docker.max, +}; + +const timeRange800withAws = { + from: DATES['8.0.0'].logs_and_metrics_with_aws.min, + to: DATES[`8.0.0`].logs_and_metrics_with_aws.max, +}; + +const timeRange800 = { + from: DATES['8.0.0'].logs_and_metrics.min, + to: DATES[`8.0.0`].logs_and_metrics.max, +}; + export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); @@ -34,6 +56,7 @@ export default function({ getService }: FtrProviderContext) { sourceId: 'default', nodeId: 'demo-stack-mysql-01', nodeType: InfraNodeType.host, + timeRange: timeRange700, }); if (metadata) { expect(metadata.features.length).to.be(12); @@ -53,6 +76,7 @@ export default function({ getService }: FtrProviderContext) { sourceId: 'default', nodeId: '631f36a845514442b93c3fdd2dc91bcd8feb680b8ac5832c7fb8fdc167bb938e', nodeType: InfraNodeType.container, + timeRange: timeRange660, }); if (metadata) { expect(metadata.features.length).to.be(10); @@ -74,6 +98,7 @@ export default function({ getService }: FtrProviderContext) { sourceId: 'default', nodeId: 'gke-observability-8--observability-8--bc1afd95-f0zc', nodeType: InfraNodeType.host, + timeRange: timeRange800withAws, }); if (metadata) { expect(metadata.features.length).to.be(58); @@ -114,6 +139,7 @@ export default function({ getService }: FtrProviderContext) { sourceId: 'default', nodeId: 'ip-172-31-47-9.us-east-2.compute.internal', nodeType: InfraNodeType.host, + timeRange: timeRange800withAws, }); if (metadata) { expect(metadata.features.length).to.be(19); @@ -155,6 +181,7 @@ export default function({ getService }: FtrProviderContext) { sourceId: 'default', nodeId: '14887487-99f8-11e9-9a96-42010a84004d', nodeType: InfraNodeType.pod, + timeRange: timeRange800withAws, }); if (metadata) { expect(metadata.features.length).to.be(29); @@ -200,6 +227,7 @@ export default function({ getService }: FtrProviderContext) { sourceId: 'default', nodeId: 'c74b04834c6d7cc1800c3afbe31d0c8c0c267f06e9eb45c2b0c2df3e6cee40c5', nodeType: InfraNodeType.container, + timeRange: timeRange800withAws, }); if (metadata) { expect(metadata.features.length).to.be(26); @@ -251,6 +279,7 @@ export default function({ getService }: FtrProviderContext) { sourceId: 'default', nodeId: 'gke-observability-8--observability-8--bc1afd95-f0zc', nodeType: 'host', + timeRange: timeRange800, }); if (metadata) { expect( @@ -265,6 +294,7 @@ export default function({ getService }: FtrProviderContext) { sourceId: 'default', nodeId: 'c1031331-9ae0-11e9-9a96-42010a84004d', nodeType: 'pod', + timeRange: timeRange800, }); if (metadata) { expect( diff --git a/x-pack/test/api_integration/apis/logstash/cluster/index.js b/x-pack/test/api_integration/apis/logstash/cluster/index.js deleted file mode 100644 index f016fde97ee4b..0000000000000 --- a/x-pack/test/api_integration/apis/logstash/cluster/index.js +++ /dev/null @@ -1,11 +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 default function({ loadTestFile }) { - describe('cluster', () => { - loadTestFile(require.resolve('./load')); - }); -} diff --git a/x-pack/test/api_integration/apis/logstash/cluster/index.ts b/x-pack/test/api_integration/apis/logstash/cluster/index.ts new file mode 100644 index 0000000000000..1d4fbd40b252b --- /dev/null +++ b/x-pack/test/api_integration/apis/logstash/cluster/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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('cluster', () => { + loadTestFile(require.resolve('./load')); + }); +} diff --git a/x-pack/test/api_integration/apis/logstash/cluster/load.js b/x-pack/test/api_integration/apis/logstash/cluster/load.js deleted file mode 100644 index a348c6e4857b1..0000000000000 --- a/x-pack/test/api_integration/apis/logstash/cluster/load.js +++ /dev/null @@ -1,21 +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 expect from '@kbn/expect'; - -export default function({ getService }) { - const supertest = getService('supertest'); - const es = getService('legacyEs'); - - describe('load', () => { - it('should return the ES cluster info', async () => { - const { body } = await supertest.get('/api/logstash/cluster').expect(200); - - const responseFromES = await es.info(); - expect(body.cluster.uuid).to.eql(responseFromES.cluster_uuid); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/logstash/cluster/load.ts b/x-pack/test/api_integration/apis/logstash/cluster/load.ts new file mode 100644 index 0000000000000..0678c5faf82dc --- /dev/null +++ b/x-pack/test/api_integration/apis/logstash/cluster/load.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + describe('load', () => { + it('should return the ES cluster info', async () => { + const { body } = await supertest.get('/api/logstash/cluster').expect(200); + + const responseFromES = await es.info(); + expect(body.cluster.uuid).to.eql(responseFromES.cluster_uuid); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/logstash/index.js b/x-pack/test/api_integration/apis/logstash/index.js deleted file mode 100644 index 53293e5ff9423..0000000000000 --- a/x-pack/test/api_integration/apis/logstash/index.js +++ /dev/null @@ -1,13 +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 default function({ loadTestFile }) { - describe('logstash', () => { - loadTestFile(require.resolve('./pipelines')); - loadTestFile(require.resolve('./pipeline')); - loadTestFile(require.resolve('./cluster')); - }); -} diff --git a/x-pack/test/api_integration/apis/logstash/index.ts b/x-pack/test/api_integration/apis/logstash/index.ts new file mode 100644 index 0000000000000..582bef5a53bf2 --- /dev/null +++ b/x-pack/test/api_integration/apis/logstash/index.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. + */ +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ loadTestFile }: FtrProviderContext) { + describe('logstash', () => { + loadTestFile(require.resolve('./pipelines')); + loadTestFile(require.resolve('./pipeline')); + loadTestFile(require.resolve('./cluster')); + }); +} diff --git a/x-pack/test/api_integration/apis/logstash/pipeline/delete.js b/x-pack/test/api_integration/apis/logstash/pipeline/delete.js deleted file mode 100644 index 85813f4ed04d1..0000000000000 --- a/x-pack/test/api_integration/apis/logstash/pipeline/delete.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export default function({ getService }) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - describe('delete', () => { - const archive = 'logstash/empty'; - - before('load pipelines archive', async () => { - await esArchiver.load(archive); - - await supertest - .put('/api/logstash/pipeline/fast_generator') - .set('kbn-xsrf', 'xxx') - .send({ - id: 'fast_generator', - description: 'foobar baz', - username: 'seger', - pipeline: 'input { generator {} }\n\n output { stdout {} }', - }) - .expect(204); - - await supertest.get('/api/logstash/pipeline/fast_generator').expect(200); - }); - - after('unload pipelines archive', () => { - return esArchiver.unload(archive); - }); - - it('should delete the specified pipeline', async () => { - await supertest - .delete('/api/logstash/pipeline/fast_generator') - .set('kbn-xsrf', 'xxx') - .expect(204); - - await supertest.get('/api/logstash/pipeline/fast_generator').expect(404); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/logstash/pipeline/delete.ts b/x-pack/test/api_integration/apis/logstash/pipeline/delete.ts new file mode 100644 index 0000000000000..cdbf5a3e6a1fe --- /dev/null +++ b/x-pack/test/api_integration/apis/logstash/pipeline/delete.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + describe('delete', () => { + const archive = 'logstash/empty'; + + before('load pipelines archive', async () => { + await esArchiver.load(archive); + + await supertest + .put('/api/logstash/pipeline/fast_generator') + .set('kbn-xsrf', 'xxx') + .send({ + id: 'fast_generator', + description: 'foobar baz', + username: 'seger', + pipeline: 'input { generator {} }\n\n output { stdout {} }', + }) + .expect(204); + + await supertest.get('/api/logstash/pipeline/fast_generator').expect(200); + }); + + after('unload pipelines archive', () => { + return esArchiver.unload(archive); + }); + + it('should delete the specified pipeline', async () => { + await supertest + .delete('/api/logstash/pipeline/fast_generator') + .set('kbn-xsrf', 'xxx') + .expect(204); + + await supertest.get('/api/logstash/pipeline/fast_generator').expect(404); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/logstash/pipeline/index.js b/x-pack/test/api_integration/apis/logstash/pipeline/index.js deleted file mode 100644 index dcc8a01378e37..0000000000000 --- a/x-pack/test/api_integration/apis/logstash/pipeline/index.js +++ /dev/null @@ -1,13 +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 default function({ loadTestFile }) { - describe('pipeline', () => { - loadTestFile(require.resolve('./load')); - loadTestFile(require.resolve('./save')); - loadTestFile(require.resolve('./delete')); - }); -} diff --git a/x-pack/test/api_integration/apis/logstash/pipeline/index.ts b/x-pack/test/api_integration/apis/logstash/pipeline/index.ts new file mode 100644 index 0000000000000..2697f7f428f5f --- /dev/null +++ b/x-pack/test/api_integration/apis/logstash/pipeline/index.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 { FtrProviderContext } from '../../../ftr_provider_context'; +export default function({ loadTestFile }: FtrProviderContext) { + describe('pipeline', () => { + loadTestFile(require.resolve('./load')); + loadTestFile(require.resolve('./save')); + loadTestFile(require.resolve('./delete')); + }); +} diff --git a/x-pack/test/api_integration/apis/logstash/pipeline/load.js b/x-pack/test/api_integration/apis/logstash/pipeline/load.js deleted file mode 100644 index eb2ab6500a9dc..0000000000000 --- a/x-pack/test/api_integration/apis/logstash/pipeline/load.js +++ /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. - */ - -import expect from '@kbn/expect'; -import pipeline from './fixtures/load'; - -export default function({ getService }) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - describe('list', () => { - const archive = 'logstash/example_pipelines'; - - before('load pipelines archive', () => { - return esArchiver.load(archive); - }); - - after('unload pipelines archive', () => { - return esArchiver.unload(archive); - }); - - it('should return the specified pipeline', async () => { - const { body } = await supertest.get('/api/logstash/pipeline/tweets_and_beats').expect(200); - - expect(body).to.eql(pipeline); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/logstash/pipeline/load.ts b/x-pack/test/api_integration/apis/logstash/pipeline/load.ts new file mode 100644 index 0000000000000..a892f527a6e61 --- /dev/null +++ b/x-pack/test/api_integration/apis/logstash/pipeline/load.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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 pipeline from './fixtures/load.json'; + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + describe('list', () => { + const archive = 'logstash/example_pipelines'; + + before('load pipelines archive', () => { + return esArchiver.load(archive); + }); + + after('unload pipelines archive', () => { + return esArchiver.unload(archive); + }); + + it('should return the specified pipeline', async () => { + const { body } = await supertest.get('/api/logstash/pipeline/tweets_and_beats').expect(200); + + expect(body).to.eql(pipeline); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/logstash/pipeline/save.js b/x-pack/test/api_integration/apis/logstash/pipeline/save.js deleted file mode 100644 index ad35ee21f00fc..0000000000000 --- a/x-pack/test/api_integration/apis/logstash/pipeline/save.js +++ /dev/null @@ -1,40 +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 expect from '@kbn/expect'; - -export default function({ getService }) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - describe('save', () => { - const archive = 'logstash/empty'; - - before('load pipelines archive', () => { - return esArchiver.load(archive); - }); - - after('unload pipelines archive', () => { - return esArchiver.unload(archive); - }); - - it('should create the specified pipeline', async () => { - await supertest - .put('/api/logstash/pipeline/fast_generator') - .set('kbn-xsrf', 'xxx') - .send({ - id: 'fast_generator', - description: 'foobar baz', - username: 'seger', - pipeline: 'input { generator {} }\n\n output { stdout {} }', - }) - .expect(204); - - const { body } = await supertest.get('/api/logstash/pipeline/fast_generator').expect(200); - - expect(body.description).to.eql('foobar baz'); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/logstash/pipeline/save.ts b/x-pack/test/api_integration/apis/logstash/pipeline/save.ts new file mode 100644 index 0000000000000..2ca9fbe7d68e0 --- /dev/null +++ b/x-pack/test/api_integration/apis/logstash/pipeline/save.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + describe('save', () => { + const archive = 'logstash/empty'; + + before('load pipelines archive', () => { + return esArchiver.load(archive); + }); + + after('unload pipelines archive', () => { + return esArchiver.unload(archive); + }); + + it('should create the specified pipeline', async () => { + await supertest + .put('/api/logstash/pipeline/fast_generator') + .set('kbn-xsrf', 'xxx') + .send({ + id: 'fast_generator', + description: 'foobar baz', + username: 'seger', + pipeline: 'input { generator {} }\n\n output { stdout {} }', + }) + .expect(204); + + const { body } = await supertest.get('/api/logstash/pipeline/fast_generator').expect(200); + + expect(body.description).to.eql('foobar baz'); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/logstash/pipelines/delete.js b/x-pack/test/api_integration/apis/logstash/pipelines/delete.js deleted file mode 100644 index 98ff5b99ea744..0000000000000 --- a/x-pack/test/api_integration/apis/logstash/pipelines/delete.js +++ /dev/null @@ -1,36 +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 default function({ getService }) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - describe('delete', () => { - const archive = 'logstash/example_pipelines'; - - before('load pipelines archive', async () => { - await esArchiver.load(archive); - await supertest.get('/api/logstash/pipeline/empty_pipeline_1').expect(200); - await supertest.get('/api/logstash/pipeline/empty_pipeline_2').expect(200); - }); - - after('unload pipelines archive', () => { - return esArchiver.unload(archive); - }); - - it('should delete the specified pipelines', async () => { - await supertest - .post('/api/logstash/pipelines/delete') - .set('kbn-xsrf', 'xxx') - .send({ - pipelineIds: ['empty_pipeline_1', 'empty_pipeline_2'], - }) - .expect(200); - - await supertest.get('/api/logstash/pipeline/empty_pipeline_1').expect(404); - await supertest.get('/api/logstash/pipeline/empty_pipeline_2').expect(404); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/logstash/pipelines/delete.ts b/x-pack/test/api_integration/apis/logstash/pipelines/delete.ts new file mode 100644 index 0000000000000..e71dc7f08ddc9 --- /dev/null +++ b/x-pack/test/api_integration/apis/logstash/pipelines/delete.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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + describe('delete', () => { + const archive = 'logstash/example_pipelines'; + + before('load pipelines archive', async () => { + await esArchiver.load(archive); + await supertest.get('/api/logstash/pipeline/empty_pipeline_1').expect(200); + await supertest.get('/api/logstash/pipeline/empty_pipeline_2').expect(200); + }); + + after('unload pipelines archive', () => { + return esArchiver.unload(archive); + }); + + it('should delete the specified pipelines', async () => { + await supertest + .post('/api/logstash/pipelines/delete') + .set('kbn-xsrf', 'xxx') + .send({ + pipelineIds: ['empty_pipeline_1', 'empty_pipeline_2'], + }) + .expect(200); + + await supertest.get('/api/logstash/pipeline/empty_pipeline_1').expect(404); + await supertest.get('/api/logstash/pipeline/empty_pipeline_2').expect(404); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/logstash/pipelines/index.js b/x-pack/test/api_integration/apis/logstash/pipelines/index.js deleted file mode 100644 index 3abe2ee5ac43d..0000000000000 --- a/x-pack/test/api_integration/apis/logstash/pipelines/index.js +++ /dev/null @@ -1,12 +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 default function({ loadTestFile }) { - describe('pipelines', () => { - loadTestFile(require.resolve('./list')); - loadTestFile(require.resolve('./delete')); - }); -} diff --git a/x-pack/test/api_integration/apis/logstash/pipelines/index.ts b/x-pack/test/api_integration/apis/logstash/pipelines/index.ts new file mode 100644 index 0000000000000..510bd625b54a0 --- /dev/null +++ b/x-pack/test/api_integration/apis/logstash/pipelines/index.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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function({ loadTestFile }: FtrProviderContext) { + describe('pipelines', () => { + loadTestFile(require.resolve('./list')); + loadTestFile(require.resolve('./delete')); + }); +} diff --git a/x-pack/test/api_integration/apis/logstash/pipelines/list.js b/x-pack/test/api_integration/apis/logstash/pipelines/list.js deleted file mode 100644 index fe5c3222a2ab1..0000000000000 --- a/x-pack/test/api_integration/apis/logstash/pipelines/list.js +++ /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. - */ - -import expect from '@kbn/expect'; -import pipelineList from './fixtures/list'; - -export default function({ getService }) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - describe('list', () => { - const archive = 'logstash/example_pipelines'; - - before('load pipelines archive', () => { - return esArchiver.load(archive); - }); - - after('unload pipelines archive', () => { - return esArchiver.unload(archive); - }); - - it('should return all the pipelines', async () => { - const { body } = await supertest.get('/api/logstash/pipelines').expect(200); - - expect(body).to.eql(pipelineList); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/logstash/pipelines/list.ts b/x-pack/test/api_integration/apis/logstash/pipelines/list.ts new file mode 100644 index 0000000000000..a4ef52791ab70 --- /dev/null +++ b/x-pack/test/api_integration/apis/logstash/pipelines/list.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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import pipelineList from './fixtures/list.json'; + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + describe('list', () => { + const archive = 'logstash/example_pipelines'; + + before('load pipelines archive', () => { + return esArchiver.load(archive); + }); + + after('unload pipelines archive', () => { + return esArchiver.unload(archive); + }); + + it('should return all the pipelines', async () => { + const { body } = await supertest.get('/api/logstash/pipelines').expect(200); + + expect(body).to.eql(pipelineList); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/management/index_lifecycle_management/templates.helpers.js b/x-pack/test/api_integration/apis/management/index_lifecycle_management/templates.helpers.js index 8778ab5c807f5..257cca36b2ecf 100644 --- a/x-pack/test/api_integration/apis/management/index_lifecycle_management/templates.helpers.js +++ b/x-pack/test/api_integration/apis/management/index_lifecycle_management/templates.helpers.js @@ -9,8 +9,6 @@ import { API_BASE_PATH } from './constants'; export const registerHelpers = ({ supertest }) => { const loadTemplates = () => supertest.get(`${API_BASE_PATH}/templates`); - const getTemplate = name => supertest.get(`${API_BASE_PATH}/templates/${name}`); - const addPolicyToTemplate = (templateName, policyName, aliasName) => supertest .post(`${API_BASE_PATH}/template`) @@ -23,7 +21,6 @@ export const registerHelpers = ({ supertest }) => { return { loadTemplates, - getTemplate, addPolicyToTemplate, }; }; diff --git a/x-pack/test/api_integration/apis/management/index_lifecycle_management/templates.js b/x-pack/test/api_integration/apis/management/index_lifecycle_management/templates.js index 374577825cd94..d30c20527471b 100644 --- a/x-pack/test/api_integration/apis/management/index_lifecycle_management/templates.js +++ b/x-pack/test/api_integration/apis/management/index_lifecycle_management/templates.js @@ -16,7 +16,7 @@ export default function({ getService }) { const { createIndexTemplate, cleanUp: cleanUpEsResources } = initElasticsearchHelpers(es); - const { loadTemplates, getTemplate, addPolicyToTemplate } = registerTemplatesHelpers({ + const { loadTemplates, addPolicyToTemplate } = registerTemplatesHelpers({ supertest, }); @@ -48,18 +48,6 @@ export default function({ getService }) { }); }); - describe('get', () => { - it('should fetch a single template', async () => { - // Create a template with the ES client - const templateName = getRandomString(); - const template = getTemplatePayload(); - await createIndexTemplate(templateName, template); - - const { body } = await getTemplate(templateName).expect(200); - expect(body.index_patterns).to.eql(template.index_patterns); - }); - }); - describe('update', () => { it('should add a policy to a template', async () => { // Create policy @@ -78,12 +66,13 @@ export default function({ getService }) { await addPolicyToTemplate(templateName, policyName, rolloverAlias).expect(200); // Fetch the template and verify that the policy has been attached - const { body } = await getTemplate(templateName); + const { body } = await loadTemplates(); + const fetchedTemplate = body.find(({ name }) => templateName === name); const { settings: { index: { lifecycle }, }, - } = body; + } = fetchedTemplate; expect(lifecycle.name).to.equal(policyName); expect(lifecycle.rollover_alias).to.equal(rolloverAlias); }); diff --git a/x-pack/test/api_integration/apis/spaces/saved_objects.ts b/x-pack/test/api_integration/apis/spaces/saved_objects.ts index 05f14a50f2170..7584be7fd8498 100644 --- a/x-pack/test/api_integration/apis/spaces/saved_objects.ts +++ b/x-pack/test/api_integration/apis/spaces/saved_objects.ts @@ -89,7 +89,7 @@ export default function({ getService }: FtrProviderContext) { .expect(404) .then((response: Record) => { expect(response.body).to.eql({ - message: 'Not Found', + message: 'Saved object [space/default] not found', statusCode: 404, error: 'Not Found', }); diff --git a/x-pack/test/api_integration/apis/uptime/feature_controls.ts b/x-pack/test/api_integration/apis/uptime/feature_controls.ts index 4c3b7f97c9544..8b82735fc38b0 100644 --- a/x-pack/test/api_integration/apis/uptime/feature_controls.ts +++ b/x-pack/test/api_integration/apis/uptime/feature_controls.ts @@ -40,7 +40,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) const executePingsRequest = async (username: string, password: string, spaceId?: string) => { const basePath = spaceId ? `/s/${spaceId}` : ''; - const url = `${basePath}${API_URLS.PINGS}?sort=desc&dateRangeStart=${PINGS_DATE_RANGE_START}&dateRangeEnd=${PINGS_DATE_RANGE_END}`; + const url = `${basePath}${API_URLS.PINGS}?sort=desc&from=${PINGS_DATE_RANGE_START}&to=${PINGS_DATE_RANGE_END}`; return await supertest .get(url) .auth(username, password) diff --git a/x-pack/test/api_integration/apis/uptime/get_all_pings.ts b/x-pack/test/api_integration/apis/uptime/get_all_pings.ts index 666986e7008b7..0b3f5faccb044 100644 --- a/x-pack/test/api_integration/apis/uptime/get_all_pings.ts +++ b/x-pack/test/api_integration/apis/uptime/get_all_pings.ts @@ -22,7 +22,7 @@ export default function({ getService }: FtrProviderContext) { it('should get all pings stored in index', async () => { const { body: apiResponse } = await supertest .get( - `/api/uptime/pings?sort=desc&dateRangeStart=${PINGS_DATE_RANGE_START}&dateRangeEnd=${PINGS_DATE_RANGE_END}` + `/api/uptime/pings?sort=desc&from=${PINGS_DATE_RANGE_START}&to=${PINGS_DATE_RANGE_END}` ) .expect(200); @@ -33,21 +33,19 @@ export default function({ getService }: FtrProviderContext) { it('should sort pings according to timestamp', async () => { const { body: apiResponse } = await supertest - .get( - `/api/uptime/pings?sort=asc&dateRangeStart=${PINGS_DATE_RANGE_START}&dateRangeEnd=${PINGS_DATE_RANGE_END}` - ) + .get(`/api/uptime/pings?sort=asc&from=${PINGS_DATE_RANGE_START}&to=${PINGS_DATE_RANGE_END}`) .expect(200); expect(apiResponse.total).to.be(2); expect(apiResponse.pings.length).to.be(2); - expect(apiResponse.pings[0].timestamp).to.be('2018-10-30T14:49:23.889Z'); - expect(apiResponse.pings[1].timestamp).to.be('2018-10-30T18:51:56.792Z'); + expect(apiResponse.pings[0]['@timestamp']).to.be('2018-10-30T14:49:23.889Z'); + expect(apiResponse.pings[1]['@timestamp']).to.be('2018-10-30T18:51:56.792Z'); }); it('should return results of n length', async () => { const { body: apiResponse } = await supertest .get( - `/api/uptime/pings?sort=desc&size=1&dateRangeStart=${PINGS_DATE_RANGE_START}&dateRangeEnd=${PINGS_DATE_RANGE_END}` + `/api/uptime/pings?sort=desc&size=1&from=${PINGS_DATE_RANGE_START}&to=${PINGS_DATE_RANGE_END}` ) .expect(200); @@ -57,10 +55,10 @@ export default function({ getService }: FtrProviderContext) { }); it('should miss pings outside of date range', async () => { - const dateRangeStart = moment('2002-01-01').valueOf(); - const dateRangeEnd = moment('2002-01-02').valueOf(); + const from = moment('2002-01-01').valueOf(); + const to = moment('2002-01-02').valueOf(); const { body: apiResponse } = await supertest - .get(`/api/uptime/pings?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}`) + .get(`/api/uptime/pings?from=${from}&to=${to}`) .expect(200); expect(apiResponse.total).to.be(0); diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/ping_list.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/ping_list.json deleted file mode 100644 index 330ec83931a62..0000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/ping_list.json +++ /dev/null @@ -1,320 +0,0 @@ -{ - "allPings": { - "total": 2000, - "locations": [ - "mpls" - ], - "pings": [ - { - "timestamp": "2019-09-11T03:40:34.410Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 413 - }, - "id": "0074-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.406Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 441 - }, - "id": "0073-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.406Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 482 - }, - "id": "0099-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.406Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 558 - }, - "id": "0098-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.406Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 304 - }, - "id": "0075-intermittent", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.405Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 487 - }, - "id": "0097-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.405Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 602 - }, - "id": "0049-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.390Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 365 - }, - "id": "0047-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.389Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 870 - }, - "id": "0077-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.387Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 2808 - }, - "id": "0076-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - } - ] - } -} \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/ping_list_count.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/ping_list_count.json deleted file mode 100644 index 3a619f517626a..0000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/ping_list_count.json +++ /dev/null @@ -1,1569 +0,0 @@ -{ - "allPings": { - "total": 2000, - "locations": [ - "mpls" - ], - "pings": [ - { - "timestamp": "2019-09-11T03:40:34.410Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 413 - }, - "id": "0074-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.406Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 441 - }, - "id": "0073-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.406Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 482 - }, - "id": "0099-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.406Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 558 - }, - "id": "0098-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.406Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 304 - }, - "id": "0075-intermittent", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.405Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 487 - }, - "id": "0097-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.405Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 602 - }, - "id": "0049-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.390Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 365 - }, - "id": "0047-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.389Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 870 - }, - "id": "0077-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.387Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 2808 - }, - "id": "0076-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.386Z", - "http": { - "response": { - "status_code": 400, - "body": { - "bytes": 3, - "hash": "26d228663f13a88592a12d16cf9587caab0388b262d6d9f126ed62f9333aca94", - "content": "400", - "content_bytes": 3 - } - } - }, - "error": { - "message": "400 Bad Request", - "type": "validate" - }, - "monitor": { - "duration": { - "us": 4258 - }, - "id": "0050-down", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "down", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.386Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 4784 - }, - "id": "0048-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.376Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 14580 - }, - "id": "0072-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.376Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 14679 - }, - "id": "0096-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.375Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 15308 - }, - "id": "0092-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.375Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 15183 - }, - "id": "0069-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.375Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 15013 - }, - "id": "0093-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.375Z", - "http": { - "response": { - "status_code": 400, - "body": { - "bytes": 3, - "hash": "26d228663f13a88592a12d16cf9587caab0388b262d6d9f126ed62f9333aca94", - "content": "400", - "content_bytes": 3 - } - } - }, - "error": { - "message": "400 Bad Request", - "type": "validate" - }, - "monitor": { - "duration": { - "us": 15117 - }, - "id": "0070-down", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "down", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.375Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 14875 - }, - "id": "0071-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.375Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 14801 - }, - "id": "0095-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.375Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 15065 - }, - "id": "0032-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.375Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 14911 - }, - "id": "0094-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.374Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 16135 - }, - "id": "0046-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.374Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 15428 - }, - "id": "0091-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.374Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 15499 - }, - "id": "0067-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.374Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 15464 - }, - "id": "0068-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.374Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 21736 - }, - "id": "0090-intermittent", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.374Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 21874 - }, - "id": "0031-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.374Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 36584 - }, - "id": "0066-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 3148 - }, - "id": "0084-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 13442 - }, - "id": "0083-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 13666 - }, - "id": "0041-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 16290 - }, - "id": "0045-intermittent", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 17255 - }, - "id": "0042-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 500, - "body": { - "bytes": 3, - "hash": "0604cd3138feed202ef293e062da2f4720f77a05d25ee036a7a01c9cfcdd1f0a", - "content": "500", - "content_bytes": 3 - } - } - }, - "error": { - "message": "500 Internal Server Error", - "type": "validate" - }, - "monitor": { - "duration": { - "us": 17146 - }, - "id": "0030-intermittent", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "down", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 17770 - }, - "id": "0063-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 18194 - }, - "id": "0061-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 17587 - }, - "id": "0065-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 22666 - }, - "id": "0062-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 33311 - }, - "id": "0026-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 33506 - }, - "id": "0085-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 33974 - }, - "id": "0025-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 33693 - }, - "id": "0088-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 33833 - }, - "id": "0089-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 34600 - }, - "id": "0087-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 35573 - }, - "id": "0028-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 35830 - }, - "id": "0086-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 35698 - }, - "id": "0064-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 35594 - }, - "id": "0029-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 35652 - }, - "id": "0044-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - } - ] - } -} \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/ping_list_monitor_id.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/ping_list_monitor_id.json deleted file mode 100644 index 5826fd9f3f540..0000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/ping_list_monitor_id.json +++ /dev/null @@ -1,475 +0,0 @@ -{ - "allPings": { - "total": 20, - "locations": [ - "mpls" - ], - "pings": [ - { - "timestamp": "2019-09-11T03:40:34.371Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 35534 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:40:04.370Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 3080 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:39:34.370Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 7810 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:39:04.371Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 1575 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:38:34.370Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 1787 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:38:04.370Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 654 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:37:34.370Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 15915 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:37:04.370Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 2679 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:36:34.371Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 2104 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:36:04.370Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 5759 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:35:34.373Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 7166 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:35:04.371Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 26830 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:34:34.371Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 993 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:34:04.381Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 3880 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:33:34.371Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 1604 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - } - ] - } -} \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/ping_list_sort.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/ping_list_sort.json deleted file mode 100644 index b9b8deae2e564..0000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/ping_list_sort.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "allPings": { - "total": 20, - "locations": [ - "mpls" - ], - "pings": [ - { - "timestamp": "2019-09-11T03:31:04.380Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 56940 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:31:34.366Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 9861 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:32:04.372Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 2924 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:32:34.375Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 21665 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - }, - { - "timestamp": "2019-09-11T03:33:04.370Z", - "http": { - "response": { - "status_code": 200, - "body": { - "bytes": 3, - "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf", - "content": null, - "content_bytes": null - } - } - }, - "error": null, - "monitor": { - "duration": { - "us": 2128 - }, - "id": "0001-up", - "ip": "127.0.0.1", - "name": "", - "scheme": null, - "status": "up", - "type": "http" - }, - "observer": { - "geo": { - "name": "mpls" - } - } - } - ] - } -} \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/index.ts b/x-pack/test/api_integration/apis/uptime/graphql/index.ts index 2e0b5e2eea2a5..862cce47f257a 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/index.ts +++ b/x-pack/test/api_integration/apis/uptime/graphql/index.ts @@ -13,6 +13,5 @@ export default function({ loadTestFile }: FtrProviderContext) { // verifying the pre-loaded documents are returned in a way that // matches the snapshots contained in './fixtures' loadTestFile(require.resolve('./monitor_states')); - loadTestFile(require.resolve('./ping_list')); }); } diff --git a/x-pack/test/api_integration/apis/uptime/graphql/ping_list.ts b/x-pack/test/api_integration/apis/uptime/graphql/ping_list.ts deleted file mode 100644 index c84b9c382acdd..0000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/ping_list.ts +++ /dev/null @@ -1,116 +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 expect from '@kbn/expect'; -import { pingsQueryString } from '../../../../../legacy/plugins/uptime/public/queries'; -import { expectFixtureEql } from './helpers/expect_fixture_eql'; -import { Ping, PingResults } from '../../../../../legacy/plugins/uptime/common/graphql/types'; - -const expectPingFixtureEql = (data: { allPings: PingResults }, fixtureName: string) => { - expectFixtureEql(data, fixtureName, d => d.allPings.pings.forEach((p: Ping) => delete p.id)); -}; - -export default function({ getService }: any) { - describe('pingList query', () => { - before('load heartbeat data', () => getService('esArchiver').load('uptime/full_heartbeat')); - after('unload heartbeat index', () => getService('esArchiver').unload('uptime/full_heartbeat')); - - const supertest = getService('supertest'); - - it('returns a list of pings for the given date range and default size', async () => { - const getPingsQuery = { - operationName: 'PingList', - query: pingsQueryString, - variables: { - dateRangeStart: '2019-01-28T17:40:08.078Z', - dateRangeEnd: '2025-01-28T19:00:16.078Z', - }, - }; - const { - body: { data }, - } = await supertest - .post('/api/uptime/graphql') - .set('kbn-xsrf', 'foo') - .send({ ...getPingsQuery }); - const { - allPings: { pings }, - } = data; - expect(pings).length(10); - - expectPingFixtureEql(data, 'ping_list'); - }); - - it('returns a list of pings for the date range and given size', async () => { - const SIZE = 50; - const getPingsQuery = { - operationName: 'PingList', - query: pingsQueryString, - variables: { - dateRangeStart: '2019-01-28T17:40:08.078Z', - dateRangeEnd: '2025-01-28T19:00:16.078Z', - size: SIZE, - }, - }; - const { - body: { data }, - } = await supertest - .post('/api/uptime/graphql') - .set('kbn-xsrf', 'foo') - .send({ ...getPingsQuery }); - const { - allPings: { pings }, - } = data; - expect(pings).length(SIZE); - expectPingFixtureEql(data, 'ping_list_count'); - }); - - it('returns a list of pings for a monitor ID', async () => { - const SIZE = 15; - const MONITOR_ID = '0001-up'; - const getPingsQuery = { - operationName: 'PingList', - query: pingsQueryString, - variables: { - dateRangeStart: '2019-01-28T17:40:08.078Z', - dateRangeEnd: '2025-01-28T19:00:16.078Z', - monitorId: MONITOR_ID, - size: SIZE, - }, - }; - const { - body: { data }, - } = await supertest - .post('/api/uptime/graphql') - .set('kbn-xsrf', 'foo') - .send({ ...getPingsQuery }); - expectPingFixtureEql(data, 'ping_list_monitor_id'); - }); - - it('returns a list of pings sorted ascending', async () => { - const SIZE = 5; - const MONITOR_ID = '0001-up'; - const getPingsQuery = { - operationName: 'PingList', - query: pingsQueryString, - variables: { - dateRangeStart: '2019-01-28T17:40:08.078Z', - dateRangeEnd: '2025-01-28T19:00:16.078Z', - monitorId: MONITOR_ID, - size: SIZE, - sort: 'asc', - }, - }; - const { - body: { data }, - } = await supertest - .post('/api/uptime/graphql') - .set('kbn-xsrf', 'foo') - .send({ ...getPingsQuery }); - - expectPingFixtureEql(data, 'ping_list_sort'); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/uptime/index.ts b/x-pack/test/api_integration/apis/uptime/index.ts index a21db08d58c4d..8def64b71a110 100644 --- a/x-pack/test/api_integration/apis/uptime/index.ts +++ b/x-pack/test/api_integration/apis/uptime/index.ts @@ -10,11 +10,12 @@ export default function({ getService, loadTestFile }: FtrProviderContext) { const es = getService('legacyEs'); describe('uptime', () => { - before(() => - es.indices.delete({ - index: 'heartbeat*', - ignore: [404], - }) + before( + async () => + await es.indices.delete({ + index: 'heartbeat*', + ignore: [404], + }) ); loadTestFile(require.resolve('./feature_controls')); diff --git a/x-pack/test/api_integration/apis/uptime/rest/dynamic_settings.ts b/x-pack/test/api_integration/apis/uptime/rest/dynamic_settings.ts index f4dd7c244f8b5..a1b731169f0a0 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/dynamic_settings.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/dynamic_settings.ts @@ -18,7 +18,13 @@ export default function({ getService }: FtrProviderContext) { }); it('can change the settings', async () => { - const newSettings = { heartbeatIndices: 'myIndex1*' }; + const newSettings = { + heartbeatIndices: 'myIndex1*', + certificatesThresholds: { + errorState: 5, + warningState: 15, + }, + }; const postResponse = await supertest .post(`/api/uptime/dynamic_settings`) .set('kbn-xsrf', 'true') diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_charts.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_charts.json index 8edcff158b0ae..e96b3b3b562b9 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_charts.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_charts.json @@ -85,129 +85,5 @@ } ] } - ], - "status": [ - { - "x": 1568172664000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568172694000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568172724000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568172754000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568172784000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568172814000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568172844000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568172874000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568172904000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568172934000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568172964000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568172994000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568173024000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568173054000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568173084000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568173114000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568173144000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568173174000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568173204000, - "up": null, - "down": null, - "total": 1 - }, - { - "x": 1568173234000, - "up": null, - "down": null, - "total": 1 - } - ], - "durationMaxValue": 0, - "statusMaxCount": 0 + ] } \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_charts_empty_sets.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_charts_empty_sets.json index 674338101bc5b..5157eace006cf 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_charts_empty_sets.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_charts_empty_sets.json @@ -1,6 +1,3 @@ { - "locationDurationLines": [], - "status": [], - "durationMaxValue": 0, - "statusMaxCount": 0 + "locationDurationLines": [] } \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_latest_status.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_latest_status.json index 2e5854f4d9866..9a33be807670e 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_latest_status.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_latest_status.json @@ -1,5 +1,4 @@ { - "timestamp": "2019-09-11T03:40:34.371Z", "observer": { "geo": { "name": "mpls", @@ -7,6 +6,7 @@ }, "hostname": "avc-x1x" }, + "@timestamp": "2019-09-11T03:40:34.371Z", "monitor": { "duration": { "us": 24627 @@ -25,5 +25,7 @@ "domain": "localhost", "query": "r=200x1", "full": "http://localhost:5678/pattern?r=200x1" - } -} + }, + "docId": "h5toHm0B0I9WX_CznN_V", + "timestamp": "2019-09-11T03:40:34.371Z" +} \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/rest/index.ts b/x-pack/test/api_integration/apis/uptime/rest/index.ts index 9b0cd61c22462..3f8df81856f5c 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/index.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/index.ts @@ -45,10 +45,11 @@ export default function({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./telemetry_collectors')); }); describe('with real-world data', () => { - before('load heartbeat data', async () => await esArchiver.load('uptime/full_heartbeat')); - after('unload', async () => await esArchiver.unload('uptime/full_heartbeat')); + beforeEach('load heartbeat data', async () => await esArchiver.load('uptime/full_heartbeat')); + afterEach('unload', async () => await esArchiver.unload('uptime/full_heartbeat')); loadTestFile(require.resolve('./monitor_latest_status')); loadTestFile(require.resolve('./ping_histogram')); + loadTestFile(require.resolve('./ping_list')); loadTestFile(require.resolve('./monitor_duration')); loadTestFile(require.resolve('./doc_count')); }); diff --git a/x-pack/test/api_integration/apis/uptime/rest/ping_list.ts b/x-pack/test/api_integration/apis/uptime/rest/ping_list.ts new file mode 100644 index 0000000000000..a261763d5991f --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/rest/ping_list.ts @@ -0,0 +1,178 @@ +/* + * 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 { isLeft } from 'fp-ts/lib/Either'; +import { PathReporter } from 'io-ts/lib/PathReporter'; +import { PingsResponseType } from '../../../../../legacy/plugins/uptime/common/runtime_types'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +function decodePingsResponseData(response: any) { + const decoded = PingsResponseType.decode(response); + if (isLeft(decoded)) { + throw Error(JSON.stringify(PathReporter.report(decoded), null, 2)); + } + return decoded.right; +} + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + describe('pingList query', () => { + before('load heartbeat data', () => getService('esArchiver').load('uptime/full_heartbeat')); + after('unload heartbeat index', () => getService('esArchiver').unload('uptime/full_heartbeat')); + + it('returns a list of pings for the given date range and default size', async () => { + const from = '2019-01-28T17:40:08.078Z'; + const to = '2025-01-28T19:00:16.078Z'; + + const apiResponse = await supertest.get(`/api/uptime/pings?from=${from}&to=${to}&size=10`); + + const { total, locations, pings } = decodePingsResponseData(apiResponse.body); + + expect(total).to.be(2000); + expect(locations).to.eql(['mpls']); + expect(pings).length(10); + expect(pings.map(({ monitor: { id } }) => id)).to.eql([ + '0074-up', + '0073-up', + '0099-up', + '0098-up', + '0075-intermittent', + '0097-up', + '0049-up', + '0047-up', + '0077-up', + '0076-up', + ]); + }); + + it('returns a list of pings for the date range and given size', async () => { + const from = '2019-01-28T17:40:08.078Z'; + const to = '2025-01-28T19:00:16.078Z'; + const size = 50; + + const apiResponse = await supertest.get( + `/api/uptime/pings?from=${from}&to=${to}&size=${size}` + ); + + const { total, locations, pings } = decodePingsResponseData(apiResponse.body); + + expect(total).to.be(2000); + expect(locations).to.eql(['mpls']); + expect(pings).length(50); + expect(pings.map(({ monitor: { id } }) => id)).to.eql([ + '0074-up', + '0073-up', + '0099-up', + '0098-up', + '0075-intermittent', + '0097-up', + '0049-up', + '0047-up', + '0077-up', + '0076-up', + '0050-down', + '0048-up', + '0072-up', + '0096-up', + '0092-up', + '0069-up', + '0093-up', + '0070-down', + '0071-up', + '0095-up', + '0032-up', + '0094-up', + '0046-up', + '0091-up', + '0067-up', + '0068-up', + '0090-intermittent', + '0031-up', + '0066-up', + '0084-up', + '0083-up', + '0041-up', + '0045-intermittent', + '0042-up', + '0030-intermittent', + '0063-up', + '0061-up', + '0065-up', + '0062-up', + '0026-up', + '0085-up', + '0025-up', + '0088-up', + '0089-up', + '0087-up', + '0028-up', + '0086-up', + '0064-up', + '0029-up', + '0044-up', + ]); + }); + + it('returns a list of pings for a monitor ID', async () => { + const from = '2019-01-28T17:40:08.078Z'; + const to = '2025-01-28T19:00:16.078Z'; + const monitorId = '0001-up'; + const size = 15; + + const apiResponse = await supertest.get( + `/api/uptime/pings?from=${from}&to=${to}&monitorId=${monitorId}&size=${size}` + ); + + const { total, locations, pings } = decodePingsResponseData(apiResponse.body); + + expect(total).to.be(20); + expect(locations).to.eql(['mpls']); + pings.forEach(({ monitor: { id } }) => expect(id).to.eql('0001-up')); + expect(pings.map(({ timestamp }) => timestamp)).to.eql([ + '2019-09-11T03:40:34.371Z', + '2019-09-11T03:40:04.370Z', + '2019-09-11T03:39:34.370Z', + '2019-09-11T03:39:04.371Z', + '2019-09-11T03:38:34.370Z', + '2019-09-11T03:38:04.370Z', + '2019-09-11T03:37:34.370Z', + '2019-09-11T03:37:04.370Z', + '2019-09-11T03:36:34.371Z', + '2019-09-11T03:36:04.370Z', + '2019-09-11T03:35:34.373Z', + '2019-09-11T03:35:04.371Z', + '2019-09-11T03:34:34.371Z', + '2019-09-11T03:34:04.381Z', + '2019-09-11T03:33:34.371Z', + ]); + }); + + it('returns a list of pings sorted ascending', async () => { + const from = '2019-01-28T17:40:08.078Z'; + const to = '2025-01-28T19:00:16.078Z'; + const monitorId = '0001-up'; + const size = 5; + const sort = 'asc'; + + const apiResponse = await supertest.get( + `/api/uptime/pings?from=${from}&to=${to}&monitorId=${monitorId}&size=${size}&sort=${sort}` + ); + + const { total, locations, pings } = decodePingsResponseData(apiResponse.body); + + expect(total).to.be(20); + expect(locations).to.eql(['mpls']); + expect(pings.map(({ timestamp }) => timestamp)).to.eql([ + '2019-09-11T03:31:04.380Z', + '2019-09-11T03:31:34.366Z', + '2019-09-11T03:32:04.372Z', + '2019-09-11T03:32:34.375Z', + '2019-09-11T03:33:04.370Z', + ]); + }); + }); +} diff --git a/x-pack/test/api_integration/config.js b/x-pack/test/api_integration/config.js index b62368bf2d608..0eac7c58044e6 100644 --- a/x-pack/test/api_integration/config.js +++ b/x-pack/test/api_integration/config.js @@ -27,6 +27,8 @@ export async function getApiIntegrationConfig({ readConfigFile }) { '--xpack.security.session.idleTimeout=3600000', // 1 hour '--optimize.enabled=false', '--xpack.endpoint.enabled=true', + '--telemetry.optIn=true', + '--xpack.endpoint.enabled=true', '--xpack.ingestManager.enabled=true', '--xpack.ingestManager.fleet.enabled=true', '--xpack.endpoint.alertResultListDefaultDateRange.from=2018-01-10T00:00:00.000Z', diff --git a/x-pack/test/api_integration/services/index.ts b/x-pack/test/api_integration/services/index.ts index 9c945f557a2d8..84b8476bd1dd1 100644 --- a/x-pack/test/api_integration/services/index.ts +++ b/x-pack/test/api_integration/services/index.ts @@ -22,6 +22,7 @@ import { import { SiemGraphQLClientProvider, SiemGraphQLClientFactoryProvider } from './siem_graphql_client'; import { InfraOpsSourceConfigurationProvider } from './infraops_source_configuration'; import { MachineLearningProvider } from './ml'; +import { IngestManagerProvider } from './ingest_manager'; export const services = { ...commonServices, @@ -39,4 +40,5 @@ export const services = { supertestWithoutAuth: SupertestWithoutAuthProvider, usageAPI: UsageAPIProvider, ml: MachineLearningProvider, + ingestManager: IngestManagerProvider, }; diff --git a/x-pack/test/api_integration/services/ingest_manager.ts b/x-pack/test/api_integration/services/ingest_manager.ts new file mode 100644 index 0000000000000..2b70a20ca0362 --- /dev/null +++ b/x-pack/test/api_integration/services/ingest_manager.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { FtrProviderContext } from '../ftr_provider_context'; +import { setupRouteService, fleetSetupRouteService } from '../../../plugins/ingest_manager/common'; + +export function IngestManagerProvider({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + return { + async setup() { + const headers = { accept: 'application/json', 'kbn-xsrf': 'some-xsrf-token' }; + + const { body } = await supertest + .get(fleetSetupRouteService.getFleetSetupPath()) + .set(headers) + .expect(200); + + if (!body.isInitialized) { + await supertest + .post(setupRouteService.getSetupPath()) + .set(headers) + .expect(200); + } + }, + }; +} diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_statuses.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_statuses.ts new file mode 100644 index 0000000000000..45805f03f8c0c --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_statuses.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { DETECTION_ENGINE_RULES_URL } from '../../../../legacy/plugins/siem/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + deleteAllRulesStatuses, + getSimpleRule, +} from './utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + describe('find_statuses', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + await deleteAllRulesStatuses(es); + }); + + it('should return an empty find statuses body correctly if no statuses are loaded', async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_find_statuses`) + .set('kbn-xsrf', 'true') + .send({ ids: [] }) + .expect(200); + + expect(body).to.eql({}); + }); + + it('should return a single rule status when a single rule is loaded from a find status with defaults added', async () => { + // add a single rule + const { body: resBody } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + await new Promise(resolve => setTimeout(resolve, 1000)).then(async () => { + // query the single rule from _find + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_find_statuses`) + .set('kbn-xsrf', 'true') + .send({ ids: [resBody.id] }) + .expect(200); + + // expected result for status should be 'going to run' or 'succeeded + expect(['succeeded', 'going to run']).to.contain(body[resBody.id].current_status.status); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts index b8034fd92e988..917654e50cb99 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts @@ -18,6 +18,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./delete_rules_bulk')); loadTestFile(require.resolve('./export_rules')); loadTestFile(require.resolve('./find_rules')); + loadTestFile(require.resolve('./find_statuses')); loadTestFile(require.resolve('./get_prepackaged_rules_status')); loadTestFile(require.resolve('./import_rules')); loadTestFile(require.resolve('./read_rules')); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts index 7b725a7830c56..0a5b2def3eb18 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts @@ -196,6 +196,19 @@ export const deleteAllAlerts = async (es: any): Promise => { }); }; +/** + * Remove all rules statuses from the .kibana index + * @param es The ElasticSearch handle + */ +export const deleteAllRulesStatuses = async (es: any): Promise => { + await es.deleteByQuery({ + index: '.kibana', + q: 'type:siem-detection-engine-rule-status', + waitForCompletion: true, + refresh: 'wait_for', + }); +}; + /** * Creates the signals index for use inside of beforeEach blocks of tests * @param supertest The supertest client library diff --git a/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/index.ts b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/index.ts index 170b7e0c6d09d..1a01cf8929c05 100644 --- a/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/index.ts +++ b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/index.ts @@ -29,7 +29,7 @@ export const plugin: PluginInitializer = core.savedObjects.registerType({ name: SAVED_OBJECT_WITH_SECRET_TYPE, hidden: false, - namespaceAgnostic: false, + namespaceType: 'single', mappings: deepFreeze({ properties: { publicProperty: { type: 'keyword' }, diff --git a/x-pack/test/endpoint_api_integration_no_ingest/apis/alerts.ts b/x-pack/test/endpoint_api_integration_no_ingest/apis/alerts.ts new file mode 100644 index 0000000000000..b75d69238d653 --- /dev/null +++ b/x-pack/test/endpoint_api_integration_no_ingest/apis/alerts.ts @@ -0,0 +1,27 @@ +/* + * 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({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + + describe('Endpoint alert API without ingest manager initialized', () => { + before(async () => { + await esArchiver.load('endpoint/alerts/api_feature'); + await esArchiver.load('endpoint/alerts/host_api_feature'); + }); + + after(async () => { + await esArchiver.unload('endpoint/alerts/api_feature'); + await esArchiver.unload('endpoint/alerts/host_api_feature'); + }); + + it('should return a 500', async () => { + await supertest.get('/api/endpoint/alerts').expect(500); + }); + }); +} diff --git a/x-pack/test/endpoint_api_integration_no_ingest/apis/index.ts b/x-pack/test/endpoint_api_integration_no_ingest/apis/index.ts new file mode 100644 index 0000000000000..6110f398df5a0 --- /dev/null +++ b/x-pack/test/endpoint_api_integration_no_ingest/apis/index.ts @@ -0,0 +1,16 @@ +/* + * 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 endpointAPIIntegrationTests({ loadTestFile }: FtrProviderContext) { + describe('Endpoint plugin', function() { + this.tags('ciGroup7'); + loadTestFile(require.resolve('./index_pattern')); + loadTestFile(require.resolve('./metadata')); + loadTestFile(require.resolve('./alerts')); + }); +} diff --git a/x-pack/test/endpoint_api_integration_no_ingest/apis/index_pattern.ts b/x-pack/test/endpoint_api_integration_no_ingest/apis/index_pattern.ts new file mode 100644 index 0000000000000..664ef7d96847c --- /dev/null +++ b/x-pack/test/endpoint_api_integration_no_ingest/apis/index_pattern.ts @@ -0,0 +1,16 @@ +/* + * 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({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('Endpoint index pattern API without ingest manager initialized', () => { + it('should not retrieve the index pattern for events', async () => { + await supertest.get('/api/endpoint/index_pattern/events').expect(404); + }); + }); +} diff --git a/x-pack/test/endpoint_api_integration_no_ingest/apis/metadata.ts b/x-pack/test/endpoint_api_integration_no_ingest/apis/metadata.ts new file mode 100644 index 0000000000000..886d3cf3d9516 --- /dev/null +++ b/x-pack/test/endpoint_api_integration_no_ingest/apis/metadata.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + describe('test metadata api when ingest manager is not initialized', () => { + before(async () => await esArchiver.load('endpoint/metadata/api_feature')); + after(async () => await esArchiver.unload('endpoint/metadata/api_feature')); + it('metadata api should return a 500', async () => { + await supertest + .post('/api/endpoint/metadata') + .set('kbn-xsrf', 'xxx') + .send() + .expect(500); + }); + }); +} diff --git a/x-pack/test/endpoint_api_integration_no_ingest/config.ts b/x-pack/test/endpoint_api_integration_no_ingest/config.ts new file mode 100644 index 0000000000000..bf8b68a7e991c --- /dev/null +++ b/x-pack/test/endpoint_api_integration_no_ingest/config.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 { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function({ readConfigFile }: FtrConfigProviderContext) { + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.js')); + + return { + ...xPackAPITestsConfig.getAll(), + testFiles: [require.resolve('./apis')], + junit: { + reportName: 'X-Pack Endpoint API Integration Without Ingest Tests', + }, + }; +} diff --git a/x-pack/test/endpoint_api_integration_no_ingest/ftr_provider_context.d.ts b/x-pack/test/endpoint_api_integration_no_ingest/ftr_provider_context.d.ts new file mode 100644 index 0000000000000..2751dbcdc6539 --- /dev/null +++ b/x-pack/test/endpoint_api_integration_no_ingest/ftr_provider_context.d.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; + +import { services } from '../api_integration/services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/functional/apps/discover/preserve_url.ts b/x-pack/test/functional/apps/discover/preserve_url.ts index 85142336a0a6b..9b9b2e2c60840 100644 --- a/x-pack/test/functional/apps/discover/preserve_url.ts +++ b/x-pack/test/functional/apps/discover/preserve_url.ts @@ -10,7 +10,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const PageObjects = getPageObjects(['common', 'discover', 'spaceSelector', 'header']); - const appsMenu = getService('appsMenu'); const globalNav = getService('globalNav'); describe('preserve url', function() { @@ -26,8 +25,7 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('discover'); await PageObjects.discover.saveSearch('A Search'); await PageObjects.common.navigateToApp('home'); - await appsMenu.clickLink('Discover'); - await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.header.clickDiscover(); const activeTitle = await globalNav.getLastBreadcrumb(); expect(activeTitle).to.be('A Search'); }); @@ -42,7 +40,7 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.spaceSelector.expectHomePage('another-space'); // other space - await appsMenu.clickLink('Discover'); + await PageObjects.header.clickDiscover(); await PageObjects.discover.saveSearch('A Search in another space'); await PageObjects.spaceSelector.openSpacesNav(); @@ -50,7 +48,7 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.spaceSelector.expectHomePage('default'); // default space - await appsMenu.clickLink('Discover'); + await PageObjects.header.clickDiscover(); await PageObjects.discover.waitUntilSearchingHasFinished(); const activeTitleDefaultSpace = await globalNav.getLastBreadcrumb(); expect(activeTitleDefaultSpace).to.be('A Search'); @@ -60,7 +58,7 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.spaceSelector.expectHomePage('another-space'); // other space - await appsMenu.clickLink('Discover'); + await PageObjects.header.clickDiscover(); await PageObjects.discover.waitUntilSearchingHasFinished(); const activeTitleOtherSpace = await globalNav.getLastBreadcrumb(); expect(activeTitleOtherSpace).to.be('A Search in another space'); diff --git a/x-pack/test/functional/apps/uptime/index.ts b/x-pack/test/functional/apps/uptime/index.ts index 3789351263b98..f47214dc2ad2f 100644 --- a/x-pack/test/functional/apps/uptime/index.ts +++ b/x-pack/test/functional/apps/uptime/index.ts @@ -16,6 +16,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const server = getService('kibanaServer'); + const uptime = getService('uptime'); describe('Uptime app', function() { this.tags('ciGroup6'); @@ -58,12 +59,14 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => { await esArchiver.unload(ARCHIVE); await esArchiver.load(ARCHIVE); await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'UTC' }); + await uptime.navigation.goToUptime(); }); after(async () => await esArchiver.unload(ARCHIVE)); - loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./overview')); loadTestFile(require.resolve('./monitor')); + loadTestFile(require.resolve('./ml_anomaly')); + loadTestFile(require.resolve('./feature_controls')); }); }); }; diff --git a/x-pack/test/functional/apps/uptime/ml_anomaly.ts b/x-pack/test/functional/apps/uptime/ml_anomaly.ts new file mode 100644 index 0000000000000..bcd165cc1afb7 --- /dev/null +++ b/x-pack/test/functional/apps/uptime/ml_anomaly.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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 ({ getService }: FtrProviderContext) => { + const uptime = getService('uptime'); + const log = getService('log'); + + describe('uptime ml anomaly', function() { + this.tags(['skipFirefox']); + const dateStart = 'Sep 10, 2019 @ 12:40:08.078'; + const dateEnd = 'Sep 11, 2019 @ 19:40:08.078'; + const monitorId = '0000-intermittent'; + + before(async () => { + if (!(await uptime.navigation.checkIfOnMonitorPage(monitorId))) { + await uptime.navigation.loadDataAndGoToMonitorPage(dateStart, dateEnd, monitorId); + } + if (await uptime.ml.alreadyHasJob()) { + log.info('Jon already exists so lets delete it to start fresh.'); + await uptime.ml.deleteMLJob(); + } + }); + + it('can open ml flyout', async () => { + await uptime.ml.openMLFlyout(); + }); + + it('has permission to create job', async () => { + expect(uptime.ml.canCreateJob()).to.eql(true); + expect(uptime.ml.hasNoLicenseInfo()).to.eql(false); + }); + + it('can create job successfully', async () => { + await uptime.ml.createMLJob(); + // await uptime.navigation.refreshApp(); + }); + + it('can open ML Manage Menu', async () => { + await uptime.ml.openMLManageMenu(); + }); + + it('can delete job successfully', async () => { + await uptime.ml.deleteMLJob(); + }); + }); +}; diff --git a/x-pack/test/functional/apps/uptime/monitor.ts b/x-pack/test/functional/apps/uptime/monitor.ts index e15750eb6157b..388d660f21eb3 100644 --- a/x-pack/test/functional/apps/uptime/monitor.ts +++ b/x-pack/test/functional/apps/uptime/monitor.ts @@ -17,7 +17,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const dateStart = 'Sep 10, 2019 @ 12:40:08.078'; const dateEnd = 'Sep 11, 2019 @ 19:40:08.078'; const monitorId = '0000-intermittent'; - const monitorName = '0000-intermittent'; before(async () => { await esArchiver.loadIfNeeded(archive); @@ -28,8 +27,29 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await esArchiver.unload(archive); }); - it('loads and displays uptime data based on date range', async () => { - await uptime.loadDataAndGoToMonitorPage(dateStart, dateEnd, monitorId, monitorName); + describe('navigation to monitor page', () => { + before(async () => { + await uptime.loadDataAndGoToMonitorPage(dateStart, dateEnd, monitorId); + }); + + it('displays ping data as expected', async () => { + await uptime.checkPingListInteractions( + [ + 'XZtoHm0B0I9WX_CznN-6', + '7ZtoHm0B0I9WX_CzJ96M', + 'pptnHm0B0I9WX_Czst5X', + 'I5tnHm0B0I9WX_CzPd46', + 'y5tmHm0B0I9WX_Czx93x', + 'XZtmHm0B0I9WX_CzUt3H', + '-JtlHm0B0I9WX_Cz3dyX', + 'k5tlHm0B0I9WX_CzaNxm', + 'NZtkHm0B0I9WX_Cz89w9', + 'zJtkHm0B0I9WX_CzftsN', + ], + 'mpls', + 'up' + ); + }); }); }); }; diff --git a/x-pack/test/functional/apps/uptime/overview.ts b/x-pack/test/functional/apps/uptime/overview.ts index 8195e6bbb6035..d0dfca64634f6 100644 --- a/x-pack/test/functional/apps/uptime/overview.ts +++ b/x-pack/test/functional/apps/uptime/overview.ts @@ -14,6 +14,13 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { describe('overview page', function() { const DEFAULT_DATE_START = 'Sep 10, 2019 @ 12:40:08.078'; const DEFAULT_DATE_END = 'Sep 11, 2019 @ 19:40:08.078'; + + beforeEach(async () => { + await uptime.goToRoot(); + await uptime.setDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); + await uptime.resetFilters(); + }); + it('loads and displays uptime data based on date range', async () => { await uptime.goToUptimeOverviewAndLoadData( DEFAULT_DATE_START, @@ -22,13 +29,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ); }); - it('runs filter query without issues', async () => { - await uptime.inputFilterQuery('monitor.status:up and monitor.id:"0000-intermittent"'); - await uptime.pageHasExpectedIds(['0000-intermittent']); - }); - it('applies filters for multiple fields', async () => { - await uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); await uptime.selectFilterItems({ location: ['mpls'], port: ['5678'], @@ -49,7 +50,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('pagination is cleared when filter criteria changes', async () => { - await uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); await uptime.changePage('next'); await uptime.pageHasExpectedIds([ '0010-down', @@ -83,7 +83,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('clears pagination parameters when size changes', async () => { - await uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); await uptime.changePage('next'); await uptime.pageUrlContains('pagination'); await uptime.setMonitorListPageSize(50); @@ -92,7 +91,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('pagination size updates to reflect current selection', async () => { - await uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); await uptime.pageHasExpectedIds([ '0000-intermittent', '0001-up', @@ -162,7 +160,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { describe('snapshot counts', () => { it('updates the snapshot count when status filter is set to down', async () => { - await uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); await uptime.setStatusFilter('down'); await retry.tryForTime(12000, async () => { @@ -172,13 +169,18 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('updates the snapshot count when status filter is set to up', async () => { - await uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); await uptime.setStatusFilter('up'); await retry.tryForTime(12000, async () => { const counts = await uptime.getSnapshotCount(); expect(counts).to.eql({ up: '93', down: '0' }); }); }); + + it('runs filter query without issues', async () => { + await uptime.inputFilterQuery('monitor.status:up and monitor.id:"0000-intermittent"'); + await uptime.pageHasExpectedIds(['0000-intermittent']); + await uptime.resetFilters(); + }); }); }); }; diff --git a/x-pack/test/functional/apps/uptime/settings.ts b/x-pack/test/functional/apps/uptime/settings.ts index 3294d928b61b3..64cfee50ac982 100644 --- a/x-pack/test/functional/apps/uptime/settings.ts +++ b/x-pack/test/functional/apps/uptime/settings.ts @@ -74,7 +74,41 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // Verify that the settings page shows the value we previously saved await settings.go(); const fields = await settings.loadFields(); - expect(fields).to.eql(newFieldValues); + expect(fields.heartbeatIndices).to.eql(newFieldValues.heartbeatIndices); + }); + + it('changing certificate expiration error threshold is reflected in settings page', async () => { + const settings = uptimeService.settings; + + await settings.go(); + + const newErrorThreshold = '5'; + await settings.changeErrorThresholdInput(newErrorThreshold); + await settings.apply(); + + await uptimePage.goToRoot(); + + // Verify that the settings page shows the value we previously saved + await settings.go(); + const fields = await settings.loadFields(); + expect(fields.certificatesThresholds.errorState).to.eql(newErrorThreshold); + }); + + it('changing certificate expiration warning threshold is reflected in settings page', async () => { + const settings = uptimeService.settings; + + await settings.go(); + + const newWarningThreshold = '15'; + await settings.changeWarningThresholdInput(newWarningThreshold); + await settings.apply(); + + await uptimePage.goToRoot(); + + // Verify that the settings page shows the value we previously saved + await settings.go(); + const fields = await settings.loadFields(); + expect(fields.certificatesThresholds.warningState).to.eql(newWarningThreshold); }); }); }; diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts index 4f12dd16247f6..cbd03110b0f14 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import expect from '@kbn/expect'; -import { VisualizeConstants } from '../../../../../../src/legacy/core_plugins/kibana/public/visualize/np_ready/visualize_constants'; +import { VisualizeConstants } from '../../../../../../src/plugins/visualize/public/application/visualize_constants'; import { FtrProviderContext } from '../../../ftr_provider_context'; export default function({ getPageObjects, getService }: FtrProviderContext) { diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index bc9a67da731cc..f26110513a9b3 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -85,7 +85,6 @@ export default async function({ readConfigFile }) { '--stats.maximumWaitTimeForAllCollectorsInS=1', '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', // server restarts should not invalidate active sessions '--xpack.encryptedSavedObjects.encryptionKey="DkdXazszSCYexXqz4YktBGHCRkV6hyNK"', - '--telemetry.banner=false', '--timelion.ui.enabled=true', ], }, diff --git a/x-pack/test/functional/config_security_basic.js b/x-pack/test/functional/config_security_basic.js index 12d94e922a97c..2bb59796b5517 100644 --- a/x-pack/test/functional/config_security_basic.js +++ b/x-pack/test/functional/config_security_basic.js @@ -42,7 +42,6 @@ export default async function({ readConfigFile }) { ...kibanaCommonConfig.get('kbnTestServer.serverArgs'), '--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d', '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', // server restarts should not invalidate active sessions - '--telemetry.banner=false', ], }, uiSettings: { diff --git a/x-pack/test/functional/es_archives/endpoint/alerts/host_api_feature/data.json.gz b/x-pack/test/functional/es_archives/endpoint/alerts/host_api_feature/data.json.gz index 94a96c54ee9cb..a71281c0ecfec 100644 Binary files a/x-pack/test/functional/es_archives/endpoint/alerts/host_api_feature/data.json.gz and b/x-pack/test/functional/es_archives/endpoint/alerts/host_api_feature/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/endpoint/alerts/host_api_feature/mappings.json b/x-pack/test/functional/es_archives/endpoint/alerts/host_api_feature/mappings.json index 61ddf3c4e65db..f9d5de0d0a94c 100644 --- a/x-pack/test/functional/es_archives/endpoint/alerts/host_api_feature/mappings.json +++ b/x-pack/test/functional/es_archives/endpoint/alerts/host_api_feature/mappings.json @@ -1,33 +1,62 @@ { "type": "index", "value": { - "aliases": { - }, - "index": "endpoint-agent-1", + "aliases": {}, + "index": "metrics-endpoint-default-1", "mappings": { + "_meta": { + "version": "1.5.0-dev" + }, + "date_detection": false, + "dynamic_templates": [ + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], "properties": { "@timestamp": { - "type": "long" + "type": "date" }, - "agent": { + "elastic": { "properties": { - "id": { - "fields": { - "keyword": { - "ignore_above": 256, + "agent": { + "properties": { + "id": { + "ignore_above": 1024, "type": "keyword" } }, - "type": "text" + "type": "object" + } + } + }, + "agent": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" }, "version": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" } } }, @@ -36,109 +65,71 @@ "policy": { "properties": { "id": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "name": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" } - } + }, + "type": "object" } } }, "event": { "properties": { "created": { - "type": "long" + "type": "date" } } }, "host": { "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, "hostname": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" }, "id": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" }, "ip": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "type": "ip" }, "mac": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" }, "os": { "properties": { "full": { "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" + "text": { + "norms": false, + "type": "text" } }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" }, "name": { "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" + "text": { + "norms": false, + "type": "text" } }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" }, "variant": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" }, "version": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" } } } @@ -146,11 +137,16 @@ } } }, + "order": 1, "settings": { "index": { - "number_of_replicas": "1", - "number_of_shards": "1" + "mapping": { + "total_fields": { + "limit": 10000 + } + }, + "refresh_interval": "5s" } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/data.json b/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/data.json index 2046f46db9f53..d3617dc236375 100644 --- a/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/data.json +++ b/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/data.json @@ -2,7 +2,7 @@ "type": "doc", "value": { "id": "3KVN2G8BYQH1gtPUuYk7", - "index": "endpoint-agent-1", + "index": "metrics-endpoint-default-1", "source": { "@timestamp": 1579881969541, "agent": { @@ -51,7 +51,7 @@ "type": "doc", "value": { "id": "3aVN2G8BYQH1gtPUuYk7", - "index": "endpoint-agent-1", + "index": "metrics-endpoint-default-1", "source": { "@timestamp": 1579881969541, "agent": { @@ -99,7 +99,7 @@ "type": "doc", "value": { "id": "3qVN2G8BYQH1gtPUuYk7", - "index": "endpoint-agent-1", + "index": "metrics-endpoint-default-1", "source": { "@timestamp": 1579881969541, "agent": { @@ -145,7 +145,7 @@ "type": "doc", "value": { "id": "36VN2G8BYQH1gtPUuYk7", - "index": "endpoint-agent-1", + "index": "metrics-endpoint-default-1", "source": { "@timestamp": 1579878369541, "agent": { @@ -194,7 +194,7 @@ "type": "doc", "value": { "id": "4KVN2G8BYQH1gtPUuYk7", - "index": "endpoint-agent-1", + "index": "metrics-endpoint-default-1", "source": { "@timestamp": 1579878369541, "agent": { @@ -241,7 +241,7 @@ "type": "doc", "value": { "id": "4aVN2G8BYQH1gtPUuYk7", - "index": "endpoint-agent-1", + "index": "metrics-endpoint-default-1", "source": { "@timestamp": 1579878369541, "agent": { @@ -288,7 +288,7 @@ "type": "doc", "value": { "id": "4qVN2G8BYQH1gtPUuYk7", - "index": "endpoint-agent-1", + "index": "metrics-endpoint-default-1", "source": { "@timestamp": 1579874769541, "agent": { @@ -336,7 +336,7 @@ "type": "doc", "value": { "id": "46VN2G8BYQH1gtPUuYk7", - "index": "endpoint-agent-1", + "index": "metrics-endpoint-default-1", "source": { "@timestamp": 1579874769541, "agent": { @@ -383,7 +383,7 @@ "type": "doc", "value": { "id": "5KVN2G8BYQH1gtPUuYk7", - "index": "endpoint-agent-1", + "index": "metrics-endpoint-default-1", "source": { "@timestamp": 1579874769541, "agent": { diff --git a/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/mappings.json b/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/mappings.json index c9a6c183f0489..f9d5de0d0a94c 100644 --- a/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/mappings.json +++ b/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/mappings.json @@ -1,50 +1,62 @@ { "type": "index", "value": { - "aliases": { - }, - "index": "endpoint-agent-1", + "aliases": {}, + "index": "metrics-endpoint-default-1", "mappings": { + "_meta": { + "version": "1.5.0-dev" + }, + "date_detection": false, + "dynamic_templates": [ + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], "properties": { "@timestamp": { - "type": "long" + "type": "date" }, "elastic": { "properties": { "agent": { "properties": { "id": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" } - } + }, + "type": "object" } } }, "agent": { "properties": { "id": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" }, "version": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" } } }, @@ -53,109 +65,71 @@ "policy": { "properties": { "id": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "name": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" } - } + }, + "type": "object" } } }, "event": { "properties": { "created": { - "type": "long" + "type": "date" } } }, "host": { "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, "hostname": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" }, "id": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" }, "ip": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "type": "ip" }, "mac": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" }, "os": { "properties": { "full": { "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" + "text": { + "norms": false, + "type": "text" } }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" }, "name": { "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" + "text": { + "norms": false, + "type": "text" } }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" }, "variant": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" }, "version": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "ignore_above": 1024, + "type": "keyword" } } } @@ -163,10 +137,15 @@ } } }, + "order": 1, "settings": { "index": { - "number_of_replicas": "1", - "number_of_shards": "1" + "mapping": { + "total_fields": { + "limit": 10000 + } + }, + "refresh_interval": "5s" } } } diff --git a/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/data.json.gz b/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/data.json.gz index 2b204d0bde271..454d260a518cd 100644 Binary files a/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/data.json.gz and b/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/mappings.json b/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/mappings.json index cf647f5c53212..fbcfa4cbe49b3 100644 --- a/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/mappings.json +++ b/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/mappings.json @@ -9,45 +9,62 @@ "mappings": { "_meta": { "migrationMappingPropertyHashes": { - "action": "c0c235fba02ebd2a2412bcda79009b58", + "action": "6e96ac5e648f57523879661ea72525b7", "action_task_params": "a9d49f184ee89641044be0ca2950fa3a", - "alert": "d49f9b8d1277c6004506eec20dc0b108", + "agent_actions": "ed270b46812f0fa1439366c428a2cf17", + "agent_configs": "38abaf89513877745c359e7700c0c66a", + "agent_events": "3231653fafe4ef3196fe3b32ab774bf2", + "agents": "c3eeb7b9d97176f15f6d126370ab23c7", + "alert": "7b44fba6773e37c806ce290ea9b7024e", "apm-indices": "9bb9b2bf1fa636ed8619cbab5ce6a1dd", - "apm-services-telemetry": "07ee1939fa4302c62ddc052ec03fed90", + "apm-telemetry": "3525d7c22c42bc80f5e6e9cb3f2b26a2", + "application_usage_totals": "c897e4310c5f24b07caaff3db53ae2c1", + "application_usage_transactional": "965839e75f809fefe04f92dc4d99722a", "canvas-element": "7390014e1091044523666d97247392fc", "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", - "config": "87aca8fdb053154f11383fce3dbf3edf", + "cases": "08b8b110dbca273d37e8aef131ecab61", + "cases-comments": "c2061fb929f585df57425102fa928b4b", + "cases-configure": "42711cbb311976c0687853f4c1354572", + "cases-user-actions": "32277330ec6b721abe3b846cfd939a71", + "config": "ae24d22d5986d04124cc6568f771066f", "dashboard": "d00f614b29a80360e1190193fd333bab", + "datasources": "d4bc0c252b2b5683ff21ea32d00acffc", + "enrollment_api_keys": "28b91e20b105b6f928e2012600085d8f", + "epm-package": "0be91c6758421dd5d0f1a58e9e5bc7c3", "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e", "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", "index-pattern": "66eccb05066c5a89924f48a9e9736499", "infrastructure-ui-source": "ddc0ecb18383f6b26101a2fadb2dab0c", - "inventory-view": "84b320fd67209906333ffce261128462", + "inventory-view": "9ecce5b58867403613d82fe496470b34", "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", "lens": "21c3ea0763beb1ecb0162529706b88c5", "lens-ui-telemetry": "509bfa5978586998e05f9e303c07a327", "map": "23d7aa4a720d4938ccde3983f87bd58d", - "maps-telemetry": "a4229f8b16a6820c6d724b7e0c1f729d", + "maps-telemetry": "268da3a48066123fc5baf35abaa55014", "metrics-explorer-view": "53c5365793677328df0ccb6138bf3cdd", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", "namespace": "2f4316de49999235636386fe51dc06c1", + "outputs": "aee9782e0d500b867859650a36280165", "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", "references": "7997cf5a56cc02bdc9c93361bde732b0", "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", "search": "181661168bbadd1eff5902361e2a0d5c", "server": "ec97f1c5da1a19609a60874e5af1100c", - "siem-ui-timeline": "6485ab095be8d15246667b98a1a34295", + "siem-detection-engine-rule-actions": "90eee2e4635260f4be0a1da8f5bc0aa0", + "siem-detection-engine-rule-status": "ae783f41c6937db6b7a2ef5c93a9e9b0", + "siem-ui-timeline": "ac8020190f5950dd3250b6499144e7fb", "siem-ui-timeline-note": "8874706eedc49059d4cf0f5094559084", "siem-ui-timeline-pinned-event": "20638091112f0e14f0e443d512301c29", - "space": "c5ca8acafa0beaa4d08d014a97b6bc6b", - "telemetry": "358ffaa88ba34a97d55af0933a117de4", + "telemetry": "36a616f7026dfa617d6655df850fe16d", "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", + "tsvb-validation-telemetry": "3a37ef6c8700ae6fc97d5c7da00e9215", "type": "2f4316de49999235636386fe51dc06c1", "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", "upgrade-assistant-reindex-operation": "a53a20fe086b72c9a86da3cc12dad8a6", "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", + "uptime-dynamic-settings": "b6289473c8985c79b6c47eebc19a0ca5", "url": "c7f66a0df8b1b52f17c28c4adb111105", "visualization": "52d7a13ad68a150c4525b292d23e12cc" } @@ -64,6 +81,11 @@ "type": "object" }, "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, "type": "text" }, "secrets": { @@ -85,6 +107,145 @@ } } }, + "agent_actions": { + "properties": { + "agent_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "data": { + "type": "flattened" + }, + "sent_at": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + }, + "agent_configs": { + "properties": { + "datasources": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "id": { + "type": "keyword" + }, + "is_default": { + "type": "boolean" + }, + "name": { + "type": "text" + }, + "namespace": { + "type": "keyword" + }, + "revision": { + "type": "integer" + }, + "status": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + }, + "updated_on": { + "type": "keyword" + } + } + }, + "agent_events": { + "properties": { + "action_id": { + "type": "keyword" + }, + "agent_id": { + "type": "keyword" + }, + "config_id": { + "type": "keyword" + }, + "data": { + "type": "text" + }, + "message": { + "type": "text" + }, + "payload": { + "type": "text" + }, + "stream_id": { + "type": "keyword" + }, + "subtype": { + "type": "keyword" + }, + "timestamp": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + }, + "agents": { + "properties": { + "access_api_key_id": { + "type": "keyword" + }, + "active": { + "type": "boolean" + }, + "config_id": { + "type": "keyword" + }, + "config_newest_revision": { + "type": "integer" + }, + "config_revision": { + "type": "integer" + }, + "current_error_events": { + "type": "text" + }, + "default_api_key": { + "type": "keyword" + }, + "enrolled_at": { + "type": "date" + }, + "last_checkin": { + "type": "date" + }, + "last_updated": { + "type": "date" + }, + "local_metadata": { + "type": "text" + }, + "shared_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "user_provided_metadata": { + "type": "text" + }, + "version": { + "type": "keyword" + } + } + }, "alert": { "properties": { "actions": { @@ -114,15 +275,18 @@ "apiKeyOwner": { "type": "keyword" }, + "consumer": { + "type": "keyword" + }, + "createdAt": { + "type": "date" + }, "createdBy": { "type": "keyword" }, "enabled": { "type": "boolean" }, - "interval": { - "type": "keyword" - }, "muteAll": { "type": "boolean" }, @@ -130,12 +294,24 @@ "type": "keyword" }, "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, "type": "text" }, "params": { "enabled": false, "type": "object" }, + "schedule": { + "properties": { + "interval": { + "type": "keyword" + } + } + }, "scheduledTaskId": { "type": "keyword" }, @@ -221,52 +397,947 @@ }, "apm-telemetry": { "properties": { - "has_any_services": { - "type": "boolean" - }, - "services_per_agent": { + "agents": { "properties": { "dotnet": { - "null_value": 0, - "type": "long" + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } }, "go": { - "null_value": 0, - "type": "long" + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } }, "java": { - "null_value": 0, - "type": "long" + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } }, "js-base": { - "null_value": 0, - "type": "long" + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } }, "nodejs": { - "null_value": 0, - "type": "long" + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } }, "python": { - "null_value": 0, - "type": "long" + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } }, "ruby": { - "null_value": 0, - "type": "long" + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } }, "rum-js": { - "null_value": 0, - "type": "long" + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } } } - } - } - }, - "canvas-element": { - "dynamic": "false", - "properties": { - "@created": { - "type": "date" + }, + "cardinality": { + "properties": { + "transaction": { + "properties": { + "name": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + }, + "user_agent": { + "properties": { + "original": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + } + } + }, + "counts": { + "properties": { + "agent_configuration": { + "properties": { + "all": { + "type": "long" + } + } + }, + "error": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "max_error_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "max_transaction_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "services": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "sourcemap": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "span": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "traces": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + } + } + }, + "has_any_services": { + "type": "boolean" + }, + "indices": { + "properties": { + "all": { + "properties": { + "total": { + "properties": { + "docs": { + "properties": { + "count": { + "type": "long" + } + } + }, + "store": { + "properties": { + "size_in_bytes": { + "type": "long" + } + } + } + } + } + } + }, + "shards": { + "properties": { + "total": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "ml": { + "properties": { + "all_jobs_count": { + "type": "long" + } + } + } + } + }, + "retainment": { + "properties": { + "error": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "span": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "services_per_agent": { + "properties": { + "dotnet": { + "null_value": 0, + "type": "long" + }, + "go": { + "null_value": 0, + "type": "long" + }, + "java": { + "null_value": 0, + "type": "long" + }, + "js-base": { + "null_value": 0, + "type": "long" + }, + "nodejs": { + "null_value": 0, + "type": "long" + }, + "python": { + "null_value": 0, + "type": "long" + }, + "ruby": { + "null_value": 0, + "type": "long" + }, + "rum-js": { + "null_value": 0, + "type": "long" + } + } + }, + "tasks": { + "properties": { + "agent_configuration": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "agents": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "cardinality": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "groupings": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "indices_stats": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "processor_events": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "services": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "versions": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + } + } + }, + "version": { + "properties": { + "apm_server": { + "properties": { + "major": { + "type": "long" + }, + "minor": { + "type": "long" + }, + "patch": { + "type": "long" + } + } + } + } + } + } + }, + "application_usage_totals": { + "properties": { + "appId": { + "type": "keyword" + }, + "minutesOnScreen": { + "type": "float" + }, + "numberOfClicks": { + "type": "long" + } + } + }, + "application_usage_transactional": { + "properties": { + "appId": { + "type": "keyword" + }, + "minutesOnScreen": { + "type": "float" + }, + "numberOfClicks": { + "type": "long" + }, + "timestamp": { + "type": "date" + } + } + }, + "canvas-element": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" }, "@timestamp": { "type": "date" @@ -285,26 +1356,257 @@ "keyword": { "type": "keyword" } - }, - "type": "text" + }, + "type": "text" + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "cases": { + "properties": { + "closed_at": { + "type": "date" + }, + "closed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "description": { + "type": "text" + }, + "external_service": { + "properties": { + "connector_id": { + "type": "keyword" + }, + "connector_name": { + "type": "keyword" + }, + "external_id": { + "type": "keyword" + }, + "external_title": { + "type": "text" + }, + "external_url": { + "type": "text" + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "status": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-comments": { + "properties": { + "comment": { + "type": "text" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-configure": { + "properties": { + "closure_type": { + "type": "keyword" + }, + "connector_id": { + "type": "keyword" + }, + "connector_name": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } } } }, - "canvas-workpad": { - "dynamic": "false", + "cases-user-actions": { "properties": { - "@created": { - "type": "date" + "action": { + "type": "keyword" }, - "@timestamp": { + "action_at": { "type": "date" }, - "name": { - "fields": { - "keyword": { + "action_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { "type": "keyword" } - }, + } + }, + "action_field": { + "type": "keyword" + }, + "new_value": { + "type": "text" + }, + "old_value": { "type": "text" } } @@ -389,6 +1691,136 @@ } } }, + "datasources": { + "properties": { + "config_id": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "enabled": { + "type": "boolean" + }, + "inputs": { + "properties": { + "config": { + "type": "flattened" + }, + "enabled": { + "type": "boolean" + }, + "processors": { + "type": "keyword" + }, + "streams": { + "properties": { + "config": { + "type": "flattened" + }, + "dataset": { + "type": "keyword" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "processors": { + "type": "keyword" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "name": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "output_id": { + "type": "keyword" + }, + "package": { + "properties": { + "name": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "revision": { + "type": "integer" + } + } + }, + "enrollment_api_keys": { + "properties": { + "active": { + "type": "boolean" + }, + "api_key": { + "type": "binary" + }, + "api_key_id": { + "type": "keyword" + }, + "config_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "expire_at": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + } + } + }, + "epm-package": { + "properties": { + "installed": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "internal": { + "type": "boolean" + }, + "name": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, "file-upload-telemetry": { "properties": { "filesUploadedTotalCount": { @@ -538,6 +1970,26 @@ } } }, + "customMetrics": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "field": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "label": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, "customOptions": { "properties": { "field": { @@ -572,6 +2024,18 @@ }, "metric": { "properties": { + "aggregation": { + "type": "keyword" + }, + "field": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "label": { + "type": "keyword" + }, "type": { "type": "keyword" } @@ -699,9 +2163,19 @@ } } }, + "indexPatternsWithGeoFieldCount": { + "type": "long" + }, "mapsTotalCount": { "type": "long" }, + "settings": { + "properties": { + "showMapVisualizationTypes": { + "type": "boolean" + } + } + }, "timeCaptured": { "type": "date" } @@ -803,7 +2277,7 @@ }, "type": "text" }, - "space": { + "visualization": { "fields": { "keyword": { "ignore_above": 256, @@ -828,6 +2302,37 @@ "namespace": { "type": "keyword" }, + "outputs": { + "properties": { + "api_key": { + "type": "keyword" + }, + "ca_sha256": { + "type": "keyword" + }, + "config": { + "type": "flattened" + }, + "fleet_enroll_password": { + "type": "binary" + }, + "fleet_enroll_username": { + "type": "binary" + }, + "hosts": { + "type": "keyword" + }, + "is_default": { + "type": "boolean" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, "query": { "properties": { "description": { @@ -917,6 +2422,73 @@ } } }, + "siem-detection-engine-rule-actions": { + "properties": { + "actions": { + "properties": { + "action_type_id": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "params": { + "dynamic": "true", + "type": "object" + } + } + }, + "alertThrottle": { + "type": "keyword" + }, + "ruleAlertId": { + "type": "keyword" + }, + "ruleThrottle": { + "type": "keyword" + } + } + }, + "siem-detection-engine-rule-status": { + "properties": { + "alertId": { + "type": "keyword" + }, + "bulkCreateTimeDurations": { + "type": "float" + }, + "gap": { + "type": "text" + }, + "lastFailureAt": { + "type": "date" + }, + "lastFailureMessage": { + "type": "text" + }, + "lastLookBackDate": { + "type": "date" + }, + "lastSuccessAt": { + "type": "date" + }, + "lastSuccessMessage": { + "type": "text" + }, + "searchAfterTimeDurations": { + "type": "float" + }, + "status": { + "type": "keyword" + }, + "statusDate": { + "type": "date" + } + } + }, "siem-ui-timeline": { "properties": { "columns": { @@ -1051,6 +2623,9 @@ "description": { "type": "text" }, + "eventType": { + "type": "keyword" + }, "favorite": { "properties": { "favoriteDate": { @@ -1255,6 +2830,9 @@ }, "telemetry": { "properties": { + "allowChangingOptInStatus": { + "type": "boolean" + }, "enabled": { "type": "boolean" }, @@ -1262,11 +2840,15 @@ "type": "date" }, "lastVersionChecked": { - "ignore_above": 256, + "type": "keyword" + }, + "reportFailureCount": { + "type": "integer" + }, + "reportFailureVersion": { "type": "keyword" }, "sendUsageFrom": { - "ignore_above": 256, "type": "keyword" }, "userHasSeenNotice": { @@ -1315,6 +2897,13 @@ } } }, + "tsvb-validation-telemetry": { + "properties": { + "failedRequests": { + "type": "long" + } + } + }, "type": { "type": "keyword" }, @@ -1391,6 +2980,13 @@ } } }, + "uptime-dynamic-settings": { + "properties": { + "heartbeatIndices": { + "type": "keyword" + } + } + }, "url": { "properties": { "accessCount": { diff --git a/x-pack/test/functional/es_archives/reporting/historic/data.json.gz b/x-pack/test/functional/es_archives/reporting/historic/data.json.gz deleted file mode 100644 index ecb85ec6faca4..0000000000000 Binary files a/x-pack/test/functional/es_archives/reporting/historic/data.json.gz and /dev/null differ diff --git a/x-pack/test/functional/es_archives/reporting/historic/mappings.json b/x-pack/test/functional/es_archives/reporting/historic/mappings.json deleted file mode 100644 index 3a5af0158ce00..0000000000000 --- a/x-pack/test/functional/es_archives/reporting/historic/mappings.json +++ /dev/null @@ -1,386 +0,0 @@ -{ - "type": "index", - "value": { - "index": ".kibana", - "mappings": { - "properties": { - "config": { - "dynamic": "true", - "properties": { - "buildNum": { - "type": "keyword" - }, - "defaultIndex": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "xPackMonitoring:showBanner": { - "type": "boolean" - } - } - }, - "dashboard": { - "dynamic": "strict", - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "optionsJSON": { - "type": "text" - }, - "panelsJSON": { - "type": "text" - }, - "refreshInterval": { - "properties": { - "display": { - "type": "keyword" - }, - "pause": { - "type": "boolean" - }, - "section": { - "type": "integer" - }, - "value": { - "type": "integer" - } - } - }, - "timeFrom": { - "type": "keyword" - }, - "timeRestore": { - "type": "boolean" - }, - "timeTo": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "graph-workspace": { - "dynamic": "strict", - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "numLinks": { - "type": "integer" - }, - "numVertices": { - "type": "integer" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "wsState": { - "type": "text" - } - } - }, - "index-pattern": { - "dynamic": "strict", - "properties": { - "fieldFormatMap": { - "type": "text" - }, - "fields": { - "type": "text" - }, - "intervalName": { - "type": "keyword" - }, - "notExpandable": { - "type": "boolean" - }, - "sourceFilters": { - "type": "text" - }, - "timeFieldName": { - "type": "keyword" - }, - "title": { - "type": "text" - } - } - }, - "search": { - "dynamic": "strict", - "properties": { - "columns": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "sort": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "server": { - "dynamic": "strict", - "properties": { - "uuid": { - "type": "keyword" - } - } - }, - "space": { - "properties": { - "_reserved": { - "type": "boolean" - }, - "color": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "initials": { - "type": "keyword" - }, - "name": { - "fields": { - "keyword": { - "ignore_above": 2048, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "spaceId": { - "type": "keyword" - }, - "timelion-sheet": { - "dynamic": "strict", - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "timelion_chart_height": { - "type": "integer" - }, - "timelion_columns": { - "type": "integer" - }, - "timelion_interval": { - "type": "keyword" - }, - "timelion_other_interval": { - "type": "keyword" - }, - "timelion_rows": { - "type": "integer" - }, - "timelion_sheet": { - "type": "text" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "type": { - "type": "keyword" - }, - "updated_at": { - "type": "date" - }, - "url": { - "dynamic": "strict", - "properties": { - "accessCount": { - "type": "long" - }, - "accessDate": { - "type": "date" - }, - "createDate": { - "type": "date" - }, - "url": { - "fields": { - "keyword": { - "ignore_above": 2048, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "visualization": { - "dynamic": "strict", - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "savedSearchId": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "visState": { - "type": "text" - } - } - } - } - }, - "settings": { - "index": { - "number_of_replicas": "1", - "number_of_shards": "1" - } - } - } -} - -{ - "type": "index", - "value": { - "index": ".reporting-2018.03.11", - "mappings": { - "properties": { - "attempts": { - "type": "short" - }, - "completed_at": { - "type": "date" - }, - "created_at": { - "type": "date" - }, - "created_by": { - "type": "keyword" - }, - "jobtype": { - "type": "keyword" - }, - "max_attempts": { - "type": "short" - }, - "output": { - "properties": { - "content": { - "enabled": false, - "type": "object" - }, - "content_type": { - "type": "keyword" - }, - "max_size_reached": { - "type": "boolean" - } - } - }, - "payload": { - "enabled": false, - "type": "object" - }, - "priority": { - "type": "byte" - }, - "process_expiration": { - "type": "date" - }, - "started_at": { - "type": "date" - }, - "status": { - "type": "keyword" - }, - "timeout": { - "type": "long" - } - } - }, - "settings": { - "index": { - "auto_expand_replicas": "0-1", - "number_of_replicas": "0", - "number_of_shards": "1" - } - } - } -} \ No newline at end of file diff --git a/x-pack/test/functional/page_objects/reporting_page.js b/x-pack/test/functional/page_objects/reporting_page.js index 7cdd1c083239b..cdfafeec1bf46 100644 --- a/x-pack/test/functional/page_objects/reporting_page.js +++ b/x-pack/test/functional/page_objects/reporting_page.js @@ -19,24 +19,10 @@ export function ReportingPageProvider({ getService, getPageObjects }) { const log = getService('log'); const config = getService('config'); const testSubjects = getService('testSubjects'); - const esArchiver = getService('esArchiver'); const browser = getService('browser'); - const kibanaServer = getService('kibanaServer'); - const PageObjects = getPageObjects(['common', 'security', 'settings', 'share', 'timePicker']); + const PageObjects = getPageObjects(['common', 'security', 'share', 'timePicker']); class ReportingPage { - async initTests() { - log.debug('ReportingPage:initTests'); - await PageObjects.settings.navigateTo(); - await esArchiver.loadIfNeeded('../../functional/es_archives/logstash_functional'); - await esArchiver.load('reporting/historic'); - await kibanaServer.uiSettings.replace({ - defaultIndex: 'logstash-*', - }); - - await browser.setWindowSize(1600, 850); - } - async forceSharedItemsContainerSize({ width }) { await browser.execute(` var el = document.querySelector('[data-shared-items-container]'); @@ -130,6 +116,20 @@ export function ReportingPageProvider({ getService, getPageObjects }) { return await retry.try(async () => await testSubjects.find('generateReportButton')); } + async isGenerateReportButtonDisabled() { + const generateReportButton = await this.getGenerateReportButton(); + return await retry.try(async () => { + const isDisabled = await generateReportButton.getAttribute('disabled'); + return isDisabled; + }); + } + + async canReportBeCreated() { + await this.clickGenerateReportButton(); + const success = await this.checkForReportingToasts(); + return success; + } + async checkUsePrintLayout() { // The print layout checkbox slides in as part of an animation, and tests can // attempt to click it too quickly, leading to flaky tests. The 500ms wait allows diff --git a/x-pack/test/functional/page_objects/uptime_page.ts b/x-pack/test/functional/page_objects/uptime_page.ts index 39c3c46adddbb..0ebcb5c87deee 100644 --- a/x-pack/test/functional/page_objects/uptime_page.ts +++ b/x-pack/test/functional/page_objects/uptime_page.ts @@ -9,42 +9,38 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function UptimePageProvider({ getPageObjects, getService }: FtrProviderContext) { const pageObjects = getPageObjects(['common', 'timePicker']); - const { common: commonService, navigation, alerts } = getService('uptime'); + const { alerts, common: commonService, monitor, navigation } = getService('uptime'); const retry = getService('retry'); return new (class UptimePage { public async goToRoot() { - await pageObjects.common.navigateToApp('uptime'); + await navigation.goToUptime(); } - public async goToUptimePageAndSetDateRange( - datePickerStartValue: string, - datePickerEndValue: string - ) { - await pageObjects.common.navigateToApp('uptime'); - await pageObjects.timePicker.setAbsoluteRange(datePickerStartValue, datePickerEndValue); + public async setDateRange(start: string, end: string) { + const { start: prevStart, end: prevEnd } = await pageObjects.timePicker.getTimeConfig(); + if (start !== prevStart || prevEnd !== end) { + await pageObjects.timePicker.setAbsoluteRange(start, end); + } else { + await navigation.refreshApp(); + } } public async goToUptimeOverviewAndLoadData( - datePickerStartValue: string, - datePickerEndValue: string, + dateStart: string, + dateEnd: string, monitorIdToCheck?: string ) { - await pageObjects.common.navigateToApp('uptime'); - await pageObjects.timePicker.setAbsoluteRange(datePickerStartValue, datePickerEndValue); + await navigation.goToUptime(); + await this.setDateRange(dateStart, dateEnd); if (monitorIdToCheck) { await commonService.monitorIdExists(monitorIdToCheck); } } - public async loadDataAndGoToMonitorPage( - datePickerStartValue: string, - datePickerEndValue: string, - monitorId: string, - monitorName?: string - ) { - await pageObjects.timePicker.setAbsoluteRange(datePickerStartValue, datePickerEndValue); - await navigation.goToMonitor(monitorId, monitorName); + public async loadDataAndGoToMonitorPage(dateStart: string, dateEnd: string, monitorId: string) { + await this.setDateRange(dateStart, dateEnd); + await navigation.goToMonitor(monitorId); } public async inputFilterQuery(filterQuery: string) { @@ -140,5 +136,24 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo await commonService.openPageSizeSelectPopover(); return commonService.clickPageSizeSelectPopoverItem(size); } + + public async checkPingListInteractions( + timestamps: string[], + location?: string, + status?: string + ): Promise { + if (location) { + await monitor.setPingListLocation(location); + } + if (status) { + await monitor.setPingListStatus(status); + } + return monitor.checkForPingListTimestamps(timestamps); + } + + public async resetFilters() { + await this.inputFilterQuery(''); + await commonService.resetStatusFilter(); + } })(); } diff --git a/x-pack/test/functional/services/uptime/common.ts b/x-pack/test/functional/services/uptime/common.ts index ed465eee343f9..b5be1e29a0e8c 100644 --- a/x-pack/test/functional/services/uptime/common.ts +++ b/x-pack/test/functional/services/uptime/common.ts @@ -10,6 +10,7 @@ export function UptimeCommonProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const browser = getService('browser'); const retry = getService('retry'); + const find = getService('find'); return { async assertExists(key: string) { @@ -52,6 +53,20 @@ export function UptimeCommonProvider({ getService }: FtrProviderContext) { async setStatusFilterDown() { await testSubjects.click('xpack.uptime.filterBar.filterStatusDown'); }, + async resetStatusFilter() { + const upFilter = await find.byCssSelector( + '[data-test-subj="xpack.uptime.filterBar.filterStatusUp"]' + ); + if (await upFilter.elementHasClass('euiFilterButton-hasActiveFilters')) { + this.setStatusFilterUp(); + } + const downFilter = await find.byCssSelector( + '[data-test-subj="xpack.uptime.filterBar.filterStatusDown"]' + ); + if (await downFilter.elementHasClass('euiFilterButton-hasActiveFilters')) { + this.setStatusFilterDown(); + } + }, async selectFilterItem(filterType: string, option: string) { const popoverId = `filter-popover_${filterType}`; const optionId = `filter-popover-item_${option}`; diff --git a/x-pack/test/functional/services/uptime/ml_anomaly.ts b/x-pack/test/functional/services/uptime/ml_anomaly.ts new file mode 100644 index 0000000000000..e15f47ddd9709 --- /dev/null +++ b/x-pack/test/functional/services/uptime/ml_anomaly.ts @@ -0,0 +1,59 @@ +/* + * 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 function UptimeMLAnomalyProvider({ getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const log = getService('log'); + + return { + async openMLFlyout() { + return retry.tryForTime(15000, async () => { + await testSubjects.click('uptimeEnableAnomalyBtn'); + await testSubjects.existOrFail('uptimeMLFlyout'); + }); + }, + + async openMLManageMenu() { + return retry.tryForTime(30000, async () => { + await testSubjects.click('uptimeManageMLJobBtn'); + await testSubjects.existOrFail('uptimeManageMLContextMenu'); + }); + }, + + async alreadyHasJob() { + return await testSubjects.exists('uptimeManageMLJobBtn'); + }, + + async createMLJob() { + await testSubjects.click('uptimeMLCreateJobBtn'); + return retry.tryForTime(10000, async () => { + await testSubjects.existOrFail('uptimeMLJobSuccessfullyCreated'); + log.info('Job successfully created'); + }); + }, + + async deleteMLJob() { + await testSubjects.click('uptimeDeleteMLJobBtn'); + return retry.tryForTime(10000, async () => { + await testSubjects.click('uptimeMLJobDeleteConfirmModel > confirmModalConfirmButton'); + await testSubjects.existOrFail('uptimeMLJobSuccessfullyDeleted'); + log.info('Job successfully deleted'); + }); + }, + + async canCreateJob() { + const createJobBtn = await testSubjects.find('uptimeMLCreateJobBtn'); + return !!(await createJobBtn.getAttribute('disabled')); + }, + + async hasNoLicenseInfo() { + return await testSubjects.missingOrFail('uptimeMLLicenseInfo', { timeout: 1000 }); + }, + }; +} diff --git a/x-pack/test/functional/services/uptime/monitor.ts b/x-pack/test/functional/services/uptime/monitor.ts index 3bdec4b6749d4..a3e3d953e2eb7 100644 --- a/x-pack/test/functional/services/uptime/monitor.ts +++ b/x-pack/test/functional/services/uptime/monitor.ts @@ -27,5 +27,22 @@ export function UptimeMonitorProvider({ getService }: FtrProviderContext) { await find.descendantExistsByCssSelector('canvas.mapboxgl-canvas', mapPanel); }); }, + async setPingListLocation(location: string) { + await testSubjects.click('xpack.uptime.pingList.locationSelect', 5000); + return testSubjects.click(`xpack.uptime.pingList.locationOptions.${location}`, 5000); + }, + async setPingListStatus(status: string) { + await testSubjects.click('xpack.uptime.pingList.statusSelect', 5000); + return testSubjects.click(`xpack.uptime.pingList.statusOptions.${status}`, 5000); + }, + async checkForPingListTimestamps(timestamps: string[]): Promise { + return retry.tryForTime(10000, async () => { + await Promise.all( + timestamps.map(timestamp => + testSubjects.existOrFail(`xpack.uptime.pingList.ping-${timestamp}`) + ) + ); + }); + }, }; } diff --git a/x-pack/test/functional/services/uptime/navigation.ts b/x-pack/test/functional/services/uptime/navigation.ts index 15ee869da1e6a..36a5d7c9702f8 100644 --- a/x-pack/test/functional/services/uptime/navigation.ts +++ b/x-pack/test/functional/services/uptime/navigation.ts @@ -9,17 +9,27 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export function UptimeNavigationProvider({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects(['common', 'header']); + const PageObjects = getPageObjects(['common', 'timePicker', 'header']); const goToUptimeRoot = async () => { - await retry.tryForTime(30 * 1000, async () => { - await PageObjects.common.navigateToApp('uptime'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('uptimeOverviewPage', { timeout: 2000 }); + // Check if are already on overview uptime page, we don't need to repeat the step + await retry.tryForTime(60 * 1000, async () => { + if (await testSubjects.exists('uptimeSettingsToOverviewLink', { timeout: 0 })) { + await testSubjects.click('uptimeSettingsToOverviewLink'); + await testSubjects.existOrFail('uptimeOverviewPage', { timeout: 2000 }); + } else if (!(await testSubjects.exists('uptimeOverviewPage', { timeout: 0 }))) { + await PageObjects.common.navigateToApp('uptime'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('uptimeOverviewPage', { timeout: 2000 }); + } }); }; return { + async refreshApp() { + await testSubjects.click('superDatePickerApplyTimeButton'); + }, + async goToUptime() { await goToUptimeRoot(); }, @@ -30,17 +40,29 @@ export function UptimeNavigationProvider({ getService, getPageObjects }: FtrProv await testSubjects.existOrFail('uptimeSettingsPage', { timeout: 2000 }); }, - goToMonitor: async (monitorId: string, monitorName?: string) => { - await testSubjects.click(`monitor-page-link-${monitorId}`, 5000); - if ( - monitorName && - (await testSubjects.getVisibleText('monitor-page-title')) !== monitorName - ) { - throw new Error('Expected monitor name not found'); + checkIfOnMonitorPage: async (monitorId: string) => { + const monitorPage = await testSubjects.exists('uptimeMonitorPage', { timeout: 1000 }); + if (monitorId && monitorPage) { + const thisMonitorPage = + (await testSubjects.getVisibleText('monitor-page-title')) === monitorId; + return monitorPage && thisMonitorPage; + } else { + return monitorPage; + } + }, + + goToMonitor: async (monitorId: string) => { + if (!(await testSubjects.exists('uptimeMonitorPage', { timeout: 0 }))) { + await testSubjects.click(`monitor-page-link-${monitorId}`); + await testSubjects.existOrFail('uptimeMonitorPage', { + timeout: 30000, + }); } - await testSubjects.existOrFail('uptimeMonitorPage', { - timeout: 30000, - }); + }, + + async loadDataAndGoToMonitorPage(dateStart: string, dateEnd: string, monitorId: string) { + await PageObjects.timePicker.setAbsoluteRange(dateStart, dateEnd); + await this.goToMonitor(monitorId); }, }; } diff --git a/x-pack/test/functional/services/uptime/settings.ts b/x-pack/test/functional/services/uptime/settings.ts index a64d39cd62a6d..14cab368b766a 100644 --- a/x-pack/test/functional/services/uptime/settings.ts +++ b/x-pack/test/functional/services/uptime/settings.ts @@ -10,20 +10,41 @@ export function UptimeSettingsProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const retry = getService('retry'); + const changeInputField = async (text: string, field: string) => { + const input = await testSubjects.find(field, 5000); + await input.clearValueWithKeyboard(); + await input.type(text); + }; + return { go: async () => { await testSubjects.click('settings-page-link', 5000); }, + changeHeartbeatIndicesInput: async (text: string) => { - const input = await testSubjects.find('heartbeat-indices-input-loaded', 5000); - await input.clearValueWithKeyboard(); - await input.type(text); + await changeInputField(text, 'heartbeat-indices-input-loaded'); + }, + changeErrorThresholdInput: async (text: string) => { + await changeInputField(text, 'error-state-threshold-input-loaded'); + }, + changeWarningThresholdInput: async (text: string) => { + await changeInputField(text, 'warning-state-threshold-input-loaded'); }, loadFields: async () => { - const input = await testSubjects.find('heartbeat-indices-input-loaded', 5000); - const heartbeatIndices = await input.getAttribute('value'); + const indInput = await testSubjects.find('heartbeat-indices-input-loaded', 5000); + const errorInput = await testSubjects.find('error-state-threshold-input-loaded', 5000); + const warningInput = await testSubjects.find('warning-state-threshold-input-loaded', 5000); + const heartbeatIndices = await indInput.getAttribute('value'); + const errorThreshold = await errorInput.getAttribute('value'); + const warningThreshold = await warningInput.getAttribute('value'); - return { heartbeatIndices }; + return { + heartbeatIndices, + certificatesThresholds: { + errorState: errorThreshold, + warningState: warningThreshold, + }, + }; }, applyButtonIsDisabled: async () => { return !!(await (await testSubjects.find('apply-settings-button')).getAttribute('disabled')); diff --git a/x-pack/test/functional/services/uptime/uptime.ts b/x-pack/test/functional/services/uptime/uptime.ts index c96bd0e0c4675..601feb6b0646e 100644 --- a/x-pack/test/functional/services/uptime/uptime.ts +++ b/x-pack/test/functional/services/uptime/uptime.ts @@ -11,6 +11,7 @@ import { UptimeCommonProvider } from './common'; import { UptimeMonitorProvider } from './monitor'; import { UptimeNavigationProvider } from './navigation'; import { UptimeAlertsProvider } from './alerts'; +import { UptimeMLAnomalyProvider } from './ml_anomaly'; export function UptimeProvider(context: FtrProviderContext) { const common = UptimeCommonProvider(context); @@ -18,6 +19,7 @@ export function UptimeProvider(context: FtrProviderContext) { const monitor = UptimeMonitorProvider(context); const navigation = UptimeNavigationProvider(context); const alerts = UptimeAlertsProvider(context); + const ml = UptimeMLAnomalyProvider(context); return { common, @@ -25,5 +27,6 @@ export function UptimeProvider(context: FtrProviderContext) { monitor, navigation, alerts, + ml, }; } diff --git a/x-pack/test/functional_endpoint/apps/endpoint/host_list.ts b/x-pack/test/functional_endpoint/apps/endpoint/host_list.ts index 2e204775808c9..9a4ffecf85d52 100644 --- a/x-pack/test/functional_endpoint/apps/endpoint/host_list.ts +++ b/x-pack/test/functional_endpoint/apps/endpoint/host_list.ts @@ -167,7 +167,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '', '0', '00000000-0000-0000-0000-000000000000', - 'active', + 'Successful', '10.101.149.262606:a000:ffc0:39:11ef:37b9:3371:578c', 'rezzani-7.example.com', '6.8.0', diff --git a/x-pack/test/functional_endpoint/config.ts b/x-pack/test/functional_endpoint/config.ts index 37bf57b67b47e..6ae78ab9d48ac 100644 --- a/x-pack/test/functional_endpoint/config.ts +++ b/x-pack/test/functional_endpoint/config.ts @@ -30,6 +30,7 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { ...xpackFunctionalConfig.get('kbnTestServer.serverArgs'), '--xpack.endpoint.enabled=true', '--xpack.ingestManager.enabled=true', + '--xpack.ingestManager.epm.enabled=true', '--xpack.ingestManager.fleet.enabled=true', ], }, diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts index 029af1ea06e4f..bce8e08cd16d3 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts @@ -17,6 +17,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); const supertest = getService('supertest'); const find = getService('find'); + const retry = getService('retry'); async function createAlert(overwrites: Record = {}) { const { body: createdAlert } = await supertest @@ -38,8 +39,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { return createdAlert; } - // FLAKY: https://github.com/elastic/kibana/issues/62472 - describe.skip('alerts', function() { + describe('alerts', function() { before(async () => { await pageObjects.common.navigateToApp('triggersActions'); await testSubjects.click('alertsTab'); @@ -48,10 +48,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should create an alert', async () => { const alertName = generateUniqueKey(); await pageObjects.triggersActionsUI.clickCreateAlertButton(); - const nameInput = await testSubjects.find('alertNameInput'); - await nameInput.click(); - await nameInput.clearValue(); - await nameInput.type(alertName); + await testSubjects.setValue('alertNameInput', alertName); await testSubjects.click('.index-threshold-SelectOption'); await testSubjects.click('selectIndexExpression'); const comboBox = await find.byCssSelector('#indexSelectSearchBox'); @@ -60,30 +57,28 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const filterSelectItem = await find.byCssSelector(`.euiFilterSelectItem`); await filterSelectItem.click(); await testSubjects.click('thresholdAlertTimeFieldSelect'); - const fieldOptions = await find.allByCssSelector('#thresholdTimeField option'); - await fieldOptions[1].click(); + await retry.try(async () => { + const fieldOptions = await find.allByCssSelector('#thresholdTimeField option'); + expect(fieldOptions[1]).not.to.be(undefined); + await fieldOptions[1].click(); + }); + await testSubjects.click('closePopover'); // need this two out of popup clicks to close them + const nameInput = await testSubjects.find('alertNameInput'); await nameInput.click(); await testSubjects.click('.slack-ActionTypeSelectOption'); await testSubjects.click('createActionConnectorButton'); - const connectorNameInput = await testSubjects.find('nameInput'); - await connectorNameInput.click(); - await connectorNameInput.clearValue(); - const connectorName = generateUniqueKey(); - await connectorNameInput.type(connectorName); - const slackWebhookUrlInput = await testSubjects.find('slackWebhookUrlInput'); - await slackWebhookUrlInput.click(); - await slackWebhookUrlInput.clearValue(); - await slackWebhookUrlInput.type('https://test'); + const slackConnectorName = generateUniqueKey(); + await testSubjects.setValue('nameInput', slackConnectorName); + await testSubjects.setValue('slackWebhookUrlInput', 'https://test'); await find.clickByCssSelector('[data-test-subj="saveActionButtonModal"]:not(disabled)'); - const loggingMessageInput = await testSubjects.find('slackMessageTextArea'); - await loggingMessageInput.click(); - await loggingMessageInput.clearValue(); - await loggingMessageInput.type('test message'); - await testSubjects.click('slackAddVariableButton'); - const variableMenuButton = await testSubjects.find('variableMenuButton-0'); - await variableMenuButton.click(); + const createdConnectorToastTitle = await pageObjects.common.closeToast(); + expect(createdConnectorToastTitle).to.eql(`Created '${slackConnectorName}'`); + await testSubjects.setValue('slackMessageTextArea', 'test message'); + await testSubjects.click('messageAddVariableButton'); + await testSubjects.click('variableMenuButton-0'); + await testSubjects.click('saveAlertButton'); const toastTitle = await pageObjects.common.closeToast(); expect(toastTitle).to.eql(`Saved '${alertName}'`); @@ -134,7 +129,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should edit an alert', async () => { const createdAlert = await createAlert({ alertTypeId: '.index-threshold', - name: 'new alert', + name: generateUniqueKey(), params: { aggType: 'count', termSize: 5, @@ -162,11 +157,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const editLink = await testSubjects.findAll('alertsTableCell-editLink'); await editLink[0].click(); - const updatedAlertName = 'Changed Alert Name'; - const nameInputToUpdate = await testSubjects.find('alertNameInput'); - await nameInputToUpdate.click(); - await nameInputToUpdate.clearValue(); - await nameInputToUpdate.type(updatedAlertName); + const updatedAlertName = `Changed Alert Name ${generateUniqueKey()}`; + await testSubjects.setValue('alertNameInput', updatedAlertName, { clearWithKeyboard: true }); await find.clickByCssSelector('[data-test-subj="saveEditedAlertButton"]:not(disabled)'); @@ -219,10 +211,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const editLink = await testSubjects.findAll('alertsTableCell-editLink'); await editLink[0].click(); - const throttleInputToSetInitialValue = await testSubjects.find('throttleInput'); - await throttleInputToSetInitialValue.click(); - await throttleInputToSetInitialValue.clearValue(); - await throttleInputToSetInitialValue.type('1'); + await testSubjects.setValue('throttleInput', '1', { clearWithKeyboard: true }); await find.clickByCssSelector('[data-test-subj="saveEditedAlertButton"]:not(disabled)'); @@ -310,11 +299,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const editLink = await testSubjects.findAll('alertsTableCell-editLink'); await editLink[0].click(); - const updatedAlertName = 'Changed Alert Name'; - const nameInputToUpdate = await testSubjects.find('alertNameInput'); - await nameInputToUpdate.click(); - await nameInputToUpdate.clearValue(); - await nameInputToUpdate.type(updatedAlertName); + const updatedAlertName = `Changed Alert Name ${generateUniqueKey()}`; + await testSubjects.setValue('alertNameInput', updatedAlertName); await testSubjects.click('cancelSaveEditedAlertButton'); await find.waitForDeletedByCssSelector('[data-test-subj="cancelSaveEditedAlertButton"]'); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts index c2013ba3502e2..0e6f991be24d0 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts @@ -13,12 +13,20 @@ function generateUniqueKey() { } export default ({ getPageObjects, getService }: FtrProviderContext) => { + const alerting = getService('alerting'); const testSubjects = getService('testSubjects'); const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); const find = getService('find'); describe('Connectors', function() { before(async () => { + await alerting.actions.createAction({ + name: `server-log-${Date.now()}`, + actionTypeId: '.server-log', + config: {}, + secrets: {}, + }); + await pageObjects.common.navigateToApp('triggersActions'); await testSubjects.click('connectorsTab'); }); @@ -176,5 +184,30 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const searchResultsAfterDelete = await pageObjects.triggersActionsUI.getConnectorsList(); expect(searchResultsAfterDelete.length).to.eql(0); }); + + it('should not be able to delete a pre-configured connector', async () => { + const preconfiguredConnectorName = 'xyz'; + await pageObjects.triggersActionsUI.searchConnectors(preconfiguredConnectorName); + + const searchResults = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResults.length).to.eql(1); + + expect(await testSubjects.exists('deleteConnector')).to.be(false); + expect(await testSubjects.exists('preConfiguredTitleMessage')).to.be(true); + }); + + it('should not be able to edit a pre-configured connector', async () => { + const preconfiguredConnectorName = 'xyz'; + + await pageObjects.triggersActionsUI.searchConnectors(preconfiguredConnectorName); + + const searchResultsBeforeEdit = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsBeforeEdit.length).to.eql(1); + + await find.clickByCssSelector('[data-test-subj="connectorsTableCell-name"] button'); + + expect(await testSubjects.exists('preconfiguredBadge')).to.be(true); + expect(await testSubjects.exists('saveEditedActionButton')).to.be(false); + }); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts index 2a0358160da51..3e5a8c57c4c7e 100644 --- a/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts @@ -33,7 +33,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // put the fetch code in a retry block with a timeout. let alert: any; await retry.tryForTime(15000, async () => { - const apiResponse = await supertest.get('/api/alert/_find'); + const apiResponse = await supertest.get('/api/alert/_find?search=uptime-test'); const alertsFromThisTest = apiResponse.body.data.filter( ({ name }: { name: string }) => name === 'uptime-test' ); @@ -54,25 +54,27 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { tags, } = alert; - // we're not testing the flyout's ability to associate alerts with action connectors - expect(actions).to.eql([]); + try { + // we're not testing the flyout's ability to associate alerts with action connectors + expect(actions).to.eql([]); - expect(alertTypeId).to.eql('xpack.uptime.alerts.monitorStatus'); - expect(consumer).to.eql('uptime'); - expect(interval).to.eql('11m'); - expect(tags).to.eql(['uptime', 'another']); - expect(numTimes).to.be(3); - expect(timerange.from).to.be('now-1h'); - expect(timerange.to).to.be('now'); - expect(locations).to.eql(['mpls']); - expect(filters).to.eql( - '{"bool":{"should":[{"match_phrase":{"monitor.id":"0001-up"}}],"minimum_should_match":1}}' - ); - - await supertest - .delete(`/api/alert/${id}`) - .set('kbn-xsrf', 'true') - .expect(204); + expect(alertTypeId).to.eql('xpack.uptime.alerts.monitorStatus'); + expect(consumer).to.eql('uptime'); + expect(interval).to.eql('11m'); + expect(tags).to.eql(['uptime', 'another']); + expect(numTimes).to.be(3); + expect(timerange.from).to.be('now-1h'); + expect(timerange.to).to.be('now'); + expect(locations).to.eql(['mpls']); + expect(filters).to.eql( + '{"bool":{"should":[{"match_phrase":{"monitor.id":"0001-up"}}],"minimum_should_match":1}}' + ); + } finally { + await supertest + .delete(`/api/alert/${id}`) + .set('kbn-xsrf', 'true') + .expect(204); + } }); }); }; diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index 538817bd9d14c..a620b1d953376 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -52,6 +52,16 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { `--plugin-path=${join(__dirname, 'fixtures', 'plugins', 'alerts')}`, '--xpack.actions.enabled=true', '--xpack.alerting.enabled=true', + `--xpack.actions.preconfigured=${JSON.stringify([ + { + id: 'my-slack1', + actionTypeId: '.slack', + name: 'Slack#xyz', + config: { + webhookUrl: 'https://hooks.slack.com/services/abcd/efgh/ijklmnopqrstuvwxyz', + }, + }, + ])}`, ], }, }; diff --git a/x-pack/test/plugin_api_integration/plugins/event_log/server/plugin.ts b/x-pack/test/plugin_api_integration/plugins/event_log/server/plugin.ts index eccbd4fb7f90b..1b6db3a9a31b4 100644 --- a/x-pack/test/plugin_api_integration/plugins/event_log/server/plugin.ts +++ b/x-pack/test/plugin_api_integration/plugins/event_log/server/plugin.ts @@ -50,7 +50,7 @@ export class EventLogFixturePlugin core.savedObjects.registerType({ name: 'event_log_test', hidden: false, - namespaceAgnostic: true, + namespaceType: 'agnostic', mappings: { properties: {}, }, diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts index c440971225d78..d664357c3ba12 100644 --- a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts @@ -220,9 +220,9 @@ export default function({ getService }: FtrProviderContext) { duration: 1000000, }, kibana: { - namespace: 'default', saved_objects: [ { + namespace: 'default', type: 'event_log_test', id, }, diff --git a/x-pack/test/reporting/.gitignore b/x-pack/test/reporting/.gitignore new file mode 100644 index 0000000000000..99ee4c44686a0 --- /dev/null +++ b/x-pack/test/reporting/.gitignore @@ -0,0 +1 @@ +functional/reports/session/ diff --git a/x-pack/test/reporting/configs/chromium_functional.js b/x-pack/test/reporting/configs/chromium_functional.js index 05c3b6c142946..753d2b2a20ab9 100644 --- a/x-pack/test/reporting/configs/chromium_functional.js +++ b/x-pack/test/reporting/configs/chromium_functional.js @@ -5,6 +5,7 @@ */ export default async function({ readConfigFile }) { + // TODO move reporting tests to x-pack/test/functional/apps//reporting const functionalConfig = await readConfigFile(require.resolve('../../functional/config.js')); return { diff --git a/x-pack/test/reporting/functional/reporting.js b/x-pack/test/reporting/functional/reporting.js index 012f0922c28cf..6107363986a40 100644 --- a/x-pack/test/reporting/functional/reporting.js +++ b/x-pack/test/reporting/functional/reporting.js @@ -15,8 +15,14 @@ const mkdirAsync = promisify(fs.mkdir); const REPORTS_FOLDER = path.resolve(__dirname, 'reports'); +/* + * TODO Remove this file and spread the tests to various apps + */ + export default function({ getService, getPageObjects }) { - const retry = getService('retry'); + const esArchiver = getService('esArchiver'); + const browser = getService('browser'); + const log = getService('log'); const config = getService('config'); const PageObjects = getPageObjects([ 'reporting', @@ -27,65 +33,32 @@ export default function({ getService, getPageObjects }) { 'visualize', 'visEditor', ]); - const log = getService('log'); describe('Reporting', () => { - before('initialize tests', async () => { - await PageObjects.reporting.initTests(); - }); - - const expectDisabledGenerateReportButton = async () => { - const generateReportButton = await PageObjects.reporting.getGenerateReportButton(); - await retry.try(async () => { - const isDisabled = await generateReportButton.getAttribute('disabled'); - expect(isDisabled).to.be('true'); + describe('Dashboard', () => { + before('initialize tests', async () => { + log.debug('ReportingPage:initTests'); + await esArchiver.loadIfNeeded('reporting/ecommerce'); + await esArchiver.loadIfNeeded('reporting/ecommerce_kibana'); + await browser.setWindowSize(1600, 850); }); - }; - - const expectEnabledGenerateReportButton = async () => { - const generateReportButton = await PageObjects.reporting.getGenerateReportButton(); - await retry.try(async () => { - const isDisabled = await generateReportButton.getAttribute('disabled'); - expect(isDisabled).to.be(null); + after('clean up archives', async () => { + await esArchiver.unload('reporting/ecommerce'); + await esArchiver.unload('reporting/ecommerce_kibana'); }); - }; - - const expectReportCanBeCreated = async () => { - await PageObjects.reporting.clickGenerateReportButton(); - const success = await PageObjects.reporting.checkForReportingToasts(); - expect(success).to.be(true); - }; - - const writeSessionReport = async (name, rawPdf, reportExt = 'pdf') => { - const sessionDirectory = path.resolve(REPORTS_FOLDER, 'session'); - await mkdirAsync(sessionDirectory, { recursive: true }); - const sessionReportPath = path.resolve(sessionDirectory, `${name}.${reportExt}`); - await writeFileAsync(sessionReportPath, rawPdf); - return sessionReportPath; - }; - - const getBaselineReportPath = (fileName, reportExt = 'pdf') => { - const baselineFolder = path.resolve(REPORTS_FOLDER, 'baseline'); - const fullPath = path.resolve(baselineFolder, `${fileName}.${reportExt}`); - log.debug(`getBaselineReportPath (${fullPath})`); - return fullPath; - }; - - describe('Dashboard', () => { - beforeEach(() => PageObjects.reporting.clearToastNotifications()); describe('Print PDF button', () => { it('is not available if new', async () => { await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.clickNewDashboard(); await PageObjects.reporting.openPdfReportingPanel(); - await expectDisabledGenerateReportButton(); + expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be('true'); }); it('becomes available when saved', async () => { await PageObjects.dashboard.saveDashboard('My PDF Dashboard'); await PageObjects.reporting.openPdfReportingPanel(); - await expectEnabledGenerateReportButton(); + expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); }); }); @@ -95,15 +68,7 @@ export default function({ getService, getPageObjects }) { // function is taking about 15 seconds per comparison in jenkins. this.timeout(300000); await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.gotoDashboardEditMode('My PDF Dashboard'); - await PageObjects.reporting.setTimepickerInDataRange(); - const visualizations = PageObjects.dashboard.getTestVisualizationNames(); - - const tileMapIndex = visualizations.indexOf('Visualization TileMap'); - visualizations.splice(tileMapIndex, 1); - - await PageObjects.dashboard.addVisualizations(visualizations); - await PageObjects.dashboard.saveDashboard('report test'); + await PageObjects.dashboard.loadSavedDashboard('Ecom Dashboard'); await PageObjects.reporting.openPdfReportingPanel(); await PageObjects.reporting.checkUsePrintLayout(); await PageObjects.reporting.clickGenerateReportButton(); @@ -121,30 +86,36 @@ export default function({ getService, getPageObjects }) { await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.clickNewDashboard(); await PageObjects.reporting.openPngReportingPanel(); - await expectDisabledGenerateReportButton(); + expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be('true'); }); it('becomes available when saved', async () => { await PageObjects.dashboard.saveDashboard('My PNG Dash'); await PageObjects.reporting.openPngReportingPanel(); - await expectEnabledGenerateReportButton(); + expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); }); }); describe('Preserve Layout', () => { it('matches baseline report', async function() { + const writeSessionReport = async (name, rawPdf, reportExt) => { + const sessionDirectory = path.resolve(REPORTS_FOLDER, 'session'); + await mkdirAsync(sessionDirectory, { recursive: true }); + const sessionReportPath = path.resolve(sessionDirectory, `${name}.${reportExt}`); + await writeFileAsync(sessionReportPath, rawPdf); + return sessionReportPath; + }; + const getBaselineReportPath = (fileName, reportExt) => { + const baselineFolder = path.resolve(REPORTS_FOLDER, 'baseline'); + const fullPath = path.resolve(baselineFolder, `${fileName}.${reportExt}`); + log.debug(`getBaselineReportPath (${fullPath})`); + return fullPath; + }; + this.timeout(300000); await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.gotoDashboardEditMode('My PNG Dash'); - await PageObjects.reporting.setTimepickerInDataRange(); - - const visualizations = PageObjects.dashboard.getTestVisualizationNames(); - const tileMapIndex = visualizations.indexOf('Visualization TileMap'); - visualizations.splice(tileMapIndex, 1); - - await PageObjects.dashboard.addVisualizations(visualizations); - await PageObjects.dashboard.saveDashboard('PNG report test'); + await PageObjects.dashboard.loadSavedDashboard('Ecom Dashboard'); await PageObjects.reporting.openPngReportingPanel(); await PageObjects.reporting.forceSharedItemsContainerSize({ width: 1405 }); await PageObjects.reporting.clickGenerateReportButton(); @@ -167,44 +138,64 @@ export default function({ getService, getPageObjects }) { }); describe('Discover', () => { + before('initialize tests', async () => { + log.debug('ReportingPage:initTests'); + await esArchiver.loadIfNeeded('reporting/ecommerce'); + await browser.setWindowSize(1600, 850); + }); + after('clean up archives', async () => { + await esArchiver.unload('reporting/ecommerce'); + }); + describe('Generate CSV button', () => { beforeEach(() => PageObjects.common.navigateToApp('discover')); it('is not available if new', async () => { await PageObjects.reporting.openCsvReportingPanel(); - await expectDisabledGenerateReportButton(); + expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be('true'); }); it('becomes available when saved', async () => { await PageObjects.discover.saveSearch('my search - expectEnabledGenerateReportButton'); await PageObjects.reporting.openCsvReportingPanel(); - await expectEnabledGenerateReportButton(); + expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); }); it('generates a report with data', async () => { await PageObjects.reporting.setTimepickerInDataRange(); await PageObjects.discover.saveSearch('my search - with data - expectReportCanBeCreated'); await PageObjects.reporting.openCsvReportingPanel(); - await expectReportCanBeCreated(); + expect(await PageObjects.reporting.canReportBeCreated()).to.be(true); }); it('generates a report with no data', async () => { await PageObjects.reporting.setTimepickerInNoDataRange(); await PageObjects.discover.saveSearch('my search - no data - expectReportCanBeCreated'); await PageObjects.reporting.openCsvReportingPanel(); - await expectReportCanBeCreated(); + expect(await PageObjects.reporting.canReportBeCreated()).to.be(true); }); }); }); describe('Visualize', () => { + before('initialize tests', async () => { + log.debug('ReportingPage:initTests'); + await esArchiver.loadIfNeeded('reporting/ecommerce'); + await esArchiver.loadIfNeeded('reporting/ecommerce_kibana'); + await browser.setWindowSize(1600, 850); + }); + after('clean up archives', async () => { + await esArchiver.unload('reporting/ecommerce'); + await esArchiver.unload('reporting/ecommerce_kibana'); + }); + describe('Print PDF button', () => { it('is not available if new', async () => { await PageObjects.common.navigateToUrl('visualize', 'new'); await PageObjects.visualize.clickAreaChart(); - await PageObjects.visualize.clickNewSearch(); + await PageObjects.visualize.clickNewSearch('ecommerce'); await PageObjects.reporting.openPdfReportingPanel(); - await expectDisabledGenerateReportButton(); + expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be('true'); }); it('becomes available when saved', async () => { @@ -214,14 +205,16 @@ export default function({ getService, getPageObjects }) { await PageObjects.visEditor.clickGo(); await PageObjects.visualize.saveVisualization('my viz'); await PageObjects.reporting.openPdfReportingPanel(); - await expectEnabledGenerateReportButton(); + expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); }); - it('matches baseline report', async function() { + it('downloaded PDF has OK status', async function() { // Generating and then comparing reports can take longer than the default 60s timeout because the comparePngs // function is taking about 15 seconds per comparison in jenkins. this.timeout(180000); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.loadSavedDashboard('Ecom Dashboard'); await PageObjects.reporting.openPdfReportingPanel(); await PageObjects.reporting.clickGenerateReportButton(); diff --git a/x-pack/test/reporting/functional/reports/baseline/dashboard_preserve_layout.pdf b/x-pack/test/reporting/functional/reports/baseline/dashboard_preserve_layout.pdf deleted file mode 100644 index b7370109c687c..0000000000000 Binary files a/x-pack/test/reporting/functional/reports/baseline/dashboard_preserve_layout.pdf and /dev/null differ diff --git a/x-pack/test/reporting/functional/reports/baseline/dashboard_preserve_layout.png b/x-pack/test/reporting/functional/reports/baseline/dashboard_preserve_layout.png index a0dfea9ef4fa7..1eb5f29d212c2 100644 Binary files a/x-pack/test/reporting/functional/reports/baseline/dashboard_preserve_layout.png and b/x-pack/test/reporting/functional/reports/baseline/dashboard_preserve_layout.png differ diff --git a/x-pack/test/reporting/functional/reports/baseline/dashboard_print.pdf b/x-pack/test/reporting/functional/reports/baseline/dashboard_print.pdf deleted file mode 100644 index bf2bca54ca2d7..0000000000000 Binary files a/x-pack/test/reporting/functional/reports/baseline/dashboard_print.pdf and /dev/null differ diff --git a/x-pack/test/reporting/functional/reports/baseline/visualize_print.pdf b/x-pack/test/reporting/functional/reports/baseline/visualize_print.pdf deleted file mode 100644 index c11967c12ebf5..0000000000000 Binary files a/x-pack/test/reporting/functional/reports/baseline/visualize_print.pdf and /dev/null differ diff --git a/x-pack/test/saved_object_api_integration/common/config.ts b/x-pack/test/saved_object_api_integration/common/config.ts index dda7934ce875a..eaf7230a832a8 100644 --- a/x-pack/test/saved_object_api_integration/common/config.ts +++ b/x-pack/test/saved_object_api_integration/common/config.ts @@ -56,8 +56,10 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) ...config.xpack.api.get('kbnTestServer.serverArgs'), '--optimize.enabled=false', '--server.xsrf.disableProtection=true', + `--plugin-path=${path.join(__dirname, 'fixtures', 'isolated_type_plugin')}`, `--plugin-path=${path.join(__dirname, 'fixtures', 'namespace_agnostic_type_plugin')}`, `--plugin-path=${path.join(__dirname, 'fixtures', 'hidden_type_plugin')}`, + `--plugin-path=${path.join(__dirname, 'fixtures', 'shared_type_plugin')}`, ...disabledPlugins.map(key => `--xpack.${key}.enabled=false`), ], }, diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 34361ad9df542..d2c14189e2529 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -53,7 +53,7 @@ { "type": "doc", "value": { - "id": "index-pattern:91200a00-9efd-11e7-acb3-3dab96693fab", + "id": "index-pattern:defaultspace-index-pattern-id", "index": ".kibana", "source": { "index-pattern": { @@ -76,7 +76,7 @@ "source": { "config": { "buildNum": 8467, - "defaultIndex": "91200a00-9efd-11e7-acb3-3dab96693fab" + "defaultIndex": "defaultspace-index-pattern-id" }, "type": "config", "updated_at": "2017-09-21T18:49:16.302Z" @@ -88,15 +88,15 @@ { "type": "doc", "value": { - "id": "visualization:dd7caf20-9efd-11e7-acb3-3dab96693fab", + "id": "isolatedtype:defaultspace-isolatedtype-id", "index": ".kibana", "source": { - "type": "visualization", + "type": "isolatedtype", "updated_at": "2017-09-21T18:51:23.794Z", - "visualization": { + "isolatedtype": { "description": "", "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"index\":\"91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + "searchSourceJSON": "{\"index\":\"defaultspace-index-pattern-id\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" }, "title": "Count of requests", "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", @@ -111,7 +111,7 @@ { "type": "doc", "value": { - "id": "dashboard:be3733a0-9efe-11e7-acb3-3dab96693fab", + "id": "dashboard:defaultspace-dashboard-id", "index": ".kibana", "source": { "dashboard": { @@ -121,7 +121,7 @@ "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" }, "optionsJSON": "{}", - "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]", + "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"isolatedtype\",\"id\":\"defaultspace-isolatedtype-id\",\"col\":1,\"row\":1}]", "refreshInterval": { "display": "Off", "pause": false, @@ -144,7 +144,7 @@ { "type": "doc", "value": { - "id": "space_1:index-pattern:space_1-91200a00-9efd-11e7-acb3-3dab96693fab", + "id": "space_1:index-pattern:space1-index-pattern-id", "index": ".kibana", "source": { "index-pattern": { @@ -168,7 +168,7 @@ "source": { "config": { "buildNum": 8467, - "defaultIndex": "91200a00-9efd-11e7-acb3-3dab96693fab" + "defaultIndex": "defaultspace-index-pattern-id" }, "namespace": "space_1", "type": "config", @@ -181,16 +181,16 @@ { "type": "doc", "value": { - "id": "space_1:visualization:space_1-dd7caf20-9efd-11e7-acb3-3dab96693fab", + "id": "space_1:isolatedtype:space1-isolatedtype-id", "index": ".kibana", "source": { "namespace": "space_1", - "type": "visualization", + "type": "isolatedtype", "updated_at": "2017-09-21T18:51:23.794Z", - "visualization": { + "isolatedtype": { "description": "", "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"index\":\"space_1-91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + "searchSourceJSON": "{\"index\":\"space1-index-pattern-id\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" }, "title": "Count of requests", "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", @@ -205,7 +205,7 @@ { "type": "doc", "value": { - "id": "space_1:dashboard:space_1-be3733a0-9efe-11e7-acb3-3dab96693fab", + "id": "space_1:dashboard:space1-dashboard-id", "index": ".kibana", "source": { "dashboard": { @@ -215,7 +215,7 @@ "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" }, "optionsJSON": "{}", - "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"space_1-dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]", + "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"isolatedtype\",\"id\":\"space1-isolatedtype-id\",\"col\":1,\"row\":1}]", "refreshInterval": { "display": "Off", "pause": false, @@ -239,7 +239,7 @@ { "type": "doc", "value": { - "id": "space_2:index-pattern:space_2-91200a00-9efd-11e7-acb3-3dab96693fab", + "id": "space_2:index-pattern:space2-index-pattern-id", "index": ".kibana", "source": { "index-pattern": { @@ -263,7 +263,7 @@ "source": { "config": { "buildNum": 8467, - "defaultIndex": "91200a00-9efd-11e7-acb3-3dab96693fab" + "defaultIndex": "defaultspace-index-pattern-id" }, "namespace": "space_2", "type": "config", @@ -276,16 +276,16 @@ { "type": "doc", "value": { - "id": "space_2:visualization:space_2-dd7caf20-9efd-11e7-acb3-3dab96693fab", + "id": "space_2:isolatedtype:space2-isolatedtype-id", "index": ".kibana", "source": { "namespace": "space_2", - "type": "visualization", + "type": "isolatedtype", "updated_at": "2017-09-21T18:51:23.794Z", - "visualization": { + "isolatedtype": { "description": "", "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"index\":\"space_2-91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + "searchSourceJSON": "{\"index\":\"space2-index-pattern-id\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" }, "title": "Count of requests", "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", @@ -300,7 +300,7 @@ { "type": "doc", "value": { - "id": "space_2:dashboard:space_2-be3733a0-9efe-11e7-acb3-3dab96693fab", + "id": "space_2:dashboard:space2-dashboard-id", "index": ".kibana", "source": { "dashboard": { @@ -310,7 +310,7 @@ "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" }, "optionsJSON": "{}", - "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"space_2-dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]", + "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"isolatedtype\",\"id\":\"space2-isolatedtype-id\",\"col\":1,\"row\":1}]", "refreshInterval": { "display": "Off", "pause": false, @@ -334,11 +334,11 @@ { "type": "doc", "value": { - "id": "globaltype:8121a00-8efd-21e7-1cb3-34ab966434445", + "id": "globaltype:globaltype-id", "index": ".kibana", "source": { "globaltype": { - "name": "My favorite global object" + "title": "My favorite global object" }, "type": "globaltype", "updated_at": "2017-09-21T18:59:16.270Z" @@ -346,3 +346,54 @@ "type": "doc" } } + +{ + "type": "doc", + "value": { + "id": "sharedtype:default_and_space_1", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in the default and space_1 spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:only_space_1", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object only in space_1" + }, + "type": "sharedtype", + "namespaces": ["space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:only_space_2", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object only in space_2" + }, + "type": "sharedtype", + "namespaces": ["space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index c2489f2a906c8..7b5b1d86f6bcc 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -82,7 +82,7 @@ }, "globaltype": { "properties": { - "name": { + "title": { "fields": { "keyword": { "ignore_above": 2048, @@ -147,9 +147,41 @@ } } }, + "isolatedtype": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + }, "namespace": { "type": "keyword" }, + "namespaces": { + "type": "keyword" + }, "search": { "properties": { "columns": { @@ -186,6 +218,19 @@ } } }, + "sharedtype": { + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, "space": { "properties": { "_reserved": { @@ -282,35 +327,6 @@ "type": "text" } } - }, - "visualization": { - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "savedSearchId": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "visState": { - "type": "text" - } - } } } }, @@ -322,4 +338,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/hidden_type_plugin/index.js b/x-pack/test/saved_object_api_integration/common/fixtures/hidden_type_plugin/index.js index ea32811794c47..5989db84e2290 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/hidden_type_plugin/index.js +++ b/x-pack/test/saved_object_api_integration/common/fixtures/hidden_type_plugin/index.js @@ -21,5 +21,7 @@ export default function(kibana) { }, config() {}, + + init() {}, // need empty init for plugin to load }); } diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/hidden_type_plugin/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/hidden_type_plugin/mappings.json index e4815273964a1..45f898e10e2ba 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/hidden_type_plugin/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/hidden_type_plugin/mappings.json @@ -1,7 +1,7 @@ { "hiddentype": { "properties": { - "name": { + "title": { "type": "text", "fields": { "keyword": { diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/index.js b/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/index.js new file mode 100644 index 0000000000000..a406c6737da5f --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/index.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import mappings from './mappings.json'; + +export default function(kibana) { + return new kibana.Plugin({ + require: ['kibana', 'elasticsearch', 'xpack_main'], + name: 'isolated_type_plugin', + uiExports: { + savedObjectsManagement: { + isolatedtype: { + isImportableAndExportable: true, + }, + }, + mappings, + }, + + config() {}, + + init() {}, // need empty init for plugin to load + }); +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/mappings.json new file mode 100644 index 0000000000000..141ebbc93c290 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/mappings.json @@ -0,0 +1,31 @@ +{ + "isolatedtype": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/package.json b/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/package.json new file mode 100644 index 0000000000000..665ecb1b31d7e --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "isolated_type_plugin", + "version": "0.0.0", + "kibana": { + "version": "kibana" + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/mappings.json index b30a2c3877b88..64d309b4209a2 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/mappings.json @@ -1,7 +1,7 @@ { "globaltype": { "properties": { - "name": { + "title": { "type": "text", "fields": { "keyword": { diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/index.js b/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/index.js new file mode 100644 index 0000000000000..91a24fb9f4f56 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/index.js @@ -0,0 +1,27 @@ +/* + * 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 mappings from './mappings.json'; + +export default function(kibana) { + return new kibana.Plugin({ + require: ['kibana', 'elasticsearch', 'xpack_main'], + name: 'shared_type_plugin', + uiExports: { + savedObjectsManagement: {}, + savedObjectSchemas: { + sharedtype: { + multiNamespace: true, + }, + }, + mappings, + }, + + config() {}, + + init() {}, // need empty init for plugin to load + }); +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/mappings.json new file mode 100644 index 0000000000000..918958aec0d6d --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/mappings.json @@ -0,0 +1,15 @@ +{ + "sharedtype": { + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/package.json b/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/package.json new file mode 100644 index 0000000000000..c52f4256c5c06 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "shared_type_plugin", + "version": "0.0.0", + "kibana": { + "version": "kibana" + } +} diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts new file mode 100644 index 0000000000000..b32950538f8e5 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const SAVED_OBJECT_TEST_CASES = Object.freeze({ + SINGLE_NAMESPACE_DEFAULT_SPACE: Object.freeze({ + type: 'isolatedtype', + id: 'defaultspace-isolatedtype-id', + }), + SINGLE_NAMESPACE_SPACE_1: Object.freeze({ + type: 'isolatedtype', + id: 'space1-isolatedtype-id', + }), + SINGLE_NAMESPACE_SPACE_2: Object.freeze({ + type: 'isolatedtype', + id: 'space2-isolatedtype-id', + }), + MULTI_NAMESPACE_DEFAULT_AND_SPACE_1: Object.freeze({ + type: 'sharedtype', + id: 'default_and_space_1', + }), + MULTI_NAMESPACE_ONLY_SPACE_1: Object.freeze({ + type: 'sharedtype', + id: 'only_space_1', + }), + MULTI_NAMESPACE_ONLY_SPACE_2: Object.freeze({ + type: 'sharedtype', + id: 'only_space_2', + }), + NAMESPACE_AGNOSTIC: Object.freeze({ + type: 'globaltype', + id: 'globaltype-id', + }), + HIDDEN: Object.freeze({ + type: 'hiddentype', + id: 'any', + }), +}); diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts new file mode 100644 index 0000000000000..5640dfefa4f8d --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts @@ -0,0 +1,302 @@ +/* + * 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 { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; +import { SAVED_OBJECT_TEST_CASES as CASES } from './saved_object_test_cases'; +import { SPACES } from './spaces'; +import { AUTHENTICATION } from './authentication'; +import { TestCase, TestUser, ExpectResponseBody } from './types'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { + NOT_A_KIBANA_USER, + SUPERUSER, + KIBANA_LEGACY_USER, + KIBANA_DUAL_PRIVILEGES_USER, + KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + KIBANA_RBAC_USER, + KIBANA_RBAC_DASHBOARD_ONLY_USER, + KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + KIBANA_RBAC_SPACE_1_ALL_USER, + KIBANA_RBAC_SPACE_1_READ_USER, +} = AUTHENTICATION; + +export function getUrlPrefix(spaceId: string) { + return spaceId && spaceId !== DEFAULT_SPACE_ID ? `/s/${spaceId}` : ``; +} + +export function getExpectedSpaceIdProperty(spaceId: string) { + if (spaceId === DEFAULT_SPACE_ID) { + return {}; + } + return { + spaceId, + }; +} + +export const getTestTitle = ( + testCaseOrCases: TestCase | TestCase[], + bulkStatusCode?: 200 | 403 // only used for bulk test suites; other suites specify forbidden/permitted in each test case +) => { + const testCases = Array.isArray(testCaseOrCases) ? testCaseOrCases : [testCaseOrCases]; + const stringify = (array: TestCase[]) => array.map(x => `${x.type}/${x.id}`).join(); + if (bulkStatusCode === 403 || (testCases.length === 1 && testCases[0].failure === 403)) { + return `forbidden [${stringify(testCases)}]`; + } + if (testCases.find(x => x.failure === 403)) { + throw new Error( + 'Cannot create test title for multiple forbidden test cases; specify individual tests for each of these test cases' + ); + } + // permitted + const list: string[] = []; + Object.entries({ + success: undefined, + 'bad request': 400, + 'not found': 404, + conflict: 409, + }).forEach(([descriptor, failure]) => { + const filtered = testCases.filter(x => x.failure === failure); + if (filtered.length) { + list.push(`${descriptor} [${stringify(filtered)}]`); + } + }); + return `${list.join(' and ')}`; +}; + +export const testCaseFailures = { + // test suites need explicit return types for number primitives + fail400: (condition?: boolean): { failure?: 400 } => + condition !== false ? { failure: 400 } : {}, + fail404: (condition?: boolean): { failure?: 404 } => + condition !== false ? { failure: 404 } : {}, + fail409: (condition?: boolean): { failure?: 409 } => + condition !== false ? { failure: 409 } : {}, +}; + +/** + * Test cases have additional properties that we don't want to send in HTTP Requests + */ +export const createRequest = ({ type, id }: TestCase) => ({ type, id }); + +const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); +const isNamespaceAgnostic = (type: string) => type === 'globaltype'; +const isMultiNamespace = (type: string) => type === 'sharedtype'; +export const expectResponses = { + forbidden: (action: string) => (typeOrTypes: string | string[]): ExpectResponseBody => async ( + response: Record + ) => { + const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; + const uniqueSorted = uniq(types).sort(); + expect(response.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to ${action} ${uniqueSorted.join()}`, + }); + }, + permitted: async (object: Record, testCase: TestCase) => { + const { type, id, failure } = testCase; + if (failure) { + let error: ReturnType; + if (failure === 400) { + error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type); + } else if (failure === 404) { + error = SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } else if (failure === 409) { + error = SavedObjectsErrorHelpers.createConflictError(type, id); + } else { + throw new Error(`Encountered unexpected error code ${failure}`); + } + // should not call permitted with a 403 failure case + if (object.type && object.id) { + // bulk request error + expect(object.type).to.eql(type); + expect(object.id).to.eql(id); + expect(object.error).to.eql(error.output.payload); + } else { + // non-bulk request error + expect(object.error).to.eql(error.output.payload.error); + expect(object.statusCode).to.eql(error.output.payload.statusCode); + // ignore the error.message, because it can vary for decorated non-bulk errors (e.g., conflict) + } + } else { + // fall back to default behavior of testing the success outcome + expect(object.type).to.eql(type); + if (id) { + expect(object.id).to.eql(id); + } else { + // created an object without specifying the ID, so it was auto-generated + expect(object.id).to.match(/^[0-9a-f-]{36}$/); + } + expect(object).not.to.have.property('error'); + expect(object.updated_at).to.match(/^[\d-]{10}T[\d:\.]{12}Z$/); + // don't test attributes, version, or references + } + }, + /** + * Additional assertions that we use in `bulk_create` and `create` to ensure that + * newly-created (or overwritten) objects don't have unexpected properties + */ + successCreated: async (es: any, spaceId: string, type: string, id: string) => { + const isNamespaceUndefined = + spaceId === SPACES.DEFAULT.spaceId || isNamespaceAgnostic(type) || isMultiNamespace(type); + const expectedSpacePrefix = isNamespaceUndefined ? '' : `${spaceId}:`; + const savedObject = await es.get({ + id: `${expectedSpacePrefix}${type}:${id}`, + index: '.kibana', + }); + const { namespace: actualNamespace, namespaces: actualNamespaces } = savedObject._source; + if (isNamespaceUndefined) { + expect(actualNamespace).to.eql(undefined); + } else { + expect(actualNamespace).to.eql(spaceId); + } + if (isMultiNamespace(type)) { + if (id === CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1.id) { + expect(actualNamespaces).to.eql([DEFAULT_SPACE_ID, SPACE_1_ID]); + } else if (id === CASES.MULTI_NAMESPACE_ONLY_SPACE_1.id) { + expect(actualNamespaces).to.eql([SPACE_1_ID]); + } else if (id === CASES.MULTI_NAMESPACE_ONLY_SPACE_2.id) { + expect(actualNamespaces).to.eql([SPACE_2_ID]); + } else { + // newly created in this space + expect(actualNamespaces).to.eql([spaceId]); + } + } + return savedObject; + }, +}; + +/** + * Get test scenarios for each type of suite. + * @param modifier Use this to generate additional permutations of test scenarios. + * For instance, a modifier of ['foo', 'bar'] would return + * a `securityAndSpaces` of: [ + * { spaceId: DEFAULT_SPACE_ID, users: {...}, modifier: 'foo' }, + * { spaceId: DEFAULT_SPACE_ID, users: {...}, modifier: 'bar' }, + * { spaceId: SPACE_1_ID, users: {...}, modifier: 'foo' }, + * { spaceId: SPACE_1_ID, users: {...}, modifier: 'bar' }, + * ] + */ +export const getTestScenarios = (modifiers?: T[]) => { + const commonUsers = { + noAccess: { ...NOT_A_KIBANA_USER, description: 'user with no access' }, + superuser: { ...SUPERUSER, description: 'superuser' }, + legacyAll: { ...KIBANA_LEGACY_USER, description: 'legacy user' }, + allGlobally: { ...KIBANA_RBAC_USER, description: 'rbac user with all globally' }, + readGlobally: { + ...KIBANA_RBAC_DASHBOARD_ONLY_USER, + description: 'rbac user with read globally', + }, + dualAll: { ...KIBANA_DUAL_PRIVILEGES_USER, description: 'dual-privileges user' }, + dualRead: { + ...KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + description: 'dual-privileges readonly user', + }, + }; + + interface Security { + modifier?: T; + users: Record< + | keyof typeof commonUsers + | 'allAtDefaultSpace' + | 'readAtDefaultSpace' + | 'allAtSpace1' + | 'readAtSpace1', + TestUser + >; + } + interface SecurityAndSpaces { + modifier?: T; + users: Record< + keyof typeof commonUsers | 'allAtSpace' | 'readAtSpace' | 'allAtOtherSpace', + TestUser + >; + spaceId: string; + } + interface Spaces { + modifier?: T; + spaceId: string; + } + + let spaces: Spaces[] = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID].map(x => ({ spaceId: x })); + let security: Security[] = [ + { + users: { + ...commonUsers, + allAtDefaultSpace: { + ...KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + description: 'rbac user with all at default space', + }, + readAtDefaultSpace: { + ...KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + description: 'rbac user with read at default space', + }, + allAtSpace1: { + ...KIBANA_RBAC_SPACE_1_ALL_USER, + description: 'rbac user with all at space_1', + }, + readAtSpace1: { + ...KIBANA_RBAC_SPACE_1_READ_USER, + description: 'rbac user with read at space_1', + }, + }, + }, + ]; + let securityAndSpaces: SecurityAndSpaces[] = [ + { + spaceId: DEFAULT_SPACE_ID, + users: { + ...commonUsers, + allAtSpace: { + ...KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + description: 'user with all at the space', + }, + readAtSpace: { + ...KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + description: 'user with read at the space', + }, + allAtOtherSpace: { + ...KIBANA_RBAC_SPACE_1_ALL_USER, + description: 'user with all at other space', + }, + }, + }, + { + spaceId: SPACE_1_ID, + users: { + ...commonUsers, + allAtSpace: { ...KIBANA_RBAC_SPACE_1_ALL_USER, description: 'user with all at the space' }, + readAtSpace: { + ...KIBANA_RBAC_SPACE_1_READ_USER, + description: 'user with read at the space', + }, + allAtOtherSpace: { + ...KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + description: 'user with all at other space', + }, + }, + }, + ]; + if (modifiers) { + const addModifier = (list: T[]) => + list.map(x => modifiers.map(modifier => ({ ...x, modifier }))).flat(); + spaces = addModifier(spaces); + security = addModifier(security); + securityAndSpaces = addModifier(securityAndSpaces); + } + return { + spaces, + security, + securityAndSpaces, + }; +}; diff --git a/x-pack/test/saved_object_api_integration/common/lib/space_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/space_test_utils.ts deleted file mode 100644 index 1619d77761c84..0000000000000 --- a/x-pack/test/saved_object_api_integration/common/lib/space_test_utils.ts +++ /dev/null @@ -1,24 +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 { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; - -export function getUrlPrefix(spaceId: string) { - return spaceId && spaceId !== DEFAULT_SPACE_ID ? `/s/${spaceId}` : ``; -} - -export function getIdPrefix(spaceId: string) { - return spaceId === DEFAULT_SPACE_ID ? '' : `${spaceId}-`; -} - -export function getExpectedSpaceIdProperty(spaceId: string) { - if (spaceId === DEFAULT_SPACE_ID) { - return {}; - } - return { - spaceId, - }; -} diff --git a/x-pack/test/saved_object_api_integration/common/lib/types.ts b/x-pack/test/saved_object_api_integration/common/lib/types.ts index 487afff1494c0..f6e6d391ae905 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/types.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/types.ts @@ -4,9 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ -export type DescribeFn = (text: string, fn: () => void) => void; +export type ExpectResponseBody = (response: Record) => Promise; -export interface TestDefinitionAuthentication { - username?: string; - password?: string; +export interface TestDefinition { + title: string; + responseStatusCode: number; + responseBody: ExpectResponseBody; +} + +export interface TestSuite { + user?: TestUser; + spaceId?: string; + tests: T[]; +} + +export interface TestCase { + type: string; + id: string; + failure?: 400 | 403 | 404 | 409; +} + +export interface TestUser { + username: string; + password: string; + description: string; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts index b6f1bb956d72d..0dafe6b7b386d 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts @@ -6,224 +6,137 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface BulkCreateTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; -} - -interface BulkCreateCustomTest extends BulkCreateTest { - description: string; - requestBody: { - [key: string]: any; - }; -} - -interface BulkCreateTests { - default: BulkCreateTest; - includingSpace: BulkCreateTest; - custom?: BulkCreateCustomTest; +import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SPACES } from '../lib/spaces'; +import { + createRequest, + expectResponses, + getUrlPrefix, + getTestTitle, +} from '../lib/saved_object_test_utils'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +export interface BulkCreateTestDefinition extends TestDefinition { + request: Array<{ type: string; id: string }>; + overwrite: boolean; } - -interface BulkCreateTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - tests: BulkCreateTests; +export type BulkCreateTestSuite = TestSuite; +export interface BulkCreateTestCase extends TestCase { + failure?: 400 | 409; // only used for permitted response case } -const createBulkRequests = (spaceId: string) => [ - { - type: 'visualization', - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - attributes: { - title: 'An existing visualization', - }, - }, - { - type: 'dashboard', - id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, - attributes: { - title: 'A great new dashboard', - }, - }, - { - type: 'globaltype', - id: '05976c65-1145-4858-bbf0-d225cc78a06e', - attributes: { - name: 'A new globaltype object', - }, - }, - { - type: 'globaltype', - id: '8121a00-8efd-21e7-1cb3-34ab966434445', - attributes: { - name: 'An existing globaltype', - }, - }, -]; +const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake +const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; -const isGlobalType = (type: string) => type === 'globaltype'; +const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' }); +const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); +const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); +export const TEST_CASES = Object.freeze({ + ...CASES, + NEW_SINGLE_NAMESPACE_OBJ, + NEW_MULTI_NAMESPACE_OBJ, + NEW_NAMESPACE_AGNOSTIC_OBJ, +}); export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { - const createExpectResults = (spaceId = DEFAULT_SPACE_ID) => async (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - saved_objects: [ - { - type: 'visualization', - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - error: { - message: 'version conflict, document already exists', - statusCode: 409, - }, - }, - { - type: 'dashboard', - id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, - updated_at: resp.body.saved_objects[1].updated_at, - version: resp.body.saved_objects[1].version, - attributes: { - title: 'A great new dashboard', - }, - references: [], - }, - { - type: 'globaltype', - id: `05976c65-1145-4858-bbf0-d225cc78a06e`, - updated_at: resp.body.saved_objects[2].updated_at, - version: resp.body.saved_objects[2].version, - attributes: { - name: 'A new globaltype object', - }, - references: [], - }, - { - type: 'globaltype', - id: '8121a00-8efd-21e7-1cb3-34ab966434445', - error: { - message: 'version conflict, document already exists', - statusCode: 409, - }, - }, - ], - }); - - for (const savedObject of createBulkRequests(spaceId)) { - const expectedSpacePrefix = - spaceId === DEFAULT_SPACE_ID || isGlobalType(savedObject.type) ? '' : `${spaceId}:`; - - // query ES directory to ensure namespace was or wasn't specified - const { _source } = await es.get({ - id: `${expectedSpacePrefix}${savedObject.type}:${savedObject.id}`, - index: '.kibana', - }); - - const { namespace: actualNamespace } = _source; - - if (spaceId === DEFAULT_SPACE_ID || isGlobalType(savedObject.type)) { - expect(actualNamespace).to.eql(undefined); - } else { - expect(actualNamespace).to.eql(spaceId); + const expectForbidden = expectResponses.forbidden('bulk_create'); + const expectResponseBody = ( + testCases: BulkCreateTestCase | BulkCreateTestCase[], + statusCode: 200 | 403, + spaceId = SPACES.DEFAULT.spaceId + ): ExpectResponseBody => async (response: Record) => { + const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; + if (statusCode === 403) { + const types = testCaseArray.map(x => x.type); + await expectForbidden(types)(response); + } else { + // permitted + const savedObjects = response.body.saved_objects; + expect(savedObjects).length(testCaseArray.length); + for (let i = 0; i < savedObjects.length; i++) { + const object = savedObjects[i]; + const testCase = testCaseArray[i]; + await expectResponses.permitted(object, testCase); + if (!testCase.failure) { + expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + await expectResponses.successCreated(es, spaceId, object.type, object.id); + } } } }; - - const expectBadRequestForHiddenType = (resp: { [key: string]: any }) => { - const spaceEntry = resp.body.saved_objects.find( - (entry: any) => entry.id === 'my-hiddentype' && entry.type === 'hiddentype' - ); - expect(spaceEntry).to.eql({ - id: 'my-hiddentype', - type: 'hiddentype', - error: { - message: "Unsupported saved object type: 'hiddentype': Bad Request", - statusCode: 400, - error: 'Bad Request', + const createTestDefinitions = ( + testCases: BulkCreateTestCase | BulkCreateTestCase[], + forbidden: boolean, + overwrite: boolean, + options?: { + spaceId?: string; + singleRequest?: boolean; + responseBodyOverride?: ExpectResponseBody; + } + ): BulkCreateTestDefinition[] => { + const cases = Array.isArray(testCases) ? testCases : [testCases]; + const responseStatusCode = forbidden ? 403 : 200; + if (!options?.singleRequest) { + // if we are testing cases that should result in a forbidden response, we can do each case individually + // this ensures that multiple test cases of a single type will each result in a forbidden error + return cases.map(x => ({ + title: getTestTitle(x, responseStatusCode), + request: [createRequest(x)], + responseStatusCode, + responseBody: + options?.responseBodyOverride || + expectResponseBody(x, responseStatusCode, options?.spaceId), + overwrite, + })); + } + // batch into a single request to save time during test execution + return [ + { + title: getTestTitle(cases, responseStatusCode), + request: cases.map(x => createRequest(x)), + responseStatusCode, + responseBody: + options?.responseBodyOverride || + expectResponseBody(cases, responseStatusCode, options?.spaceId), + overwrite, }, - }); + ]; }; - const expectedForbiddenTypes = ['dashboard', 'globaltype', 'visualization']; - const expectedForbiddenTypesWithHiddenType = [ - 'dashboard', - 'globaltype', - 'hiddentype', - 'visualization', - ]; - const createExpectRbacForbidden = (types: string[] = expectedForbiddenTypes) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to bulk_create ${types.join(',')}`, - }); - }; - - const makeBulkCreateTest = (describeFn: DescribeFn) => ( + const makeBulkCreateTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: BulkCreateTestDefinition + definition: BulkCreateTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`should return ${tests.default.statusCode}`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_create`) - .auth(user.username, user.password) - .send(createBulkRequests(spaceId)) - .expect(tests.default.statusCode) - .then(tests.default.response); - }); - - it(`including a hiddentype saved object should return ${tests.includingSpace.statusCode}`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_create`) - .auth(user.username, user.password) - .send( - createBulkRequests(spaceId).concat([ - { - type: 'hiddentype', - id: `my-hiddentype`, - attributes: { - name: 'My awesome hiddentype', - }, - }, - ]) - ) - .expect(tests.includingSpace.statusCode) - .then(tests.includingSpace.response); - }); + const attrs = { attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL } }; - if (tests.custom) { - it(tests.custom!.description, async () => { + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const requestBody = test.request.map(x => ({ ...x, ...attrs })); + const query = test.overwrite ? '?overwrite=true' : ''; await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_create`) - .auth(user.username, user.password) - .send(tests.custom!.requestBody) - .expect(tests.custom!.statusCode) - .then(tests.custom!.response); + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_create${query}`) + .auth(user?.username, user?.password) + .send(requestBody) + .expect(test.responseStatusCode) + .then(test.responseBody); }); } }); }; - const bulkCreateTest = makeBulkCreateTest(describe); + const addTests = makeBulkCreateTest(describe); // @ts-ignore - bulkCreateTest.only = makeBulkCreateTest(describe.only); + addTests.only = makeBulkCreateTest(describe.only); return { - bulkCreateTest, - createExpectResults, - createExpectRbacForbidden, - expectBadRequestForHiddenType, - expectedForbiddenTypesWithHiddenType, + addTests, + createTestDefinitions, + expectForbidden, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts index 9c5cc375502d1..f03dac597294c 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts @@ -6,203 +6,110 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; +import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SPACES } from '../lib/spaces'; +import { + createRequest, + expectResponses, + getUrlPrefix, + getTestTitle, +} from '../lib/saved_object_test_utils'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; -interface BulkGetTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; +export interface BulkGetTestDefinition extends TestDefinition { + request: Array<{ type: string; id: string }>; } - -interface BulkGetTests { - default: BulkGetTest; - includingHiddenType: BulkGetTest; -} - -interface BulkGetTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - otherSpaceId?: string; - tests: BulkGetTests; +export type BulkGetTestSuite = TestSuite; +export interface BulkGetTestCase extends TestCase { + failure?: 400 | 404; // only used for permitted response case } -const createBulkRequests = (spaceId: string) => [ - { - type: 'visualization', - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - }, - { - type: 'dashboard', - id: `${getIdPrefix(spaceId)}does not exist`, - }, - { - type: 'globaltype', - id: '8121a00-8efd-21e7-1cb3-34ab966434445', - }, -]; +const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); +export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); export function bulkGetTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const createExpectNotFoundResults = (spaceId: string) => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - saved_objects: [ - { - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - type: 'visualization', - error: { - statusCode: 404, - message: 'Not found', - }, - }, - { - id: `${getIdPrefix(spaceId)}does not exist`, - type: 'dashboard', - error: { - statusCode: 404, - message: 'Not found', - }, - }, - { - id: `8121a00-8efd-21e7-1cb3-34ab966434445`, - type: 'globaltype', - updated_at: '2017-09-21T18:59:16.270Z', - version: resp.body.saved_objects[2].version, - attributes: { - name: 'My favorite global object', - }, - references: [], - }, - ], - }); + const expectForbidden = expectResponses.forbidden('bulk_get'); + const expectResponseBody = ( + testCases: BulkGetTestCase | BulkGetTestCase[], + statusCode: 200 | 403 + ): ExpectResponseBody => async (response: Record) => { + const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; + if (statusCode === 403) { + const types = testCaseArray.map(x => x.type); + await expectForbidden(types)(response); + } else { + // permitted + const savedObjects = response.body.saved_objects; + expect(savedObjects).length(testCaseArray.length); + for (let i = 0; i < savedObjects.length; i++) { + const object = savedObjects[i]; + const testCase = testCaseArray[i]; + await expectResponses.permitted(object, testCase); + } + } }; - - const expectBadRequestForHiddenType = (resp: { [key: string]: any }) => { - const spaceEntry = resp.body.saved_objects.find( - (entry: any) => entry.id === 'my-hiddentype' && entry.type === 'hiddentype' - ); - expect(spaceEntry).to.eql({ - id: 'my-hiddentype', - type: 'hiddentype', - error: { - message: "Unsupported saved object type: 'hiddentype': Bad Request", - statusCode: 400, - error: 'Bad Request', + const createTestDefinitions = ( + testCases: BulkGetTestCase | BulkGetTestCase[], + forbidden: boolean, + options?: { + singleRequest?: boolean; + responseBodyOverride?: ExpectResponseBody; + } + ): BulkGetTestDefinition[] => { + const cases = Array.isArray(testCases) ? testCases : [testCases]; + const responseStatusCode = forbidden ? 403 : 200; + if (!options?.singleRequest) { + // if we are testing cases that should result in a forbidden response, we can do each case individually + // this ensures that multiple test cases of a single type will each result in a forbidden error + return cases.map(x => ({ + title: getTestTitle(x, responseStatusCode), + request: [createRequest(x)], + responseStatusCode, + responseBody: options?.responseBodyOverride || expectResponseBody(x, responseStatusCode), + })); + } + // batch into a single request to save time during test execution + return [ + { + title: getTestTitle(cases, responseStatusCode), + request: cases.map(x => createRequest(x)), + responseStatusCode, + responseBody: + options?.responseBodyOverride || expectResponseBody(cases, responseStatusCode), }, - }); + ]; }; - const expectedForbiddenTypes = ['dashboard', 'globaltype', 'visualization']; - const expectedForbiddenTypesWithHiddenType = [ - 'dashboard', - 'globaltype', - 'hiddentype', - 'visualization', - ]; - const createExpectRbacForbidden = (types: string[] = expectedForbiddenTypes) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to bulk_get ${types.join(',')}`, - }); - }; - - const createExpectResults = (spaceId = DEFAULT_SPACE_ID) => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - saved_objects: [ - { - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - type: 'visualization', - migrationVersion: resp.body.saved_objects[0].migrationVersion, - updated_at: '2017-09-21T18:51:23.794Z', - version: resp.body.saved_objects[0].version, - attributes: { - title: 'Count of requests', - description: '', - version: 1, - // cheat for some of the more complex attributes - visState: resp.body.saved_objects[0].attributes.visState, - uiStateJSON: resp.body.saved_objects[0].attributes.uiStateJSON, - kibanaSavedObjectMeta: resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta, - }, - references: [ - { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - id: `${getIdPrefix(spaceId)}91200a00-9efd-11e7-acb3-3dab96693fab`, - }, - ], - }, - { - id: `${getIdPrefix(spaceId)}does not exist`, - type: 'dashboard', - error: { - statusCode: 404, - message: 'Not found', - }, - }, - { - id: `8121a00-8efd-21e7-1cb3-34ab966434445`, - type: 'globaltype', - updated_at: '2017-09-21T18:59:16.270Z', - version: resp.body.saved_objects[2].version, - attributes: { - name: 'My favorite global object', - }, - references: [], - }, - ], - }); - }; - - const makeBulkGetTest = (describeFn: DescribeFn) => ( + const makeBulkGetTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: BulkGetTestDefinition + definition: BulkGetTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, otherSpaceId, tests } = definition; + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`should return ${tests.default.statusCode}`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_get`) - .auth(user.username, user.password) - .send(createBulkRequests(otherSpaceId || spaceId)) - .expect(tests.default.statusCode) - .then(tests.default.response); - }); - - it(`with a hiddentype saved object included should return ${tests.includingHiddenType.statusCode}`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_get`) - .auth(user.username, user.password) - .send( - createBulkRequests(otherSpaceId || spaceId).concat([ - { - type: 'hiddentype', - id: `my-hiddentype`, - }, - ]) - ) - .expect(tests.includingHiddenType.statusCode) - .then(tests.includingHiddenType.response); - }); + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + await supertest + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_get`) + .auth(user?.username, user?.password) + .send(test.request) + .expect(test.responseStatusCode) + .then(test.responseBody); + }); + } }); }; - const bulkGetTest = makeBulkGetTest(describe); + const addTests = makeBulkGetTest(describe); // @ts-ignore - bulkGetTest.only = makeBulkGetTest(describe.only); + addTests.only = makeBulkGetTest(describe.only); return { - bulkGetTest, - createExpectNotFoundResults, - createExpectResults, - createExpectRbacForbidden, - expectBadRequestForHiddenType, - expectedForbiddenTypesWithHiddenType, + addTests, + createTestDefinitions, + expectForbidden, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts index d14c5ccbd1d0e..e0e2118300ef4 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts @@ -6,234 +6,119 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface BulkUpdateTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; -} - -interface BulkUpdateTests { - spaceAware: BulkUpdateTest; - notSpaceAware: BulkUpdateTest; - hiddenType: BulkUpdateTest; - doesntExist: BulkUpdateTest; +import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SPACES } from '../lib/spaces'; +import { + createRequest, + expectResponses, + getUrlPrefix, + getTestTitle, +} from '../lib/saved_object_test_utils'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +export interface BulkUpdateTestDefinition extends TestDefinition { + request: Array<{ type: string; id: string }>; } - -interface BulkUpdateTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - otherSpaceId?: string; - tests: BulkUpdateTests; +export type BulkUpdateTestSuite = TestSuite; +export interface BulkUpdateTestCase extends TestCase { + failure?: 404; // only used for permitted response case } -export function bulkUpdateTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const createExpectNotFound = (type: string, id: string, spaceId = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - const [, savedObject] = resp.body.saved_objects; - expect(savedObject.error).eql({ - statusCode: 404, - error: 'Not Found', - message: `Saved object [${type}/${getIdPrefix(spaceId)}${id}] not found`, - }); - }; - - const createExpectDoesntExistNotFound = (spaceId?: string) => { - return createExpectNotFound('visualization', 'not an id', spaceId); - }; - - const createExpectSpaceAwareNotFound = (spaceId?: string) => { - return createExpectNotFound('visualization', 'dd7caf20-9efd-11e7-acb3-3dab96693fab', spaceId); - }; - - const expectHiddenTypeNotFound = createExpectNotFound( - 'hiddentype', - 'hiddentype_1', - DEFAULT_SPACE_ID - ); - - const createExpectRbacForbidden = (types: string[]) => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to bulk_update ${types.join()}`, - }); - }; - - const expectDoesntExistRbacForbidden = createExpectRbacForbidden(['globaltype', 'visualization']); +const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake +const NEW_ATTRIBUTE_VAL = `Updated attribute value ${Date.now()}`; - const expectNotSpaceAwareRbacForbidden = createExpectRbacForbidden(['globaltype']); +const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); +export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); - const expectHiddenTypeRbacForbidden = createExpectRbacForbidden(['globaltype', 'hiddentype']); - const expectHiddenTypeRbacForbiddenWithGlobalAllowed = createExpectRbacForbidden(['hiddentype']); - - const expectSpaceAwareRbacForbidden = createExpectRbacForbidden(['globaltype', 'visualization']); - - const expectNotSpaceAwareResults = (resp: { [key: string]: any }) => { - const [, savedObject] = resp.body.saved_objects; - // loose uuid validation - expect(savedObject) - .to.have.property('id') - .match(/^[0-9a-f-]{36}$/); - - // loose ISO8601 UTC time with milliseconds validation - expect(savedObject) - .to.have.property('updated_at') - .match(/^[\d-]{10}T[\d:\.]{12}Z$/); - - expect(savedObject).to.eql({ - id: savedObject.id, - type: 'globaltype', - updated_at: savedObject.updated_at, - version: savedObject.version, - attributes: { - name: 'My second favorite', - }, - }); +export function bulkUpdateTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const expectForbidden = expectResponses.forbidden('bulk_update'); + const expectResponseBody = ( + testCases: BulkUpdateTestCase | BulkUpdateTestCase[], + statusCode: 200 | 403 + ): ExpectResponseBody => async (response: Record) => { + const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; + if (statusCode === 403) { + const types = testCaseArray.map(x => x.type); + await expectForbidden(types)(response); + } else { + // permitted + const savedObjects = response.body.saved_objects; + expect(savedObjects).length(testCaseArray.length); + for (let i = 0; i < savedObjects.length; i++) { + const object = savedObjects[i]; + const testCase = testCaseArray[i]; + await expectResponses.permitted(object, testCase); + if (!testCase.failure) { + expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + } + } + } }; - - const expectSpaceAwareResults = (resp: { [key: string]: any }) => { - const [, savedObject] = resp.body.saved_objects; - // loose uuid validation ignoring prefix - expect(savedObject) - .to.have.property('id') - .match(/[0-9a-f-]{36}$/); - - // loose ISO8601 UTC time with milliseconds validation - expect(savedObject) - .to.have.property('updated_at') - .match(/^[\d-]{10}T[\d:\.]{12}Z$/); - - expect(savedObject).to.eql({ - id: savedObject.id, - type: 'visualization', - updated_at: savedObject.updated_at, - version: savedObject.version, - attributes: { - title: 'My second favorite vis', + const createTestDefinitions = ( + testCases: BulkUpdateTestCase | BulkUpdateTestCase[], + forbidden: boolean, + options?: { + singleRequest?: boolean; + responseBodyOverride?: ExpectResponseBody; + } + ): BulkUpdateTestDefinition[] => { + const cases = Array.isArray(testCases) ? testCases : [testCases]; + const responseStatusCode = forbidden ? 403 : 200; + if (!options?.singleRequest) { + // if we are testing cases that should result in a forbidden response, we can do each case individually + // this ensures that multiple test cases of a single type will each result in a forbidden error + return cases.map(x => ({ + title: getTestTitle(x, responseStatusCode), + request: [createRequest(x)], + responseStatusCode, + responseBody: options?.responseBodyOverride || expectResponseBody(x, responseStatusCode), + })); + } + // batch into a single request to save time during test execution + return [ + { + title: getTestTitle(cases, responseStatusCode), + request: cases.map(x => createRequest(x)), + responseStatusCode, + responseBody: + options?.responseBodyOverride || expectResponseBody(cases, responseStatusCode), }, - }); + ]; }; - const makeBulkUpdateTest = (describeFn: DescribeFn) => ( + const makeBulkUpdateTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: BulkUpdateTestDefinition + definition: BulkUpdateTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, otherSpaceId, tests } = definition; - - // We add this type into all bulk updates - // to ensure that having additional items in the bulk - // update doesn't change the expected outcome overall - let updateCount = 0; - const generateNonSpaceAwareGlobalSavedObject = () => ({ - type: 'globaltype', - id: `8121a00-8efd-21e7-1cb3-34ab966434445`, - attributes: { - name: `Update #${++updateCount}`, - }, - }); + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`should return ${tests.spaceAware.statusCode} for a space-aware doc`, async () => { - await supertest - .put(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_update`) - .auth(user.username, user.password) - .send([ - generateNonSpaceAwareGlobalSavedObject(), - { - type: 'visualization', - id: `${getIdPrefix(otherSpaceId || spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - attributes: { - title: 'My second favorite vis', - }, - }, - generateNonSpaceAwareGlobalSavedObject(), - ]) - .expect(tests.spaceAware.statusCode) - .then(tests.spaceAware.response); - }); - - it(`should return ${tests.notSpaceAware.statusCode} for a non space-aware doc`, async () => { - await supertest - .put(`${getUrlPrefix(otherSpaceId || spaceId)}/api/saved_objects/_bulk_update`) - .auth(user.username, user.password) - .send([ - generateNonSpaceAwareGlobalSavedObject(), - { - type: 'globaltype', - id: `8121a00-8efd-21e7-1cb3-34ab966434445`, - attributes: { - name: 'My second favorite', - }, - }, - generateNonSpaceAwareGlobalSavedObject(), - ]) - .expect(tests.notSpaceAware.statusCode) - .then(tests.notSpaceAware.response); - }); - it(`should return ${tests.hiddenType.statusCode} for hiddentype doc`, async () => { - await supertest - .put(`${getUrlPrefix(otherSpaceId || spaceId)}/api/saved_objects/_bulk_update`) - .auth(user.username, user.password) - .send([ - generateNonSpaceAwareGlobalSavedObject(), - { - type: 'hiddentype', - id: 'hiddentype_1', - attributes: { - name: 'My favorite hidden type', - }, - }, - generateNonSpaceAwareGlobalSavedObject(), - ]) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response); - }); + const attrs = { attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL } }; - describe('unknown id', () => { - it(`should return ${tests.doesntExist.statusCode}`, async () => { + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const requestBody = test.request.map(x => ({ ...x, ...attrs })); await supertest .put(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_update`) - .auth(user.username, user.password) - .send([ - generateNonSpaceAwareGlobalSavedObject(), - { - type: 'visualization', - id: `${getIdPrefix(spaceId)}not an id`, - attributes: { - title: 'My second favorite vis', - }, - }, - generateNonSpaceAwareGlobalSavedObject(), - ]) - .expect(tests.doesntExist.statusCode) - .then(tests.doesntExist.response); + .auth(user?.username, user?.password) + .send(requestBody) + .expect(test.responseStatusCode) + .then(test.responseBody); }); - }); + } }); }; - const bulkUpdateTest = makeBulkUpdateTest(describe); + const addTests = makeBulkUpdateTest(describe); // @ts-ignore - bulkUpdateTest.only = makeBulkUpdateTest(describe.only); + addTests.only = makeBulkUpdateTest(describe.only); return { - createExpectDoesntExistNotFound, - createExpectSpaceAwareNotFound, - expectSpaceNotFound: expectHiddenTypeNotFound, - expectDoesntExistRbacForbidden, - expectNotSpaceAwareRbacForbidden, - expectNotSpaceAwareResults, - expectSpaceAwareRbacForbidden, - expectSpaceAwareResults, - expectHiddenTypeRbacForbidden, - expectHiddenTypeRbacForbiddenWithGlobalAllowed, - bulkUpdateTest, + addTests, + createTestDefinitions, + expectForbidden, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/create.ts b/x-pack/test/saved_object_api_integration/common/suites/create.ts index 29960c513d40f..f657756be92cd 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/create.ts @@ -3,206 +3,117 @@ * 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 { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface CreateTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; -} - -interface CreateCustomTest extends CreateTest { - type: string; - description: string; - requestBody: any; -} - -interface CreateTests { - spaceAware: CreateTest; - notSpaceAware: CreateTest; - hiddenType: CreateTest; - custom?: CreateCustomTest; +import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SPACES } from '../lib/spaces'; +import { + createRequest, + expectResponses, + getUrlPrefix, + getTestTitle, +} from '../lib/saved_object_test_utils'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +export interface CreateTestDefinition extends TestDefinition { + request: { type: string; id: string }; + overwrite: boolean; } - -interface CreateTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - tests: CreateTests; +export type CreateTestSuite = TestSuite; +export interface CreateTestCase extends TestCase { + failure?: 400 | 403 | 409; } -const spaceAwareType = 'visualization'; -const notSpaceAwareType = 'globaltype'; +const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake +const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; + +// ID intentionally left blank on NEW_SINGLE_NAMESPACE_OBJ to ensure we can create saved objects without specifying the ID +// we could create six separate test cases to test every permutation, but there's no real value in doing so +const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: '' }); +const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); +const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); +export const TEST_CASES = Object.freeze({ + ...CASES, + NEW_SINGLE_NAMESPACE_OBJ, + NEW_MULTI_NAMESPACE_OBJ, + NEW_NAMESPACE_AGNOSTIC_OBJ, +}); export function createTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { - const createExpectRbacForbidden = (type: string) => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to create ${type}`, - }); - }; - - const expectBadRequestForHiddenType = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - message: "Unsupported saved object type: 'hiddentype': Bad Request", - statusCode: 400, - error: 'Bad Request', - }); - }; - - const createExpectSpaceAwareResults = (spaceId = DEFAULT_SPACE_ID) => async (resp: { - [key: string]: any; - }) => { - expect(resp.body) - .to.have.property('id') - .match(/^[0-9a-f-]{36}$/); - - // loose ISO8601 UTC time with milliseconds validation - expect(resp.body) - .to.have.property('updated_at') - .match(/^[\d-]{10}T[\d:\.]{12}Z$/); - - expect(resp.body).to.eql({ - id: resp.body.id, - migrationVersion: resp.body.migrationVersion, - type: spaceAwareType, - updated_at: resp.body.updated_at, - version: resp.body.version, - attributes: { - title: 'My favorite vis', - }, - references: [], - }); - - const expectedSpacePrefix = spaceId === DEFAULT_SPACE_ID ? '' : `${spaceId}:`; - - // query ES directory to ensure namespace was or wasn't specified - const { _source } = await es.get({ - id: `${expectedSpacePrefix}${spaceAwareType}:${resp.body.id}`, - index: '.kibana', - }); - - const { namespace: actualNamespace } = _source; - - if (spaceId === DEFAULT_SPACE_ID) { - expect(actualNamespace).to.eql(undefined); + const expectForbidden = expectResponses.forbidden('create'); + const expectResponseBody = ( + testCase: CreateTestCase, + spaceId = SPACES.DEFAULT.spaceId + ): ExpectResponseBody => async (response: Record) => { + if (testCase.failure === 403) { + await expectForbidden(testCase.type)(response); } else { - expect(actualNamespace).to.eql(spaceId); + // permitted + const object = response.body; + await expectResponses.permitted(object, testCase); + if (!testCase.failure) { + expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + await expectResponses.successCreated(es, spaceId, object.type, object.id); + } } }; - - const expectNotSpaceAwareRbacForbidden = createExpectRbacForbidden(notSpaceAwareType); - - const expectNotSpaceAwareResults = async (resp: { [key: string]: any }) => { - expect(resp.body) - .to.have.property('id') - .match(/^[0-9a-f-]{36}$/); - - // loose ISO8601 UTC time with milliseconds validation - expect(resp.body) - .to.have.property('updated_at') - .match(/^[\d-]{10}T[\d:\.]{12}Z$/); - - expect(resp.body).to.eql({ - id: resp.body.id, - type: notSpaceAwareType, - updated_at: resp.body.updated_at, - version: resp.body.version, - attributes: { - name: `Can't be contained to a space`, - }, - references: [], - }); - - // query ES directory to ensure namespace wasn't specified - const { _source } = await es.get({ - id: `${notSpaceAwareType}:${resp.body.id}`, - index: '.kibana', - }); - - const { namespace: actualNamespace } = _source; - - expect(actualNamespace).to.eql(undefined); + const createTestDefinitions = ( + testCases: CreateTestCase | CreateTestCase[], + forbidden: boolean, + overwrite: boolean, + options?: { + spaceId?: string; + responseBodyOverride?: ExpectResponseBody; + } + ): CreateTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map(x => ({ ...x, failure: 403 })); + } + return cases.map(x => ({ + title: getTestTitle(x), + responseStatusCode: x.failure ?? 200, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x, options?.spaceId), + overwrite, + })); }; - const expectSpaceAwareRbacForbidden = createExpectRbacForbidden(spaceAwareType); - - const expectHiddenTypeRbacForbidden = createExpectRbacForbidden('hiddentype'); - - const makeCreateTest = (describeFn: DescribeFn) => ( + const makeCreateTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: CreateTestDefinition + definition: CreateTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; + describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`should return ${tests.spaceAware.statusCode} for a space-aware type`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/${spaceAwareType}`) - .auth(user.username, user.password) - .send({ - attributes: { - title: 'My favorite vis', - }, - }) - .expect(tests.spaceAware.statusCode) - .then(tests.spaceAware.response); - }); - - it(`should return ${tests.notSpaceAware.statusCode} for a non space-aware type`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/${notSpaceAwareType}`) - .auth(user.username, user.password) - .send({ - attributes: { - name: `Can't be contained to a space`, - }, - }) - .expect(tests.notSpaceAware.statusCode) - .then(tests.notSpaceAware.response); - }); - - it(`should return ${tests.hiddenType.statusCode} for the hiddentype`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/hiddentype`) - .auth(user.username, user.password) - .send({ - attributes: { - name: `Can't be created via the Saved Objects API`, - }, - }) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response); - }); - if (tests.custom) { - it(tests.custom.description, async () => { + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const { type, id } = test.request; + const path = `${type}${id ? `/${id}` : ''}`; + const requestBody = { attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL } }; + const query = test.overwrite ? '?overwrite=true' : ''; await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/${tests.custom!.type}`) - .auth(user.username, user.password) - .send(tests.custom!.requestBody) - .expect(tests.custom!.statusCode) - .then(tests.custom!.response); + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/${path}${query}`) + .auth(user?.username, user?.password) + .send(requestBody) + .expect(test.responseStatusCode) + .then(test.responseBody); }); } }); }; - const createTest = makeCreateTest(describe); + const addTests = makeCreateTest(describe); // @ts-ignore - createTest.only = makeCreateTest(describe.only); + addTests.only = makeCreateTest(describe.only); return { - createExpectSpaceAwareResults, - createTest, - expectNotSpaceAwareRbacForbidden, - expectNotSpaceAwareResults, - expectSpaceAwareRbacForbidden, - expectBadRequestForHiddenType, - expectHiddenTypeRbacForbidden, + addTests, + createTestDefinitions, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/delete.ts b/x-pack/test/saved_object_api_integration/common/suites/delete.ts index d96ae5446d732..2222aa0c97267 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/delete.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/delete.ts @@ -4,147 +4,97 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface DeleteTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; +import expect from '@kbn/expect/expect.js'; +import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SPACES } from '../lib/spaces'; +import { + createRequest, + expectResponses, + getUrlPrefix, + getTestTitle, +} from '../lib/saved_object_test_utils'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +export interface DeleteTestDefinition extends TestDefinition { + request: { type: string; id: string }; } - -interface DeleteTests { - spaceAware: DeleteTest; - notSpaceAware: DeleteTest; - hiddenType: DeleteTest; - invalidId: DeleteTest; +export type DeleteTestSuite = TestSuite; +export interface DeleteTestCase extends TestCase { + failure?: 403 | 404; } -interface DeleteTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - otherSpaceId?: string; - tests: DeleteTests; -} +const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); +export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); export function deleteTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const createExpectNotFound = (spaceId: string, type: string, id: string) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: `Saved object [${type}/${getIdPrefix(spaceId)}${id}] not found`, - }); - }; - - const createExpectRbacForbidden = (type: string) => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to delete ${type}`, - }); - }; - - const expectGenericNotFound = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: `Not Found`, - }); - }; - - const createExpectSpaceAwareNotFound = (spaceId: string = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - createExpectNotFound(spaceId, 'dashboard', 'be3733a0-9efe-11e7-acb3-3dab96693fab')(resp); - }; - - const createExpectUnknownDocNotFound = (spaceId: string = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - createExpectNotFound(spaceId, 'dashboard', `not-a-real-id`)(resp); + const expectForbidden = expectResponses.forbidden('delete'); + const expectResponseBody = (testCase: DeleteTestCase): ExpectResponseBody => async ( + response: Record + ) => { + if (testCase.failure === 403) { + await expectForbidden(testCase.type)(response); + } else { + // permitted + const object = response.body; + if (testCase.failure) { + await expectResponses.permitted(object, testCase); + } else { + // the success response for `delete` is an empty object + expect(object).to.eql({}); + } + } }; - - const expectEmpty = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({}); + const createTestDefinitions = ( + testCases: DeleteTestCase | DeleteTestCase[], + forbidden: boolean, + options?: { + spaceId?: string; + responseBodyOverride?: ExpectResponseBody; + } + ): DeleteTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map(x => ({ ...x, failure: 403 })); + } + return cases.map(x => ({ + title: getTestTitle(x), + responseStatusCode: x.failure ?? 200, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x), + })); }; - const expectRbacInvalidIdForbidden = createExpectRbacForbidden('dashboard'); - - const expectRbacNotSpaceAwareForbidden = createExpectRbacForbidden('globaltype'); - - const expectRbacSpaceAwareForbidden = createExpectRbacForbidden('dashboard'); - - const expectRbacHiddenTypeForbidden = createExpectRbacForbidden('hiddentype'); - - const makeDeleteTest = (describeFn: DescribeFn) => ( + const makeDeleteTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: DeleteTestDefinition + definition: DeleteTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, otherSpaceId, tests } = definition; + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`should return ${tests.spaceAware.statusCode} when deleting a space-aware doc`, async () => - await supertest - .delete( - `${getUrlPrefix(spaceId)}/api/saved_objects/dashboard/${getIdPrefix( - otherSpaceId || spaceId - )}be3733a0-9efe-11e7-acb3-3dab96693fab` - ) - .auth(user.username, user.password) - .expect(tests.spaceAware.statusCode) - .then(tests.spaceAware.response)); - - it(`should return ${tests.notSpaceAware.statusCode} when deleting a non-space-aware doc`, async () => - await supertest - .delete( - `${getUrlPrefix( - spaceId - )}/api/saved_objects/globaltype/8121a00-8efd-21e7-1cb3-34ab966434445` - ) - .auth(user.username, user.password) - .expect(tests.notSpaceAware.statusCode) - .then(tests.notSpaceAware.response)); - - it(`should return ${tests.hiddenType.statusCode} when deleting a hiddentype doc`, async () => - await supertest - .delete(`${getUrlPrefix(spaceId)}/api/saved_objects/hiddentype/hiddentype_1`) - .auth(user.username, user.password) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response)); - - it(`should return ${tests.invalidId.statusCode} when deleting an unknown doc`, async () => - await supertest - .delete( - `${getUrlPrefix(spaceId)}/api/saved_objects/dashboard/${getIdPrefix( - otherSpaceId || spaceId - )}not-a-real-id` - ) - .auth(user.username, user.password) - .expect(tests.invalidId.statusCode) - .then(tests.invalidId.response)); + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const { type, id } = test.request; + await supertest + .delete(`${getUrlPrefix(spaceId)}/api/saved_objects/${type}/${id}`) + .auth(user?.username, user?.password) + .expect(test.responseStatusCode) + .then(test.responseBody); + }); + } }); }; - const deleteTest = makeDeleteTest(describe); + const addTests = makeDeleteTest(describe); // @ts-ignore - deleteTest.only = makeDeleteTest(describe.only); + addTests.only = makeDeleteTest(describe.only); return { - expectGenericNotFound, - createExpectSpaceAwareNotFound, - createExpectUnknownDocNotFound, - deleteTest, - expectEmpty, - expectRbacInvalidIdForbidden, - expectRbacNotSpaceAwareForbidden, - expectRbacSpaceAwareForbidden, - expectRbacHiddenTypeForbidden, + addTests, + createTestDefinitions, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/export.ts b/x-pack/test/saved_object_api_integration/common/suites/export.ts index e6853096962ec..ddd43d42410ae 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/export.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/export.ts @@ -5,164 +5,191 @@ */ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; +import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SPACES } from '../lib/spaces'; +import { expectResponses, getUrlPrefix } from '../lib/saved_object_test_utils'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; -interface ExportTest { - statusCode: number; - description: string; - response: (resp: { [key: string]: any }) => void; -} +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; -interface ExportTests { - spaceAwareType: ExportTest; - hiddenType: ExportTest; - noTypeOrObjects: ExportTest; +export interface ExportTestDefinition extends TestDefinition { + request: ReturnType; } - -interface ExportTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - tests: ExportTests; +export type ExportTestSuite = TestSuite; +export interface ExportTestCase { + title: string; + type: string; + id?: string; + successResult?: TestCase | TestCase[]; + failure?: 400 | 403; } +export const getTestCases = (spaceId?: string) => ({ + singleNamespaceObject: { + title: 'single-namespace object', + ...(spaceId === SPACE_1_ID + ? CASES.SINGLE_NAMESPACE_SPACE_1 + : spaceId === SPACE_2_ID + ? CASES.SINGLE_NAMESPACE_SPACE_2 + : CASES.SINGLE_NAMESPACE_DEFAULT_SPACE), + } as ExportTestCase, + singleNamespaceType: { + // this test explicitly ensures that single-namespace objects from other spaces are not returned + title: 'single-namespace type', + type: 'isolatedtype', + successResult: + spaceId === SPACE_1_ID + ? CASES.SINGLE_NAMESPACE_SPACE_1 + : spaceId === SPACE_2_ID + ? CASES.SINGLE_NAMESPACE_SPACE_2 + : CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + } as ExportTestCase, + multiNamespaceObject: { + title: 'multi-namespace object', + ...(spaceId === SPACE_1_ID + ? CASES.MULTI_NAMESPACE_ONLY_SPACE_1 + : spaceId === SPACE_2_ID + ? CASES.MULTI_NAMESPACE_ONLY_SPACE_2 + : CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1), + failure: 400, // multi-namespace types cannot be exported yet + } as ExportTestCase, + multiNamespaceType: { + title: 'multi-namespace type', + type: 'sharedtype', + // successResult: + // spaceId === SPACE_1_ID + // ? [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, CASES.MULTI_NAMESPACE_ONLY_SPACE_1] + // : spaceId === SPACE_2_ID + // ? CASES.MULTI_NAMESPACE_ONLY_SPACE_2 + // : CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + failure: 400, // multi-namespace types cannot be exported yet + } as ExportTestCase, + namespaceAgnosticObject: { + title: 'namespace-agnostic object', + ...CASES.NAMESPACE_AGNOSTIC, + } as ExportTestCase, + namespaceAgnosticType: { + title: 'namespace-agnostic type', + type: 'globaltype', + successResult: CASES.NAMESPACE_AGNOSTIC, + } as ExportTestCase, + hiddenObject: { title: 'hidden object', ...CASES.HIDDEN, failure: 400 } as ExportTestCase, + hiddenType: { title: 'hidden type', type: 'hiddentype', failure: 400 } as ExportTestCase, +}); +export const createRequest = ({ type, id }: ExportTestCase) => + id ? { objects: [{ type, id }] } : { type }; +const getTestTitle = ({ failure, title }: ExportTestCase) => { + let description = 'success'; + if (failure === 400) { + description = 'bad request'; + } else if (failure === 403) { + description = 'forbidden'; + } + return `${description} ["${title}"]`; +}; + export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const createExpectRbacForbidden = (type: string) => (resp: { [key: string]: any }) => { - // In export only, the API uses "bulk_get" or "find" depending on the parameters it receives. - // The best that could be done here is to have an if statement to ensure at least one of the - // two errors has been thrown. - if (resp.body.message.indexOf(`bulk_get`) !== -1) { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to bulk_get ${type}`, + const expectForbiddenBulkGet = expectResponses.forbidden('bulk_get'); + const expectForbiddenFind = expectResponses.forbidden('find'); + const expectResponseBody = (testCase: ExportTestCase): ExpectResponseBody => async ( + response: Record + ) => { + const { type, id, successResult = { type, id }, failure } = testCase; + if (failure === 403) { + // In export only, the API uses "bulk_get" or "find" depending on the parameters it receives. + // The best that could be done here is to have an if statement to ensure at least one of the + // two errors has been thrown. + if (id) { + await expectForbiddenBulkGet(type)(response); + } else { + await expectForbiddenFind(type)(response); + } + } else if (failure === 400) { + // 400 + expect(response.body.error).to.eql('Bad Request'); + expect(response.body.statusCode).to.eql(failure); + if (id) { + expect(response.body.message).to.eql( + `Trying to export object(s) with non-exportable types: ${type}:${id}` + ); + } else { + expect(response.body.message).to.eql(`Trying to export non-exportable type(s): ${type}`); + } + } else { + // 2xx + expect(response.body).not.to.have.property('error'); + const ndjson = response.text.split('\n'); + const savedObjectsArray = Array.isArray(successResult) ? successResult : [successResult]; + expect(ndjson.length).to.eql(savedObjectsArray.length + 1); + for (let i = 0; i < savedObjectsArray.length; i++) { + const object = JSON.parse(ndjson[i]); + const { type: expectedType, id: expectedId } = savedObjectsArray[i]; + expect(object.type).to.eql(expectedType); + expect(object.id).to.eql(expectedId); + expect(object.updated_at).to.match(/^[\d-]{10}T[\d:\.]{12}Z$/); + // don't test attributes, version, or references + } + const exportDetails = JSON.parse(ndjson[ndjson.length - 1]); + expect(exportDetails).to.eql({ + exportedCount: ndjson.length - 1, + missingRefCount: 0, + missingReferences: [], }); - return; } - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to find ${type}`, - }); }; - - const expectTypeOrObjectsRequired = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: '[request body]: expected a plain object value, but found [null] instead.', - }); - }; - - const expectInvalidTypeSpecified = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: `Trying to export object(s) with non-exportable types: hiddentype:hiddentype_1`, - }); - }; - - const createExpectVisualizationResults = (spaceId = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - const response = JSON.parse(resp.text); - expect(response).to.eql({ - type: 'visualization', - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - version: response.version, - attributes: response.attributes, - references: [ - { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - id: `${getIdPrefix(spaceId)}91200a00-9efd-11e7-acb3-3dab96693fab`, - }, - ], - migrationVersion: response.migrationVersion, - updated_at: '2017-09-21T18:51:23.794Z', - }); + const createTestDefinitions = ( + testCases: ExportTestCase | ExportTestCase[], + forbidden: boolean, + options?: { + responseBodyOverride?: ExpectResponseBody; + } + ): ExportTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map(x => ({ ...x, failure: 403 })); + } + return cases.map(x => ({ + title: getTestTitle(x), + responseStatusCode: x.failure ?? 200, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x), + })); }; - const makeExportTest = (describeFn: DescribeFn) => ( + const makeExportTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: ExportTestDefinition + definition: ExportTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + const { user, spaceId = DEFAULT_SPACE_ID, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`space aware type should return ${tests.spaceAwareType.statusCode} with ${tests.spaceAwareType.description} when querying by type`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_export`) - .send({ - type: 'visualization', - excludeExportDetails: true, - }) - .auth(user.username, user.password) - .expect(tests.spaceAwareType.statusCode) - .then(tests.spaceAwareType.response); - }); - - it(`space aware type should return ${tests.spaceAwareType.statusCode} with ${tests.spaceAwareType.description} when querying by objects`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_export`) - .send({ - objects: [ - { - type: 'visualization', - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - }, - ], - excludeExportDetails: true, - }) - .auth(user.username, user.password) - .expect(tests.spaceAwareType.statusCode) - .then(tests.spaceAwareType.response); - }); - - describe('hidden type', () => { - it(`should return ${tests.hiddenType.statusCode} with ${tests.hiddenType.description}`, async () => { + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { await supertest .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_export`) - .send({ - objects: [ - { - type: 'hiddentype', - id: `hiddentype_1`, - }, - ], - excludeExportDetails: true, - }) - .auth(user.username, user.password) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response); + .auth(user?.username, user?.password) + .send(test.request) + .expect(test.responseStatusCode) + .then(test.responseBody); }); - }); - - describe('no type or objects', () => { - it(`should return ${tests.noTypeOrObjects.statusCode} with ${tests.noTypeOrObjects.description}`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_export`) - .auth(user.username, user.password) - .expect(tests.noTypeOrObjects.statusCode) - .then(tests.noTypeOrObjects.response); - }); - }); + } }); }; - const exportTest = makeExportTest(describe); + const addTests = makeExportTest(describe); // @ts-ignore - exportTest.only = makeExportTest(describe.only); + addTests.only = makeExportTest(describe.only); return { - createExpectRbacForbidden, - expectTypeOrObjectsRequired, - expectInvalidTypeSpecified, - createExpectVisualizationResults, - exportTest, + addTests, + createTestDefinitions, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/find.ts b/x-pack/test/saved_object_api_integration/common/suites/find.ts index 5479960634ccb..75d6653365fdf 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/find.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/find.ts @@ -3,271 +3,190 @@ * 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 { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface FindTest { - statusCode: number; - description: string; - response: (resp: { [key: string]: any }) => void; +import querystring from 'querystring'; +import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SPACES } from '../lib/spaces'; +import { expectResponses, getUrlPrefix } from '../lib/saved_object_test_utils'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; + +export interface FindTestDefinition extends TestDefinition { + request: { query: string }; } - -interface FindTests { - spaceAwareType: FindTest; - notSpaceAwareType: FindTest; - unknownType: FindTest; - pageBeyondTotal: FindTest; - unknownSearchField: FindTest; - hiddenType: FindTest; - noType: FindTest; - filterWithNotSpaceAwareType: FindTest; - filterWithHiddenType: FindTest; - filterWithUnknownType: FindTest; - filterWithNoType: FindTest; - filterWithUnAllowedType: FindTest; +export type FindTestSuite = TestSuite; +export interface FindTestCase { + title: string; + query: string; + successResult?: { + savedObjects?: TestCase | TestCase[]; + page?: number; + perPage?: number; + total?: number; + }; + failure?: 400 | 403; } -interface FindTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - tests: FindTests; -} +export const getTestCases = (spaceId?: string) => ({ + singleNamespaceType: { + title: 'find single-namespace type', + query: 'type=isolatedtype&fields=title', + successResult: { + savedObjects: + spaceId === SPACE_1_ID + ? CASES.SINGLE_NAMESPACE_SPACE_1 + : spaceId === SPACE_2_ID + ? CASES.SINGLE_NAMESPACE_SPACE_2 + : CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + }, + } as FindTestCase, + multiNamespaceType: { + title: 'find multi-namespace type', + query: 'type=sharedtype&fields=title', + successResult: { + savedObjects: + spaceId === SPACE_1_ID + ? [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, CASES.MULTI_NAMESPACE_ONLY_SPACE_1] + : spaceId === SPACE_2_ID + ? CASES.MULTI_NAMESPACE_ONLY_SPACE_2 + : CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + }, + } as FindTestCase, + namespaceAgnosticType: { + title: 'find namespace-agnostic type', + query: 'type=globaltype&fields=title', + successResult: { savedObjects: CASES.NAMESPACE_AGNOSTIC }, + } as FindTestCase, + hiddenType: { title: 'find hidden type', query: 'type=hiddentype&fields=name' } as FindTestCase, + unknownType: { title: 'find unknown type', query: 'type=wigwags' } as FindTestCase, + pageBeyondTotal: { + title: 'find page beyond total', + query: 'type=isolatedtype&page=100&per_page=100', + successResult: { page: 100, perPage: 100, total: 1, savedObjects: [] }, + } as FindTestCase, + unknownSearchField: { + title: 'find unknown search field', + query: 'type=url&search_fields=a', + } as FindTestCase, + filterWithNamespaceAgnosticType: { + title: 'filter with namespace-agnostic type', + query: 'type=globaltype&filter=globaltype.attributes.title:*global*', + successResult: { savedObjects: CASES.NAMESPACE_AGNOSTIC }, + } as FindTestCase, + filterWithHiddenType: { + title: 'filter with hidden type', + query: `type=hiddentype&fields=name&filter=hiddentype.attributes.title:'hello'`, + } as FindTestCase, + filterWithUnknownType: { + title: 'filter with unknown type', + query: `type=wigwags&filter=wigwags.attributes.title:'unknown'`, + } as FindTestCase, + filterWithDisallowedType: { + title: 'filter with disallowed type', + query: `type=globaltype&filter=dashboard.title:'Requests'`, + failure: 400, + } as FindTestCase, +}); +export const createRequest = ({ query }: FindTestCase) => ({ query }); +const getTestTitle = ({ failure, title }: FindTestCase) => { + let description = 'success'; + if (failure === 400) { + description = 'bad request'; + } else if (failure === 403) { + description = 'forbidden'; + } + return `${description} ["${title}"]`; +}; export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const createExpectEmpty = (page: number, perPage: number, total: number) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - page, - per_page: perPage, - total, - saved_objects: [], - }); - }; - - const createExpectRbacForbidden = (type?: string) => (resp: { [key: string]: any }) => { - const message = type ? `Unable to find ${type}` : `Not authorized to find saved_object`; - - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message, - }); - }; - - const expectNotSpaceAwareResults = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 1, - saved_objects: [ - { - type: 'globaltype', - id: `8121a00-8efd-21e7-1cb3-34ab966434445`, - version: resp.body.saved_objects[0].version, - attributes: { - name: 'My favorite global object', - }, - references: [], - updated_at: '2017-09-21T18:59:16.270Z', - }, - ], - }); - }; - - const expectFilterWrongTypeError = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - error: 'Bad Request', - message: 'This type dashboard is not allowed: Bad Request', - statusCode: 400, - }); - }; - - const expectTypeRequired = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - error: 'Bad Request', - message: '[request query.type]: expected at least one defined value but got [undefined]', - statusCode: 400, - }); + const expectForbidden = expectResponses.forbidden('find'); + const expectResponseBody = (testCase: FindTestCase): ExpectResponseBody => async ( + response: Record + ) => { + const { failure, successResult = {}, query } = testCase; + const parsedQuery = querystring.parse(query); + if (failure === 403) { + const type = parsedQuery.type; + await expectForbidden(type)(response); + } else if (failure === 400) { + const type = (parsedQuery.filter as string).split('.')[0]; + expect(response.body.error).to.eql('Bad Request'); + expect(response.body.statusCode).to.eql(failure); + expect(response.body.message).to.eql(`This type ${type} is not allowed: Bad Request`); + } else { + // 2xx + expect(response.body).not.to.have.property('error'); + const { page = 1, perPage = 20, total, savedObjects = [] } = successResult; + const savedObjectsArray = Array.isArray(savedObjects) ? savedObjects : [savedObjects]; + expect(response.body.page).to.eql(page); + expect(response.body.per_page).to.eql(perPage); + expect(response.body.total).to.eql(total || savedObjectsArray.length); + for (let i = 0; i < savedObjectsArray.length; i++) { + const object = response.body.saved_objects[i]; + const { type: expectedType, id: expectedId } = savedObjectsArray[i]; + expect(object.type).to.eql(expectedType); + expect(object.id).to.eql(expectedId); + expect(object.updated_at).to.match(/^[\d-]{10}T[\d:\.]{12}Z$/); + // don't test attributes, version, or references + } + } }; - - const createExpectVisualizationResults = (spaceId = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 1, - saved_objects: [ - { - type: 'visualization', - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - version: resp.body.saved_objects[0].version, - attributes: { - title: 'Count of requests', - }, - migrationVersion: resp.body.saved_objects[0].migrationVersion, - references: [ - { - id: `${getIdPrefix(spaceId)}91200a00-9efd-11e7-acb3-3dab96693fab`, - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - }, - ], - updated_at: '2017-09-21T18:51:23.794Z', - }, - ], - }); + const createTestDefinitions = ( + testCases: FindTestCase | FindTestCase[], + forbidden: boolean, + options?: { + responseBodyOverride?: ExpectResponseBody; + } + ): FindTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map(x => ({ ...x, failure: 403 })); + } + return cases.map(x => ({ + title: getTestTitle(x), + responseStatusCode: x.failure ?? 200, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x), + })); }; - const makeFindTest = (describeFn: DescribeFn) => ( + const makeFindTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: FindTestDefinition + definition: FindTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + const { user, spaceId = DEFAULT_SPACE_ID, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`space aware type should return ${tests.spaceAwareType.statusCode} with ${tests.spaceAwareType.description}`, async () => - await supertest - .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find?type=visualization&fields=title`) - .auth(user.username, user.password) - .expect(tests.spaceAwareType.statusCode) - .then(tests.spaceAwareType.response)); - - it(`not space aware type should return ${tests.notSpaceAwareType.statusCode} with ${tests.notSpaceAwareType.description}`, async () => - await supertest - .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find?type=globaltype&fields=name`) - .auth(user.username, user.password) - .expect(tests.notSpaceAwareType.statusCode) - .then(tests.notSpaceAwareType.response)); - - it(`finding a hiddentype should return ${tests.hiddenType.statusCode} with ${tests.hiddenType.description}`, async () => - await supertest - .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find?type=hiddentype&fields=name`) - .auth(user.username, user.password) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response)); - - describe('unknown type', () => { - it(`should return ${tests.unknownType.statusCode} with ${tests.unknownType.description}`, async () => - await supertest - .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find?type=wigwags`) - .auth(user.username, user.password) - .expect(tests.unknownType.statusCode) - .then(tests.unknownType.response)); - }); - - describe('page beyond total', () => { - it(`should return ${tests.pageBeyondTotal.statusCode} with ${tests.pageBeyondTotal.description}`, async () => - await supertest - .get( - `${getUrlPrefix( - spaceId - )}/api/saved_objects/_find?type=visualization&page=100&per_page=100` - ) - .auth(user.username, user.password) - .expect(tests.pageBeyondTotal.statusCode) - .then(tests.pageBeyondTotal.response)); - }); - - describe('unknown search field', () => { - it(`should return ${tests.unknownSearchField.statusCode} with ${tests.unknownSearchField.description}`, async () => - await supertest - .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find?type=url&search_fields=a`) - .auth(user.username, user.password) - .expect(tests.unknownSearchField.statusCode) - .then(tests.unknownSearchField.response)); - }); - - describe('no type', () => { - it(`should return ${tests.noType.statusCode} with ${tests.noType.description}`, async () => - await supertest - .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find`) - .auth(user.username, user.password) - .expect(tests.noType.statusCode) - .then(tests.noType.response)); - }); - - describe('filter', () => { - it(`by wrong type should return ${tests.filterWithUnAllowedType.statusCode} with ${tests.filterWithUnAllowedType.description}`, async () => - await supertest - .get( - `${getUrlPrefix( - spaceId - )}/api/saved_objects/_find?type=globaltype&filter=dashboard.title:'Requests'` - ) - .auth(user.username, user.password) - .expect(tests.filterWithUnAllowedType.statusCode) - .then(tests.filterWithUnAllowedType.response)); - - it(`not space aware type should return ${tests.filterWithNotSpaceAwareType.statusCode} with ${tests.filterWithNotSpaceAwareType.description}`, async () => + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const query = test.request.query ? `?${test.request.query}` : ''; await supertest - .get( - `${getUrlPrefix( - spaceId - )}/api/saved_objects/_find?type=globaltype&filter=globaltype.attributes.name:*global*` - ) - .auth(user.username, user.password) - .expect(tests.filterWithNotSpaceAwareType.statusCode) - .then(tests.filterWithNotSpaceAwareType.response)); - - it(`finding a hiddentype should return ${tests.filterWithHiddenType.statusCode} with ${tests.filterWithHiddenType.description}`, async () => - await supertest - .get( - `${getUrlPrefix( - spaceId - )}/api/saved_objects/_find?type=hiddentype&fields=name&filter=hiddentype.attributes.name:'hello'` - ) - .auth(user.username, user.password) - .expect(tests.filterWithHiddenType.statusCode) - .then(tests.filterWithHiddenType.response)); - - describe('unknown type', () => { - it(`should return ${tests.filterWithUnknownType.statusCode} with ${tests.filterWithUnknownType.description}`, async () => - await supertest - .get( - `${getUrlPrefix( - spaceId - )}/api/saved_objects/_find?type=wigwags&filter=wigwags.attributes.title:'unknown'` - ) - .auth(user.username, user.password) - .expect(tests.filterWithUnknownType.statusCode) - .then(tests.filterWithUnknownType.response)); - }); - - describe('no type', () => { - it(`should return ${tests.filterWithNoType.statusCode} with ${tests.filterWithNoType.description}`, async () => - await supertest - .get( - `${getUrlPrefix( - spaceId - )}/api/saved_objects/_find?filter=global.attributes.name:*global*` - ) - .auth(user.username, user.password) - .expect(tests.filterWithNoType.statusCode) - .then(tests.filterWithNoType.response)); + .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find${query}`) + .auth(user?.username, user?.password) + .expect(test.responseStatusCode) + .then(test.responseBody); }); - }); + } }); }; - const findTest = makeFindTest(describe); + const addTests = makeFindTest(describe); // @ts-ignore - findTest.only = makeFindTest(describe.only); + addTests.only = makeFindTest(describe.only); return { - createExpectEmpty, - createExpectRbacForbidden, - createExpectVisualizationResults, - expectFilterWrongTypeError, - expectNotSpaceAwareResults, - expectTypeRequired, - findTest, + addTests, + createTestDefinitions, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/get.ts b/x-pack/test/saved_object_api_integration/common/suites/get.ts index c98209ca1e105..d8fa4d91276d7 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/get.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/get.ts @@ -3,193 +3,89 @@ * 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 { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface GetTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; -} - -interface GetTests { - spaceAware: GetTest; - notSpaceAware: GetTest; - hiddenType: GetTest; - doesntExist: GetTest; +import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SPACES } from '../lib/spaces'; +import { + createRequest, + expectResponses, + getUrlPrefix, + getTestTitle, +} from '../lib/saved_object_test_utils'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +export interface GetTestDefinition extends TestDefinition { + request: { type: string; id: string }; } +export type GetTestSuite = TestSuite; +export type GetTestCase = TestCase; -interface GetTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - otherSpaceId?: string; - tests: GetTests; -} - -const spaceAwareId = 'dd7caf20-9efd-11e7-acb3-3dab96693fab'; -const notSpaceAwareId = '8121a00-8efd-21e7-1cb3-34ab966434445'; -const doesntExistId = 'foobar'; +const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); +export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); export function getTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const createExpectDoesntExistNotFound = (spaceId = DEFAULT_SPACE_ID) => { - return createExpectNotFound('visualization', doesntExistId, spaceId); - }; - - const createExpectNotFound = (type: string, id: string, spaceId = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - error: 'Not Found', - message: `Saved object [${type}/${getIdPrefix(spaceId)}${id}] not found`, - statusCode: 404, - }); - }; - - const expectHiddenTypeNotFound = createExpectNotFound( - 'hiddentype', - 'hiddentype_1', - DEFAULT_SPACE_ID - ); - - const createExpectNotSpaceAwareRbacForbidden = () => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - error: 'Forbidden', - message: `Unable to get globaltype`, - statusCode: 403, - }); - }; - - const createExpectNotSpaceAwareResults = (spaceId = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - id: `${notSpaceAwareId}`, - type: 'globaltype', - updated_at: '2017-09-21T18:59:16.270Z', - version: resp.body.version, - attributes: { - name: 'My favorite global object', - }, - references: [], - }); - }; - - const createExpectRbacForbidden = (type: string) => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - error: 'Forbidden', - message: `Unable to get ${type}`, - statusCode: 403, - }); + const expectForbidden = expectResponses.forbidden('get'); + const expectResponseBody = (testCase: GetTestCase): ExpectResponseBody => async ( + response: Record + ) => { + if (testCase.failure === 403) { + await expectForbidden(testCase.type)(response); + } else { + // permitted + const object = response.body; + await expectResponses.permitted(object, testCase); + } }; - - const createExpectSpaceAwareNotFound = (spaceId = DEFAULT_SPACE_ID) => { - return createExpectNotFound('visualization', spaceAwareId, spaceId); + const createTestDefinitions = ( + testCases: GetTestCase | GetTestCase[], + forbidden: boolean, + options?: { + spaceId?: string; + responseBodyOverride?: ExpectResponseBody; + } + ): GetTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map(x => ({ ...x, failure: 403 })); + } + return cases.map(x => ({ + title: getTestTitle(x), + responseStatusCode: x.failure ?? 200, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x), + })); }; - const expectSpaceAwareRbacForbidden = createExpectRbacForbidden('visualization'); - const expectNotSpaceAwareRbacForbidden = createExpectRbacForbidden('globaltype'); - const expectHiddenTypeRbacForbidden = createExpectRbacForbidden('hiddentype'); - const expectDoesntExistRbacForbidden = createExpectRbacForbidden('visualization'); - - const createExpectSpaceAwareResults = (spaceId = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - type: 'visualization', - migrationVersion: resp.body.migrationVersion, - updated_at: '2017-09-21T18:51:23.794Z', - version: resp.body.version, - attributes: { - title: 'Count of requests', - description: '', - version: 1, - // cheat for some of the more complex attributes - visState: resp.body.attributes.visState, - uiStateJSON: resp.body.attributes.uiStateJSON, - kibanaSavedObjectMeta: resp.body.attributes.kibanaSavedObjectMeta, - }, - references: [ - { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - id: `${getIdPrefix(spaceId)}91200a00-9efd-11e7-acb3-3dab96693fab`, - }, - ], - }); - }; - - const makeGetTest = (describeFn: DescribeFn) => ( + const makeGetTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: GetTestDefinition + definition: GetTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, otherSpaceId, tests } = definition; + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`should return ${tests.spaceAware.statusCode} when getting a space aware doc`, async () => { - await supertest - .get( - `${getUrlPrefix(spaceId)}/api/saved_objects/visualization/${getIdPrefix( - otherSpaceId || spaceId - )}${spaceAwareId}` - ) - .auth(user.username, user.password) - .expect(tests.spaceAware.statusCode) - .then(tests.spaceAware.response); - }); - - it(`should return ${tests.notSpaceAware.statusCode} when getting a non-space-aware doc`, async () => { - await supertest - .get(`${getUrlPrefix(spaceId)}/api/saved_objects/globaltype/${notSpaceAwareId}`) - .auth(user.username, user.password) - .expect(tests.notSpaceAware.statusCode) - .then(tests.notSpaceAware.response); - }); - - it(`should return ${tests.hiddenType.statusCode} when getting a hiddentype doc`, async () => { - await supertest - .get(`${getUrlPrefix(spaceId)}/api/saved_objects/hiddentype/hiddentype_1`) - .auth(user.username, user.password) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response); - }); - - describe('document does not exist', () => { - it(`should return ${tests.doesntExist.statusCode}`, async () => { + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const { type, id } = test.request; await supertest - .get( - `${getUrlPrefix(spaceId)}/api/saved_objects/visualization/${getIdPrefix( - otherSpaceId || spaceId - )}${doesntExistId}` - ) - .auth(user.username, user.password) - .expect(tests.doesntExist.statusCode) - .then(tests.doesntExist.response); + .get(`${getUrlPrefix(spaceId)}/api/saved_objects/${type}/${id}`) + .auth(user?.username, user?.password) + .expect(test.responseStatusCode) + .then(test.responseBody); }); - }); + } }); }; - const getTest = makeGetTest(describe); + const addTests = makeGetTest(describe); // @ts-ignore - getTest.only = makeGetTest(describe.only); + addTests.only = makeGetTest(describe.only); return { - createExpectDoesntExistNotFound, - createExpectNotSpaceAwareRbacForbidden, - createExpectNotSpaceAwareResults, - createExpectSpaceAwareNotFound, - createExpectSpaceAwareResults, - expectHiddenTypeNotFound, - expectSpaceAwareRbacForbidden, - expectNotSpaceAwareRbacForbidden, - expectDoesntExistRbacForbidden, - expectHiddenTypeRbacForbidden, - getTest, + addTests, + createTestDefinitions, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/import.ts b/x-pack/test/saved_object_api_integration/common/suites/import.ts index f6723c912f82e..2f631221c6955 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/import.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/import.ts @@ -6,195 +6,152 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface ImportTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; +import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SPACES } from '../lib/spaces'; +import { + createRequest, + expectResponses, + getUrlPrefix, + getTestTitle, +} from '../lib/saved_object_test_utils'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +export interface ImportTestDefinition extends TestDefinition { + request: Array<{ type: string; id: string }>; } - -interface ImportTests { - default: ImportTest; - hiddenType: ImportTest; - unknownType: ImportTest; +export type ImportTestSuite = TestSuite; +export interface ImportTestCase extends TestCase { + failure?: 400 | 409; // only used for permitted response case } -interface ImportTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - tests: ImportTests; -} +const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake +const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; -const createImportData = (spaceId: string) => [ - { - type: 'dashboard', - id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, - attributes: { - title: 'A great new dashboard', - }, - }, - { - type: 'globaltype', - id: '05976c65-1145-4858-bbf0-d225cc78a06e', - attributes: { - name: 'A new globaltype object', - }, - }, -]; +const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' }); +const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); +const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); +export const TEST_CASES = Object.freeze({ + ...CASES, + NEW_SINGLE_NAMESPACE_OBJ, + NEW_MULTI_NAMESPACE_OBJ, + NEW_NAMESPACE_AGNOSTIC_OBJ, +}); export function importTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { - const createExpectResults = (spaceId = DEFAULT_SPACE_ID) => async (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - success: true, - successCount: 2, - }); - }; - - const expectResultsWithUnsupportedHiddenType = async (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - success: false, - successCount: 2, - errors: [ - { - error: { - type: 'unsupported_type', - }, - id: '1', - type: 'hiddentype', - }, - ], - }); + const expectForbidden = expectResponses.forbidden('bulk_create'); + const expectResponseBody = ( + testCases: ImportTestCase | ImportTestCase[], + statusCode: 200 | 403, + spaceId = SPACES.DEFAULT.spaceId + ): ExpectResponseBody => async (response: Record) => { + const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; + if (statusCode === 403) { + const types = testCaseArray.map(x => x.type); + await expectForbidden(types)(response); + } else { + // permitted + const { success, successCount, errors } = response.body; + const expectedSuccesses = testCaseArray.filter(x => !x.failure); + const expectedFailures = testCaseArray.filter(x => x.failure); + expect(success).to.eql(expectedFailures.length === 0); + expect(successCount).to.eql(expectedSuccesses.length); + if (expectedFailures.length) { + expect(errors).to.have.length(expectedFailures.length); + } else { + expect(response.body).not.to.have.property('errors'); + } + for (let i = 0; i < expectedSuccesses.length; i++) { + const { type, id } = expectedSuccesses[i]; + const { _source } = await expectResponses.successCreated(es, spaceId, type, id); + expect(_source[type][NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + } + for (let i = 0; i < expectedFailures.length; i++) { + const { type, id, failure } = expectedFailures[i]; + // we don't know the order of the returned errors; search for each one + const object = (errors as Array>).find( + x => x.type === type && x.id === id + ); + expect(object).not.to.be(undefined); + if (failure === 400) { + expect(object!.error).to.eql({ type: 'unsupported_type' }); + } else { + // 409 + expect(object!.error).to.eql({ type: 'conflict' }); + } + } + } }; - - const expectUnknownTypeUnsupported = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - success: false, - successCount: 2, - errors: [ - { - id: '1', - type: 'wigwags', - title: 'Wigwags title', - error: { - type: 'unsupported_type', - }, - }, - ], - }); + const createTestDefinitions = ( + testCases: ImportTestCase | ImportTestCase[], + forbidden: boolean, + options?: { + spaceId?: string; + singleRequest?: boolean; + responseBodyOverride?: ExpectResponseBody; + } + ): ImportTestDefinition[] => { + const cases = Array.isArray(testCases) ? testCases : [testCases]; + const responseStatusCode = forbidden ? 403 : 200; + if (!options?.singleRequest) { + // if we are testing cases that should result in a forbidden response, we can do each case individually + // this ensures that multiple test cases of a single type will each result in a forbidden error + return cases.map(x => ({ + title: getTestTitle(x, responseStatusCode), + request: [createRequest(x)], + responseStatusCode, + responseBody: + options?.responseBodyOverride || + expectResponseBody(x, responseStatusCode, options?.spaceId), + })); + } + // batch into a single request to save time during test execution + return [ + { + title: getTestTitle(cases, responseStatusCode), + request: cases.map(x => createRequest(x)), + responseStatusCode, + responseBody: + options?.responseBodyOverride || + expectResponseBody(cases, responseStatusCode, options?.spaceId), + }, + ]; }; - const expectHiddenTypeUnsupported = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - success: false, - successCount: 2, - errors: [ - { - id: '1', - type: 'hiddentype', - error: { - type: 'unsupported_type', - }, - }, - ], - }); - }; - - const expectRbacForbidden = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to bulk_create dashboard,globaltype`, - }); - }; - - const makeImportTest = (describeFn: DescribeFn) => ( + const makeImportTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: ImportTestDefinition + definition: ImportTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`should return ${tests.default.statusCode}`, async () => { - const data = createImportData(spaceId); - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_import`) - .auth(user.username, user.password) - .attach( - 'file', - Buffer.from(data.map(obj => JSON.stringify(obj)).join('\n'), 'utf8'), - 'export.ndjson' - ) - .expect(tests.default.statusCode) - .then(tests.default.response); - }); - - describe('hiddentype', () => { - it(`should return ${tests.hiddenType.statusCode}`, async () => { - const data = createImportData(spaceId); - data.push({ - type: 'hiddentype', - id: '1', - attributes: { - name: 'My Hidden Type', - }, - }); - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_import`) - .query({ overwrite: true }) - .auth(user.username, user.password) - .attach( - 'file', - Buffer.from(data.map(obj => JSON.stringify(obj)).join('\n'), 'utf8'), - 'export.ndjson' - ) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response); - }); - }); + const attrs = { attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL } }; - describe('unknown type', () => { - it(`should return ${tests.unknownType.statusCode}`, async () => { - const data = createImportData(spaceId); - data.push({ - type: 'wigwags', - id: '1', - attributes: { - title: 'Wigwags title', - }, - }); + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const requestBody = test.request + .map(obj => JSON.stringify({ ...obj, ...attrs })) + .join('\n'); await supertest .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_import`) - .query({ overwrite: true }) - .auth(user.username, user.password) - .attach( - 'file', - Buffer.from(data.map(obj => JSON.stringify(obj)).join('\n'), 'utf8'), - 'export.ndjson' - ) - .expect(tests.unknownType.statusCode) - .then(tests.unknownType.response); + .auth(user?.username, user?.password) + .attach('file', Buffer.from(requestBody, 'utf8'), 'export.ndjson') + .expect(test.responseStatusCode) + .then(test.responseBody); }); - }); + } }); }; - const importTest = makeImportTest(describe); + const addTests = makeImportTest(describe); // @ts-ignore - importTest.only = makeImportTest(describe.only); + addTests.only = makeImportTest(describe.only); return { - importTest, - createExpectResults, - expectResultsWithUnsupportedHiddenType, - expectRbacForbidden, - expectUnknownTypeUnsupported, - expectHiddenTypeUnsupported, + addTests, + createTestDefinitions, + expectForbidden, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts index 1b538b9b1b65d..47c4babc5fcf9 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts @@ -6,219 +6,165 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; +import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SPACES } from '../lib/spaces'; +import { + createRequest, + expectResponses, + getUrlPrefix, + getTestTitle, +} from '../lib/saved_object_test_utils'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; -interface ResolveImportErrorsTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; +export interface ResolveImportErrorsTestDefinition extends TestDefinition { + request: Array<{ type: string; id: string }>; + overwrite: boolean; } - -interface ResolveImportErrorsTests { - default: ResolveImportErrorsTest; - hiddenType: ResolveImportErrorsTest; - unknownType: ResolveImportErrorsTest; +export type ResolveImportErrorsTestSuite = TestSuite; +export interface ResolveImportErrorsTestCase extends TestCase { + failure?: 400 | 409; // only used for permitted response case } -interface ResolveImportErrorsTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - tests: ResolveImportErrorsTests; -} +const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake +const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; -const createImportData = (spaceId: string) => [ - { - type: 'dashboard', - id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, - attributes: { - title: 'A great new dashboard', - }, - }, - { - type: 'globaltype', - id: '05976c65-1145-4858-bbf0-d225cc78a06e', - attributes: { - name: 'A new globaltype object', - }, - }, -]; +const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' }); +const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); +const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); +export const TEST_CASES = Object.freeze({ + ...CASES, + NEW_SINGLE_NAMESPACE_OBJ, + NEW_MULTI_NAMESPACE_OBJ, + NEW_NAMESPACE_AGNOSTIC_OBJ, +}); export function resolveImportErrorsTestSuiteFactory( es: any, esArchiver: any, supertest: SuperTest ) { - const createExpectResults = (spaceId = DEFAULT_SPACE_ID) => async (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - success: true, - successCount: 1, - }); - }; - - const expectUnknownTypeUnsupported = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - success: false, - successCount: 1, - errors: [ - { - id: '1', - type: 'wigwags', - title: 'Wigwags title', - error: { - type: 'unsupported_type', - }, - }, - ], - }); - }; - - const expectHiddenTypeUnsupported = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - success: false, - successCount: 1, - errors: [ - { - id: '1', - type: 'hiddentype', - error: { - type: 'unsupported_type', - }, - }, - ], - }); + const expectForbidden = expectResponses.forbidden('bulk_create'); + const expectResponseBody = ( + testCases: ResolveImportErrorsTestCase | ResolveImportErrorsTestCase[], + statusCode: 200 | 403, + spaceId = SPACES.DEFAULT.spaceId + ): ExpectResponseBody => async (response: Record) => { + const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; + if (statusCode === 403) { + const types = testCaseArray.map(x => x.type); + await expectForbidden(types)(response); + } else { + // permitted + const { success, successCount, errors } = response.body; + const expectedSuccesses = testCaseArray.filter(x => !x.failure); + const expectedFailures = testCaseArray.filter(x => x.failure); + expect(success).to.eql(expectedFailures.length === 0); + expect(successCount).to.eql(expectedSuccesses.length); + if (expectedFailures.length) { + expect(errors).to.have.length(expectedFailures.length); + } else { + expect(response.body).not.to.have.property('errors'); + } + for (let i = 0; i < expectedSuccesses.length; i++) { + const { type, id } = expectedSuccesses[i]; + const { _source } = await expectResponses.successCreated(es, spaceId, type, id); + expect(_source[type][NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + } + for (let i = 0; i < expectedFailures.length; i++) { + const { type, id, failure } = expectedFailures[i]; + // we don't know the order of the returned errors; search for each one + const object = (errors as Array>).find( + x => x.type === type && x.id === id + ); + expect(object).not.to.be(undefined); + if (failure === 400) { + expect(object!.error).to.eql({ type: 'unsupported_type' }); + } else { + // 409 + expect(object!.error).to.eql({ type: 'conflict' }); + } + } + } }; - - const expectRbacForbidden = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to bulk_create dashboard`, - }); + const createTestDefinitions = ( + testCases: ResolveImportErrorsTestCase | ResolveImportErrorsTestCase[], + forbidden: boolean, + overwrite: boolean, + options?: { + spaceId?: string; + singleRequest?: boolean; + responseBodyOverride?: ExpectResponseBody; + } + ): ResolveImportErrorsTestDefinition[] => { + const cases = Array.isArray(testCases) ? testCases : [testCases]; + const responseStatusCode = forbidden ? 403 : 200; + if (!options?.singleRequest) { + // if we are testing cases that should result in a forbidden response, we can do each case individually + // this ensures that multiple test cases of a single type will each result in a forbidden error + return cases.map(x => ({ + title: getTestTitle(x, responseStatusCode), + request: [createRequest(x)], + responseStatusCode, + responseBody: + options?.responseBodyOverride || + expectResponseBody(x, responseStatusCode, options?.spaceId), + overwrite, + })); + } + // batch into a single request to save time during test execution + return [ + { + title: getTestTitle(cases, responseStatusCode), + request: cases.map(x => createRequest(x)), + responseStatusCode, + responseBody: + options?.responseBodyOverride || + expectResponseBody(cases, responseStatusCode, options?.spaceId), + overwrite, + }, + ]; }; - const makeResolveImportErrorsTest = (describeFn: DescribeFn) => ( + const makeResolveImportErrorsTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: ResolveImportErrorsTestDefinition + definition: ResolveImportErrorsTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`should return ${tests.default.statusCode}`, async () => { - const data = createImportData(spaceId); - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_resolve_import_errors`) - .auth(user.username, user.password) - .field( - 'retries', - JSON.stringify([ - { - type: 'dashboard', - id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, - overwrite: true, - }, - ]) - ) - .attach( - 'file', - Buffer.from(data.map(obj => JSON.stringify(obj)).join('\n'), 'utf8'), - 'export.ndjson' - ) - .expect(tests.default.statusCode) - .then(tests.default.response); - }); + const attrs = { attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL } }; - describe('unknown type', () => { - it(`should return ${tests.unknownType.statusCode}`, async () => { - const data = createImportData(spaceId); - data.push({ - type: 'wigwags', - id: '1', - attributes: { - title: 'Wigwags title', - }, - }); - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_resolve_import_errors`) - .auth(user.username, user.password) - .field( - 'retries', - JSON.stringify([ - { - type: 'wigwags', - id: '1', - overwrite: true, - }, - { - type: 'dashboard', - id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, - overwrite: true, - }, - ]) - ) - .attach( - 'file', - Buffer.from(data.map(obj => JSON.stringify(obj)).join('\n'), 'utf8'), - 'export.ndjson' - ) - .expect(tests.unknownType.statusCode) - .then(tests.unknownType.response); - }); - }); - describe('hidden type', () => { - it(`should return ${tests.hiddenType.statusCode}`, async () => { - const data = createImportData(spaceId); - data.push({ - type: 'hiddentype', - id: '1', - attributes: { - name: 'My Hidden Type', - }, - }); + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const retryAttrs = test.overwrite ? { overwrite: true } : {}; + const retries = JSON.stringify( + test.request.map(({ type, id }) => ({ type, id, ...retryAttrs })) + ); + const requestBody = test.request + .map(obj => JSON.stringify({ ...obj, ...attrs })) + .join('\n'); await supertest .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_resolve_import_errors`) - .auth(user.username, user.password) - .field( - 'retries', - JSON.stringify([ - { - type: 'hiddentype', - id: '1', - overwrite: true, - }, - { - type: 'dashboard', - id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, - overwrite: true, - }, - ]) - ) - .attach( - 'file', - Buffer.from(data.map(obj => JSON.stringify(obj)).join('\n'), 'utf8'), - 'export.ndjson' - ) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response); + .auth(user?.username, user?.password) + .field('retries', retries) + .attach('file', Buffer.from(requestBody, 'utf8'), 'export.ndjson') + .expect(test.responseStatusCode) + .then(test.responseBody); }); - }); + } }); }; - const resolveImportErrorsTest = makeResolveImportErrorsTest(describe); + const addTests = makeResolveImportErrorsTest(describe); // @ts-ignore - resolveImportErrorsTest.only = makeResolveImportErrorsTest(describe.only); + addTests.only = makeResolveImportErrorsTest(describe.only); return { - resolveImportErrorsTest, - createExpectResults, - expectRbacForbidden, - expectUnknownTypeUnsupported, - expectHiddenTypeUnsupported, + addTests, + createTestDefinitions, + expectForbidden, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/update.ts b/x-pack/test/saved_object_api_integration/common/suites/update.ts index d6b7602c0114a..587e8cf320a4f 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/update.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/update.ts @@ -6,205 +6,97 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface UpdateTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; -} - -interface UpdateTests { - spaceAware: UpdateTest; - notSpaceAware: UpdateTest; - hiddenType: UpdateTest; - doesntExist: UpdateTest; +import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SPACES } from '../lib/spaces'; +import { + createRequest, + expectResponses, + getUrlPrefix, + getTestTitle, +} from '../lib/saved_object_test_utils'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +export interface UpdateTestDefinition extends TestDefinition { + request: { type: string; id: string }; } - -interface UpdateTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - otherSpaceId?: string; - tests: UpdateTests; +export type UpdateTestSuite = TestSuite; +export interface UpdateTestCase extends TestCase { + failure?: 403 | 404; } -export function updateTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const createExpectNotFound = (type: string, id: string, spaceId = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).eql({ - statusCode: 404, - error: 'Not Found', - message: `Saved object [${type}/${getIdPrefix(spaceId)}${id}] not found`, - }); - }; - - const createExpectDoesntExistNotFound = (spaceId?: string) => { - return createExpectNotFound('visualization', 'not an id', spaceId); - }; - - const createExpectSpaceAwareNotFound = (spaceId?: string) => { - return createExpectNotFound('visualization', 'dd7caf20-9efd-11e7-acb3-3dab96693fab', spaceId); - }; - - const expectHiddenTypeNotFound = createExpectNotFound( - 'hiddentype', - 'hiddentype_1', - DEFAULT_SPACE_ID - ); - - const createExpectRbacForbidden = (type: string) => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to update ${type}`, - }); - }; +const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake +const NEW_ATTRIBUTE_VAL = `Updated attribute value ${Date.now()}`; - const expectDoesntExistRbacForbidden = createExpectRbacForbidden('visualization'); +const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); +export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); - const expectNotSpaceAwareRbacForbidden = createExpectRbacForbidden('globaltype'); - - const expectHiddenTypeRbacForbidden = createExpectRbacForbidden('hiddentype'); - - const expectNotSpaceAwareResults = (resp: { [key: string]: any }) => { - // loose uuid validation - expect(resp.body) - .to.have.property('id') - .match(/^[0-9a-f-]{36}$/); - - // loose ISO8601 UTC time with milliseconds validation - expect(resp.body) - .to.have.property('updated_at') - .match(/^[\d-]{10}T[\d:\.]{12}Z$/); - - expect(resp.body).to.eql({ - id: resp.body.id, - type: 'globaltype', - updated_at: resp.body.updated_at, - version: resp.body.version, - attributes: { - name: 'My second favorite', - }, - }); +export function updateTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const expectForbidden = expectResponses.forbidden('update'); + const expectResponseBody = (testCase: UpdateTestCase): ExpectResponseBody => async ( + response: Record + ) => { + if (testCase.failure === 403) { + await expectForbidden(testCase.type)(response); + } else { + // permitted + const object = response.body; + await expectResponses.permitted(object, testCase); + if (!testCase.failure) { + expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + } + } }; - - const expectSpaceAwareRbacForbidden = createExpectRbacForbidden('visualization'); - - const expectSpaceAwareResults = (resp: { [key: string]: any }) => { - // loose uuid validation ignoring prefix - expect(resp.body) - .to.have.property('id') - .match(/[0-9a-f-]{36}$/); - - // loose ISO8601 UTC time with milliseconds validation - expect(resp.body) - .to.have.property('updated_at') - .match(/^[\d-]{10}T[\d:\.]{12}Z$/); - - expect(resp.body).to.eql({ - id: resp.body.id, - type: 'visualization', - updated_at: resp.body.updated_at, - version: resp.body.version, - attributes: { - title: 'My second favorite vis', - }, - }); + const createTestDefinitions = ( + testCases: UpdateTestCase | UpdateTestCase[], + forbidden: boolean, + options?: { + responseBodyOverride?: ExpectResponseBody; + } + ): UpdateTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map(x => ({ ...x, failure: 403 })); + } + return cases.map(x => ({ + title: getTestTitle(x), + responseStatusCode: x.failure ?? 200, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x), + })); }; - const makeUpdateTest = (describeFn: DescribeFn) => ( + const makeUpdateTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: UpdateTestDefinition + definition: UpdateTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, otherSpaceId, tests } = definition; + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`should return ${tests.spaceAware.statusCode} for a space-aware doc`, async () => { - await supertest - .put( - `${getUrlPrefix(spaceId)}/api/saved_objects/visualization/${getIdPrefix( - otherSpaceId || spaceId - )}dd7caf20-9efd-11e7-acb3-3dab96693fab` - ) - .auth(user.username, user.password) - .send({ - attributes: { - title: 'My second favorite vis', - }, - }) - .expect(tests.spaceAware.statusCode) - .then(tests.spaceAware.response); - }); - - it(`should return ${tests.notSpaceAware.statusCode} for a non space-aware doc`, async () => { - await supertest - .put( - `${getUrlPrefix( - otherSpaceId || spaceId - )}/api/saved_objects/globaltype/8121a00-8efd-21e7-1cb3-34ab966434445` - ) - .auth(user.username, user.password) - .send({ - attributes: { - name: 'My second favorite', - }, - }) - .expect(tests.notSpaceAware.statusCode) - .then(tests.notSpaceAware.response); - }); - - it(`should return ${tests.hiddenType.statusCode} for hiddentype doc`, async () => { - await supertest - .put(`${getUrlPrefix(otherSpaceId || spaceId)}/api/saved_objects/hiddentype/hiddentype_1`) - .auth(user.username, user.password) - .send({ - attributes: { - name: 'My favorite hidden type', - }, - }) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response); - }); - describe('unknown id', () => { - it(`should return ${tests.doesntExist.statusCode}`, async () => { + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const { type, id } = test.request; + const requestBody = { attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL } }; await supertest - .put( - `${getUrlPrefix(spaceId)}/api/saved_objects/visualization/${getIdPrefix( - spaceId - )}not an id` - ) - .auth(user.username, user.password) - .send({ - attributes: { - title: 'My second favorite vis', - }, - }) - .expect(tests.doesntExist.statusCode) - .then(tests.doesntExist.response); + .put(`${getUrlPrefix(spaceId)}/api/saved_objects/${type}/${id}`) + .auth(user?.username, user?.password) + .send(requestBody) + .expect(test.responseStatusCode) + .then(test.responseBody); }); - }); + } }); }; - const updateTest = makeUpdateTest(describe); + const addTests = makeUpdateTest(describe); // @ts-ignore - updateTest.only = makeUpdateTest(describe.only); + addTests.only = makeUpdateTest(describe.only); return { - createExpectDoesntExistNotFound, - createExpectSpaceAwareNotFound, - expectSpaceNotFound: expectHiddenTypeNotFound, - expectDoesntExistRbacForbidden, - expectNotSpaceAwareRbacForbidden, - expectNotSpaceAwareResults, - expectSpaceAwareRbacForbidden, - expectSpaceAwareResults, - expectHiddenTypeRbacForbidden, - updateTest, + addTests, + createTestDefinitions, }; } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts index 7768665f3b941..70d3afbfc9af3 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts @@ -4,206 +4,104 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkCreateTestSuiteFactory } from '../../common/suites/bulk_create'; +import { + bulkCreateTestSuiteFactory, + TEST_CASES as CASES, + BulkCreateTestDefinition, +} from '../../common/suites/bulk_create'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (overwrite: boolean, spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { + ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_MULTI_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - bulkCreateTest, - createExpectResults, - createExpectRbacForbidden, - expectBadRequestForHiddenType, - expectedForbiddenTypesWithHiddenType, - } = bulkCreateTestSuiteFactory(es, esArchiver, supertest); + const { addTests, createTestDefinitions, expectForbidden } = bulkCreateTestSuiteFactory( + es, + esArchiver, + supertest + ); + const createTests = (overwrite: boolean, spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite, spaceId); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId }), + authorized: [ + createTestDefinitions(normalTypes, false, overwrite, { spaceId, singleRequest: true }), + createTestDefinitions(hiddenType, true, overwrite, { spaceId }), + createTestDefinitions(allTypes, true, overwrite, { + spaceId, + singleRequest: true, + responseBodyOverride: expectForbidden(['hiddentype']), + }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, overwrite, { + spaceId, + singleRequest: true, + }), + }; + }; describe('_bulk_create', () => { - [ - { - spaceId: SPACES.DEFAULT.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - }, - }, - { - spaceId: SPACES.SPACE_1.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - }, - }, - ].forEach(scenario => { - bulkCreateTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingSpace: { - statusCode: 200, - response: expectBadRequestForHiddenType, - }, - }, - }); - - bulkCreateTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkCreateTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkCreateTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkCreateTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); + getTestScenarios([false, true]).securityAndSpaces.forEach( + ({ spaceId, users, modifier: overwrite }) => { + const suffix = ` within the ${spaceId} space${overwrite ? ' with overwrite enabled' : ''}`; + const { unauthorized, authorized, superuser } = createTests(overwrite!, spaceId); + const _addTests = (user: TestUser, tests: BulkCreateTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - bulkCreateTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - }); + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally, users.allAtSpace].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); + } + ); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts index ec5bce1707569..09ea867bff371 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts @@ -4,205 +4,91 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkGetTestSuiteFactory } from '../../common/suites/bulk_get'; +import { + bulkGetTestSuiteFactory, + TEST_CASES as CASES, + BulkGetTestDefinition, +} from '../../common/suites/bulk_get'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - const { - bulkGetTest, - createExpectResults, - createExpectRbacForbidden, - expectBadRequestForHiddenType, - expectedForbiddenTypesWithHiddenType, - } = bulkGetTestSuiteFactory(esArchiver, supertest); + const { addTests, createTestDefinitions, expectForbidden } = bulkGetTestSuiteFactory( + esArchiver, + supertest + ); + const createTests = (spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false, { singleRequest: true }), + createTestDefinitions(hiddenType, true), + createTestDefinitions(allTypes, true, { + singleRequest: true, + responseBodyOverride: expectForbidden(['hiddentype']), + }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, { singleRequest: true }), + }; + }; describe('_bulk_get', () => { - [ - { - spaceId: SPACES.DEFAULT.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - }, - }, - { - spaceId: SPACES.SPACE_1.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - }, - }, - ].forEach(scenario => { - bulkGetTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkGetTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingHiddenType: { - statusCode: 200, - response: expectBadRequestForHiddenType, - }, - }, - }); - - bulkGetTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkGetTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized, superuser } = createTests(spaceId); + const _addTests = (user: TestUser, tests: BulkGetTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - bulkGetTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, + [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach(user => { + _addTests(user, unauthorized); }); - - bulkGetTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkGetTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkGetTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkGetTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkGetTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, + [ + users.dualAll, + users.dualRead, + users.allGlobally, + users.readGlobally, + users.allAtSpace, + users.readAtSpace, + ].forEach(user => { + _addTests(user, authorized); }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts index 06240647b37a8..987209653b347 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts @@ -4,290 +4,91 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkUpdateTestSuiteFactory } from '../../common/suites/bulk_update'; +import { + bulkUpdateTestSuiteFactory, + TEST_CASES as CASES, + BulkUpdateTestDefinition, +} from '../../common/suites/bulk_update'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('bulkUpdate', () => { - const { - createExpectDoesntExistNotFound, - expectDoesntExistRbacForbidden, - expectNotSpaceAwareResults, - expectNotSpaceAwareRbacForbidden, - expectSpaceAwareRbacForbidden, - expectSpaceAwareResults, - expectSpaceNotFound, - expectHiddenTypeRbacForbidden, - expectHiddenTypeRbacForbiddenWithGlobalAllowed, - bulkUpdateTest, - } = bulkUpdateTestSuiteFactory(esArchiver, supertest); - - [ - { - spaceId: SPACES.DEFAULT.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - }, - }, - { - spaceId: SPACES.SPACE_1.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - }, - }, - ].forEach(scenario => { - bulkUpdateTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - bulkUpdateTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 200, - response: expectSpaceNotFound, - }, - doesntExist: { - statusCode: 200, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); - - bulkUpdateTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - bulkUpdateTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbiddenWithGlobalAllowed, - }, - doesntExist: { - statusCode: 200, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); + const { addTests, createTestDefinitions, expectForbidden } = bulkUpdateTestSuiteFactory( + esArchiver, + supertest + ); + const createTests = (spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false, { singleRequest: true }), + createTestDefinitions(hiddenType, true), + createTestDefinitions(allTypes, true, { + singleRequest: true, + responseBodyOverride: expectForbidden(['hiddentype']), + }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, { singleRequest: true }), + }; + }; - bulkUpdateTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); + describe('_bulk_update', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized, superuser } = createTests(spaceId); + const _addTests = (user: TestUser, tests: BulkUpdateTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - bulkUpdateTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbiddenWithGlobalAllowed, - }, - doesntExist: { - statusCode: 200, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach(user => { + _addTests(user, unauthorized); }); - - bulkUpdateTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - bulkUpdateTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbiddenWithGlobalAllowed, - }, - doesntExist: { - statusCode: 200, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); - - bulkUpdateTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - bulkUpdateTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, + [users.dualAll, users.allGlobally, users.allAtSpace].forEach(user => { + _addTests(user, authorized); }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts index e4adaa580c1db..7278504b8f0e8 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts @@ -4,248 +4,91 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { createTestSuiteFactory } from '../../common/suites/create'; +import { + createTestSuiteFactory, + TEST_CASES as CASES, + CreateTestDefinition, +} from '../../common/suites/create'; -export default function({ getService }: FtrProviderContext) { - const supertestWithoutAuth = getService('supertestWithoutAuth'); - const es = getService('legacyEs'); - const esArchiver = getService('esArchiver'); - - const { - createTest, - createExpectSpaceAwareResults, - expectNotSpaceAwareResults, - expectNotSpaceAwareRbacForbidden, - expectSpaceAwareRbacForbidden, - expectBadRequestForHiddenType, - expectHiddenTypeRbacForbidden, - } = createTestSuiteFactory(es, esArchiver, supertestWithoutAuth); - - describe('create', () => { - [ - { - spaceId: SPACES.DEFAULT.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - }, - }, - { - spaceId: SPACES.SPACE_1.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - }, - }, - ].forEach(scenario => { - createTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); - - createTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 400, - response: expectBadRequestForHiddenType, - }, - }, - }); +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail409 } = testCaseFailures; - createTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); +const createTestCases = (overwrite: boolean, spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { + ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_MULTI_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; - createTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); - - createTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); - - createTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); - - createTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const es = getService('legacyEs'); - createTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); + const { addTests, createTestDefinitions } = createTestSuiteFactory(es, esArchiver, supertest); + const createTests = (overwrite: boolean, spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite, spaceId); + return { + unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId }), + authorized: [ + createTestDefinitions(normalTypes, false, overwrite, { spaceId }), + createTestDefinitions(hiddenType, true, overwrite, { spaceId }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, overwrite, { spaceId }), + }; + }; - createTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); + describe('_create', () => { + getTestScenarios([false, true]).securityAndSpaces.forEach( + ({ spaceId, users, modifier: overwrite }) => { + const suffix = ` within the ${spaceId} space${overwrite ? ' with overwrite enabled' : ''}`; + const { unauthorized, authorized, superuser } = createTests(overwrite!, spaceId); + const _addTests = (user: TestUser, tests: CreateTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - createTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); - }); + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally, users.allAtSpace].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); + } + ); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts index bfd2112428db4..995b8fc2422d9 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts @@ -4,288 +4,83 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { deleteTestSuiteFactory } from '../../common/suites/delete'; +import { + deleteTestSuiteFactory, + TEST_CASES as CASES, + DeleteTestDefinition, +} from '../../common/suites/delete'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('delete', () => { - const { - createExpectUnknownDocNotFound, - deleteTest, - expectEmpty, - expectRbacSpaceAwareForbidden, - expectRbacNotSpaceAwareForbidden, - expectRbacInvalidIdForbidden, - expectGenericNotFound, - expectRbacHiddenTypeForbidden, - } = deleteTestSuiteFactory(esArchiver, supertest); - - [ - { - spaceId: SPACES.DEFAULT.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - }, - }, - { - spaceId: SPACES.SPACE_1.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - }, - }, - ].forEach(scenario => { - deleteTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 404, - response: expectGenericNotFound, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(scenario.spaceId), - }, - }, - }); - - deleteTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(scenario.spaceId), - }, - }, - }); + const { addTests, createTestDefinitions } = deleteTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); + return { + unauthorized: createTestDefinitions(allTypes, true, { spaceId }), + authorized: [ + createTestDefinitions(normalTypes, false, { spaceId }), + createTestDefinitions(hiddenType, true, { spaceId }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, { spaceId }), + }; + }; - deleteTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); + describe('_delete', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized, superuser } = createTests(spaceId); + const _addTests = (user: TestUser, tests: DeleteTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - deleteTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(scenario.spaceId), - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach(user => { + _addTests(user, unauthorized); }); - - deleteTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(scenario.spaceId), - }, - }, - }); - - deleteTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, + [users.dualAll, users.allGlobally, users.allAtSpace].forEach(user => { + _addTests(user, authorized); }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts index b64c3ed87c35d..6f2426e55c6a6 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts @@ -4,274 +4,70 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; -import { SPACES } from '../../common/lib/spaces'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { exportTestSuiteFactory } from '../../common/suites/export'; +import { + exportTestSuiteFactory, + getTestCases, + ExportTestDefinition, +} from '../../common/suites/export'; + +const createTestCases = (spaceId: string) => { + const cases = getTestCases(spaceId); + const exportableTypes = [ + cases.singleNamespaceObject, + cases.singleNamespaceType, + cases.namespaceAgnosticObject, + cases.namespaceAgnosticType, + ]; + const nonExportableTypes = [ + cases.multiNamespaceObject, + cases.multiNamespaceType, + cases.hiddenObject, + cases.hiddenType, + ]; + const allTypes = exportableTypes.concat(nonExportableTypes); + return { exportableTypes, nonExportableTypes, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('export', () => { - const { - createExpectRbacForbidden, - expectTypeOrObjectsRequired, - createExpectVisualizationResults, - expectInvalidTypeSpecified, - exportTest, - } = exportTestSuiteFactory(esArchiver, supertest); + const { addTests, createTestDefinitions } = exportTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const { exportableTypes, nonExportableTypes, allTypes } = createTestCases(spaceId); + return { + unauthorized: [ + createTestDefinitions(exportableTypes, true), + createTestDefinitions(nonExportableTypes, false), + ].flat(), + authorized: createTestDefinitions(allTypes, false), + }; + }; - [ - { - spaceId: SPACES.DEFAULT.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - }, - }, - { - spaceId: SPACES.SPACE_1.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - }, - }, - ].forEach(scenario => { - exportTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); + describe('_export', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized } = createTests(spaceId); + const _addTests = (user: TestUser, tests: ExportTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - exportTest(`superuser with the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, + [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach(user => { + _addTests(user, unauthorized); }); - - exportTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest(`rbac user with all at the other space within ${scenario.spaceId} space`, { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, + [ + users.dualAll, + users.dualRead, + users.allGlobally, + users.readGlobally, + users.allAtSpace, + users.readAtSpace, + users.superuser, + ].forEach(user => { + _addTests(user, authorized); }); }); }); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts index 366b8b44585cd..7c16c01d203c0 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts @@ -4,727 +4,71 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; -import { SPACES } from '../../common/lib/spaces'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { findTestSuiteFactory } from '../../common/suites/find'; +import { findTestSuiteFactory, getTestCases, FindTestDefinition } from '../../common/suites/find'; + +const createTestCases = (spaceId: string) => { + const cases = getTestCases(spaceId); + const normalTypes = [ + cases.singleNamespaceType, + cases.multiNamespaceType, + cases.namespaceAgnosticType, + cases.pageBeyondTotal, + cases.unknownSearchField, + cases.filterWithNamespaceAgnosticType, + cases.filterWithDisallowedType, + ]; + const hiddenAndUnknownTypes = [ + cases.hiddenType, + cases.unknownType, + cases.filterWithHiddenType, + cases.filterWithUnknownType, + ]; + const allTypes = normalTypes.concat(hiddenAndUnknownTypes); + return { normalTypes, hiddenAndUnknownTypes, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('find', () => { - const { - createExpectEmpty, - createExpectRbacForbidden, - createExpectVisualizationResults, - expectFilterWrongTypeError, - expectNotSpaceAwareResults, - expectTypeRequired, - findTest, - } = findTestSuiteFactory(esArchiver, supertest); + const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const { normalTypes, hiddenAndUnknownTypes, allTypes } = createTestCases(spaceId); + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenAndUnknownTypes, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; - [ - { - spaceId: SPACES.DEFAULT.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - }, - }, - { - spaceId: SPACES.SPACE_1.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - }, - }, - ].forEach(scenario => { - findTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and find url message', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, - }); + describe('_find', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized, superuser } = createTests(spaceId); + const _addTests = (user: TestUser, tests: FindTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - findTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - unknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - filterWithUnknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, + [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach(user => { + _addTests(user, unauthorized); }); - - findTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and find url message', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, - }); - - findTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and find url message', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, + [ + users.dualAll, + users.dualRead, + users.allGlobally, + users.readGlobally, + users.allAtSpace, + users.readAtSpace, + ].forEach(user => { + _addTests(user, authorized); }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts index 9667abcc5e57a..9e3203e147493 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts @@ -4,289 +4,84 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { getTestSuiteFactory } from '../../common/suites/get'; +import { + getTestSuiteFactory, + TEST_CASES as CASES, + GetTestDefinition, +} from '../../common/suites/get'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - const { - createExpectDoesntExistNotFound, - createExpectSpaceAwareResults, - createExpectNotSpaceAwareResults, - expectSpaceAwareRbacForbidden, - expectNotSpaceAwareRbacForbidden, - expectDoesntExistRbacForbidden, - expectHiddenTypeRbacForbidden, - expectHiddenTypeNotFound, - getTest, - } = getTestSuiteFactory(esArchiver, supertest); - - describe('get', () => { - [ - { - spaceId: SPACES.DEFAULT.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - }, - }, - { - spaceId: SPACES.SPACE_1.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - }, - }, - ].forEach(scenario => { - getTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - getTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 404, - response: expectHiddenTypeNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); - - getTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - getTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); + const { addTests, createTestDefinitions } = getTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenType, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; - getTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); + describe('_get', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized, superuser } = createTests(spaceId); + const _addTests = (user: TestUser, tests: GetTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - getTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, + [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach(user => { + _addTests(user, unauthorized); }); - - getTest(`rbac user with read globall within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); - - getTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); - - getTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); - - getTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, + [ + users.dualAll, + users.dualRead, + users.allGlobally, + users.readGlobally, + users.allAtSpace, + users.readAtSpace, + ].forEach(user => { + _addTests(user, authorized); }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts index 58859c292ce35..10c7f61dce5cc 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts @@ -4,245 +4,92 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { importTestSuiteFactory } from '../../common/suites/import'; +import { + importTestSuiteFactory, + TEST_CASES as CASES, + ImportTestDefinition, +} from '../../common/suites/import'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const importableTypes = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(spaceId === DEFAULT_SPACE_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(spaceId === SPACE_2_ID) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409() }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const nonImportableTypes = [ + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, + { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + ]; + const allTypes = importableTypes.concat(nonImportableTypes); + return { importableTypes, nonImportableTypes, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - importTest, - createExpectResults, - expectRbacForbidden, - expectUnknownTypeUnsupported: expectUnknownTypeUnsupported, - expectHiddenTypeUnsupported, - } = importTestSuiteFactory(es, esArchiver, supertest); + const { addTests, createTestDefinitions, expectForbidden } = importTestSuiteFactory( + es, + esArchiver, + supertest + ); + const createTests = (spaceId: string) => { + const { importableTypes, nonImportableTypes, allTypes } = createTestCases(spaceId); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: [ + createTestDefinitions(importableTypes, true, { spaceId }), + createTestDefinitions(nonImportableTypes, false, { spaceId, singleRequest: true }), + createTestDefinitions(allTypes, true, { + spaceId, + singleRequest: true, + responseBodyOverride: expectForbidden(['dashboard', 'globaltype', 'isolatedtype']), + }), + ].flat(), + authorized: createTestDefinitions(allTypes, false, { spaceId, singleRequest: true }), + }; + }; describe('_import', () => { - [ - { - spaceId: SPACES.DEFAULT.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - }, - }, - { - spaceId: SPACES.SPACE_1.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - }, - }, - ].forEach(scenario => { - importTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - importTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized } = createTests(spaceId); + const _addTests = (user: TestUser, tests: ImportTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - importTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach(user => { + _addTests(user, unauthorized); }); - - importTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - importTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - importTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, + [users.dualAll, users.allGlobally, users.allAtSpace, users.superuser].forEach(user => { + _addTests(user, authorized); }); }); }); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts index bb42c5422ece5..46d7ab6425989 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts @@ -20,6 +20,7 @@ export default function({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./bulk_create')); loadTestFile(require.resolve('./bulk_get')); + loadTestFile(require.resolve('./bulk_update')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./export')); @@ -28,6 +29,5 @@ export default function({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./import')); loadTestFile(require.resolve('./resolve_import_errors')); loadTestFile(require.resolve('./update')); - loadTestFile(require.resolve('./bulk_update')); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts index 6c91fe6310170..8e8fe874b4317 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts @@ -4,258 +4,99 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { resolveImportErrorsTestSuiteFactory } from '../../common/suites/resolve_import_errors'; +import { + resolveImportErrorsTestSuiteFactory, + TEST_CASES as CASES, + ResolveImportErrorsTestDefinition, +} from '../../common/suites/resolve_import_errors'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (overwrite: boolean, spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const importableTypes = [ + { + ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const nonImportableTypes = [ + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, + { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + ]; + const allTypes = importableTypes.concat(nonImportableTypes); + return { importableTypes, nonImportableTypes, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - resolveImportErrorsTest, - createExpectResults, - expectRbacForbidden, - expectUnknownTypeUnsupported, - expectHiddenTypeUnsupported, - } = resolveImportErrorsTestSuiteFactory(es, esArchiver, supertest); + const { addTests, createTestDefinitions, expectForbidden } = resolveImportErrorsTestSuiteFactory( + es, + esArchiver, + supertest + ); + const createTests = (overwrite: boolean, spaceId: string) => { + const { importableTypes, nonImportableTypes, allTypes } = createTestCases(overwrite, spaceId); + const singleRequest = true; + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: [ + createTestDefinitions(importableTypes, true, overwrite, { spaceId }), + createTestDefinitions(nonImportableTypes, false, overwrite, { spaceId, singleRequest }), + createTestDefinitions(allTypes, true, overwrite, { + spaceId, + singleRequest, + responseBodyOverride: expectForbidden(['dashboard', 'globaltype', 'isolatedtype']), + }), + ].flat(), + authorized: createTestDefinitions(allTypes, false, overwrite, { spaceId, singleRequest }), + }; + }; describe('_resolve_import_errors', () => { - [ - { - spaceId: SPACES.DEFAULT.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - }, - }, - { - spaceId: SPACES.SPACE_1.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - }, - }, - ].forEach(scenario => { - resolveImportErrorsTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - resolveImportErrorsTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - resolveImportErrorsTest( - `dual-privileges readonly user within the ${scenario.spaceId} space`, - { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - } - ); - - resolveImportErrorsTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - resolveImportErrorsTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest( - `rbac user with all at the space within the ${scenario.spaceId} space`, - { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - } - ); - - resolveImportErrorsTest( - `rbac user with read at the space within the ${scenario.spaceId} space`, - { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - } - ); + getTestScenarios([false, true]).securityAndSpaces.forEach( + ({ spaceId, users, modifier: overwrite }) => { + const suffix = ` within the ${spaceId} space${overwrite ? ' with overwrite enabled' : ''}`; + const { unauthorized, authorized } = createTests(overwrite!, spaceId); + const _addTests = (user: TestUser, tests: ResolveImportErrorsTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - resolveImportErrorsTest( - `rbac user with all at other space within the ${scenario.spaceId} space`, - { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - } - ); - }); + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally, users.allAtSpace, users.superuser].forEach(user => { + _addTests(user, authorized); + }); + } + ); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts index 8eb06e41e2a41..21f354d2a8e76 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts @@ -4,289 +4,83 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { updateTestSuiteFactory } from '../../common/suites/update'; +import { + updateTestSuiteFactory, + TEST_CASES as CASES, + UpdateTestDefinition, +} from '../../common/suites/update'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('update', () => { - const { - createExpectDoesntExistNotFound, - expectDoesntExistRbacForbidden, - expectNotSpaceAwareResults, - expectNotSpaceAwareRbacForbidden, - expectSpaceAwareRbacForbidden, - expectSpaceAwareResults, - expectSpaceNotFound, - expectHiddenTypeRbacForbidden, - updateTest, - } = updateTestSuiteFactory(esArchiver, supertest); - - [ - { - spaceId: SPACES.DEFAULT.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - }, - }, - { - spaceId: SPACES.SPACE_1.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - }, - }, - ].forEach(scenario => { - updateTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - updateTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 404, - response: expectSpaceNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); - - updateTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - updateTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); + const { addTests, createTestDefinitions } = updateTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenType, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; - updateTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); + describe('_update', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized, superuser } = createTests(spaceId); + const _addTests = (user: TestUser, tests: UpdateTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - updateTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach(user => { + _addTests(user, unauthorized); }); - - updateTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - updateTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); - - updateTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - updateTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, + [users.dualAll, users.allGlobally, users.allAtSpace].forEach(user => { + _addTests(user, authorized); }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts index 943a22c4399c7..5b3397c7909ae 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts @@ -4,176 +4,88 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkCreateTestSuiteFactory } from '../../common/suites/bulk_create'; +import { + bulkCreateTestSuiteFactory, + TEST_CASES as CASES, + BulkCreateTestDefinition, +} from '../../common/suites/bulk_create'; + +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (overwrite: boolean) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) }, + CASES.SINGLE_NAMESPACE_SPACE_1, + CASES.SINGLE_NAMESPACE_SPACE_2, + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409() }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_MULTI_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - bulkCreateTest, - createExpectResults, - createExpectRbacForbidden, - expectBadRequestForHiddenType, - expectedForbiddenTypesWithHiddenType: expectedForbiddenTypesWithHiddenType, - } = bulkCreateTestSuiteFactory(es, esArchiver, supertest); + const { addTests, createTestDefinitions, expectForbidden } = bulkCreateTestSuiteFactory( + es, + esArchiver, + supertest + ); + const createTests = (overwrite: boolean) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true, overwrite), + authorized: [ + createTestDefinitions(normalTypes, false, overwrite, { singleRequest: true }), + createTestDefinitions(hiddenType, true, overwrite), + createTestDefinitions(allTypes, true, overwrite, { + singleRequest: true, + responseBodyOverride: expectForbidden(['hiddentype']), + }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, overwrite, { singleRequest: true }), + }; + }; describe('_bulk_create', () => { - bulkCreateTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - includingSpace: { - statusCode: 200, - response: expectBadRequestForHiddenType, - }, - }, - }); - - bulkCreateTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkCreateTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkCreateTest(`rbac readonly user`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`rbac user with all at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`rbac user with read at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`rbac user with all at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); + getTestScenarios([false, true]).security.forEach(({ users, modifier: overwrite }) => { + const suffix = overwrite ? ' with overwrite enabled' : ''; + const { unauthorized, authorized, superuser } = createTests(overwrite!); + const _addTests = (user: TestUser, tests: BulkCreateTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, tests }); + }; - bulkCreateTest(`rbac user with read at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts index fde98694fe575..69494ed254669 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts @@ -4,175 +4,81 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkGetTestSuiteFactory } from '../../common/suites/bulk_get'; +import { + bulkGetTestSuiteFactory, + TEST_CASES as CASES, + BulkGetTestDefinition, +} from '../../common/suites/bulk_get'; + +const { fail400, fail404 } = testCaseFailures; + +const createTestCases = () => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - const { - bulkGetTest, - createExpectResults, - createExpectRbacForbidden, - expectedForbiddenTypesWithHiddenType, - expectBadRequestForHiddenType, - } = bulkGetTestSuiteFactory(esArchiver, supertest); + const { addTests, createTestDefinitions, expectForbidden } = bulkGetTestSuiteFactory( + esArchiver, + supertest + ); + const createTests = () => { + const { normalTypes, hiddenType, allTypes } = createTestCases(); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false, { singleRequest: true }), + createTestDefinitions(hiddenType, true), + createTestDefinitions(allTypes, true, { + singleRequest: true, + responseBodyOverride: expectForbidden(['hiddentype']), + }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, { singleRequest: true }), + }; + }; describe('_bulk_get', () => { - bulkGetTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkGetTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - includingHiddenType: { - statusCode: 200, - response: expectBadRequestForHiddenType, - }, - }, - }); - - bulkGetTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkGetTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkGetTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkGetTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkGetTest(`rbac user with read globally`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkGetTest(`rbac user with all at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkGetTest(`rbac user with read at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkGetTest(`rbac user with all at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized, superuser } = createTests(); + const _addTests = (user: TestUser, tests: BulkGetTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; - bulkGetTest(`rbac user with read at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, + [ + users.noAccess, + users.legacyAll, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.dualRead, users.allGlobally, users.readGlobally].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts index 6f4635f17cf8c..fb169f4c6fb86 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts @@ -4,268 +4,83 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkUpdateTestSuiteFactory } from '../../common/suites/bulk_update'; +import { + bulkUpdateTestSuiteFactory, + TEST_CASES as CASES, + BulkUpdateTestDefinition, +} from '../../common/suites/bulk_update'; + +const { fail404 } = testCaseFailures; + +const createTestCases = () => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('bulkUpdate', () => { - const { - createExpectDoesntExistNotFound, - expectDoesntExistRbacForbidden, - expectNotSpaceAwareResults, - expectNotSpaceAwareRbacForbidden, - expectSpaceAwareRbacForbidden, - expectSpaceAwareResults, - expectSpaceNotFound, - expectHiddenTypeRbacForbidden, - expectHiddenTypeRbacForbiddenWithGlobalAllowed, - bulkUpdateTest, - } = bulkUpdateTestSuiteFactory(esArchiver, supertest); - - bulkUpdateTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - bulkUpdateTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 200, - response: expectSpaceNotFound, - }, - doesntExist: { - statusCode: 200, - response: createExpectDoesntExistNotFound(), - }, - }, - }); + const { addTests, createTestDefinitions, expectForbidden } = bulkUpdateTestSuiteFactory( + esArchiver, + supertest + ); + const createTests = () => { + const { normalTypes, hiddenType, allTypes } = createTestCases(); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false, { singleRequest: true }), + createTestDefinitions(hiddenType, true), + createTestDefinitions(allTypes, true, { + singleRequest: true, + responseBodyOverride: expectForbidden(['hiddentype']), + }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, { singleRequest: true }), + }; + }; - bulkUpdateTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - bulkUpdateTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbiddenWithGlobalAllowed, - }, - doesntExist: { - statusCode: 200, - response: createExpectDoesntExistNotFound(), - }, - }, - }); - - bulkUpdateTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - bulkUpdateTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbiddenWithGlobalAllowed, - }, - doesntExist: { - statusCode: 200, - response: createExpectDoesntExistNotFound(), - }, - }, - }); - - bulkUpdateTest(`rbac user with read globally`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - bulkUpdateTest(`rbac user with all at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - bulkUpdateTest(`rbac user with read at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - bulkUpdateTest(`rbac user with all at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); + describe('_bulk_update', () => { + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized, superuser } = createTests(); + const _addTests = (user: TestUser, tests: BulkUpdateTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; - bulkUpdateTest(`rbac user with read at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts index 60a9fa0a86aa6..dc8e564e42477 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts @@ -4,222 +4,79 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { createTestSuiteFactory } from '../../common/suites/create'; +import { + createTestSuiteFactory, + TEST_CASES as CASES, + CreateTestDefinition, +} from '../../common/suites/create'; -export default function({ getService }: FtrProviderContext) { - const supertestWithoutAuth = getService('supertestWithoutAuth'); - const es = getService('legacyEs'); - const esArchiver = getService('esArchiver'); - - const { - createTest, - createExpectSpaceAwareResults, - expectNotSpaceAwareResults, - expectNotSpaceAwareRbacForbidden, - expectSpaceAwareRbacForbidden, - expectBadRequestForHiddenType, - expectHiddenTypeRbacForbidden, - } = createTestSuiteFactory(es, esArchiver, supertestWithoutAuth); - - describe('create', () => { - createTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); +const { fail400, fail409 } = testCaseFailures; - createTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 400, - response: expectBadRequestForHiddenType, - }, - }, - }); - - createTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); +const createTestCases = (overwrite: boolean) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) }, + CASES.SINGLE_NAMESPACE_SPACE_1, + CASES.SINGLE_NAMESPACE_SPACE_2, + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409() }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_MULTI_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; - createTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); - - createTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); - - createTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); - - createTest(`rbac user with read globally`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); - - createTest(`rbac user with all at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const es = getService('legacyEs'); - createTest(`rbac user with read at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); + const { addTests, createTestDefinitions } = createTestSuiteFactory(es, esArchiver, supertest); + const createTests = (overwrite: boolean) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite); + return { + unauthorized: createTestDefinitions(allTypes, true, overwrite), + authorized: [ + createTestDefinitions(normalTypes, false, overwrite), + createTestDefinitions(hiddenType, true, overwrite), + ].flat(), + superuser: createTestDefinitions(allTypes, false, overwrite), + }; + }; - createTest(`rbac user with all at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); + describe('_create', () => { + getTestScenarios([false, true]).security.forEach(({ users, modifier: overwrite }) => { + const suffix = overwrite ? ' with overwrite enabled' : ''; + const { unauthorized, authorized, superuser } = createTests(overwrite!); + const _addTests = (user: TestUser, tests: CreateTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, tests }); + }; - createTest(`rbac user with read at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts index f775b5a365d6b..05939197be352 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts @@ -4,266 +4,75 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { deleteTestSuiteFactory } from '../../common/suites/delete'; +import { + deleteTestSuiteFactory, + TEST_CASES as CASES, + DeleteTestDefinition, +} from '../../common/suites/delete'; + +const { fail404 } = testCaseFailures; + +const createTestCases = () => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('delete', () => { - const { - createExpectUnknownDocNotFound, - deleteTest, - expectEmpty, - expectRbacSpaceAwareForbidden, - expectRbacNotSpaceAwareForbidden, - expectRbacInvalidIdForbidden, - expectRbacHiddenTypeForbidden: expectRbacSpaceTypeForbidden, - expectGenericNotFound, - } = deleteTestSuiteFactory(esArchiver, supertest); - - deleteTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 404, - response: expectGenericNotFound, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(), - }, - }, - }); + const { addTests, createTestDefinitions } = deleteTestSuiteFactory(esArchiver, supertest); + const createTests = () => { + const { normalTypes, hiddenType, allTypes } = createTestCases(); + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenType, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; - deleteTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(), - }, - }, - }); - - deleteTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(), - }, - }, - }); - - deleteTest(`rbac user with read globally`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`rbac user with all at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`rbac user with read at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`rbac user with all at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); + describe('_delete', () => { + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized, superuser } = createTests(); + const _addTests = (user: TestUser, tests: DeleteTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; - deleteTest(`rbac user with readonly at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts index 2a2c3a9b90b08..0fae45a1897a7 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts @@ -4,252 +4,75 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { exportTestSuiteFactory } from '../../common/suites/export'; +import { + exportTestSuiteFactory, + getTestCases, + ExportTestDefinition, +} from '../../common/suites/export'; + +const createTestCases = () => { + const cases = getTestCases(); + const exportableTypes = [ + cases.singleNamespaceObject, + cases.singleNamespaceType, + cases.namespaceAgnosticObject, + cases.namespaceAgnosticType, + ]; + const nonExportableTypes = [ + cases.multiNamespaceObject, + cases.multiNamespaceType, + cases.hiddenObject, + cases.hiddenType, + ]; + const allTypes = exportableTypes.concat(nonExportableTypes); + return { exportableTypes, nonExportableTypes, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('export', () => { - const { - createExpectRbacForbidden, - expectTypeOrObjectsRequired, - createExpectVisualizationResults, - expectInvalidTypeSpecified, - exportTest, - } = exportTestSuiteFactory(esArchiver, supertest); - - exportTest('user with no access', { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest('superuser', { - user: AUTHENTICATION.SUPERUSER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest('legacy user', { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest('dual-privileges user', { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest('dual-privileges readonly user', { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest('rbac user with all globally', { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest('rbac user with read globally', { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); + const { addTests, createTestDefinitions } = exportTestSuiteFactory(esArchiver, supertest); + const createTests = () => { + const { exportableTypes, nonExportableTypes, allTypes } = createTestCases(); + return { + unauthorized: [ + createTestDefinitions(exportableTypes, true), + createTestDefinitions(nonExportableTypes, false), + ].flat(), + authorized: createTestDefinitions(allTypes, false), + }; + }; - exportTest('rbac user with all at default space', { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest('rbac user with read at default space', { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest('rbac user with all at space_1', { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); + describe('_export', () => { + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized } = createTests(); + const _addTests = (user: TestUser, tests: ExportTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; - exportTest('rbac user with read at space_1', { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [ + users.dualAll, + users.dualRead, + users.allGlobally, + users.readGlobally, + users.superuser, + ].forEach(user => { + _addTests(user, authorized); + }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts index 64d85a199e7bc..97513783b94b9 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts @@ -4,749 +4,70 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { findTestSuiteFactory } from '../../common/suites/find'; +import { findTestSuiteFactory, getTestCases, FindTestDefinition } from '../../common/suites/find'; + +const createTestCases = () => { + const cases = getTestCases(); + const normalTypes = [ + cases.singleNamespaceType, + cases.multiNamespaceType, + cases.namespaceAgnosticType, + cases.pageBeyondTotal, + cases.unknownSearchField, + cases.filterWithNamespaceAgnosticType, + cases.filterWithDisallowedType, + ]; + const hiddenAndUnknownTypes = [ + cases.hiddenType, + cases.unknownType, + cases.filterWithHiddenType, + cases.filterWithUnknownType, + ]; + const allTypes = normalTypes.concat(hiddenAndUnknownTypes); + return { normalTypes, hiddenAndUnknownTypes, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('find', () => { - const { - createExpectEmpty, - createExpectRbacForbidden, - createExpectVisualizationResults, - expectFilterWrongTypeError, - expectNotSpaceAwareResults, - expectTypeRequired, - findTest, - } = findTestSuiteFactory(esArchiver, supertest); - - findTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden login and find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and unknown search field', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden login and find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, - }); - - findTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - unknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - filterWithUnknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden login and find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and unknown search field', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden login and find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, - }); - - findTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`rbac user with read globally`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); + const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest); + const createTests = () => { + const { normalTypes, hiddenAndUnknownTypes, allTypes } = createTestCases(); + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenAndUnknownTypes, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; - findTest(`rbac user with all at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and unknown search field', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, - }); - - findTest(`rbac user with read at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and unknown search field', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, - }); - - findTest(`rbac user with all at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and unknown search field', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, - }); + describe('_find', () => { + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized, superuser } = createTests(); + const _addTests = (user: TestUser, tests: FindTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; - findTest(`rbac user with read at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and unknown search field', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, + [ + users.noAccess, + users.legacyAll, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.dualRead, users.allGlobally, users.readGlobally].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts index 2a31463fce8b2..7cd50fe4cea61 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts @@ -4,267 +4,73 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { getTestSuiteFactory } from '../../common/suites/get'; +import { + getTestSuiteFactory, + TEST_CASES as CASES, + GetTestDefinition, +} from '../../common/suites/get'; + +const { fail404 } = testCaseFailures; + +const createTestCases = () => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - const { - createExpectDoesntExistNotFound, - createExpectSpaceAwareResults, - createExpectNotSpaceAwareResults, - expectSpaceAwareRbacForbidden, - expectNotSpaceAwareRbacForbidden, - expectDoesntExistRbacForbidden, - expectHiddenTypeRbacForbidden, - expectHiddenTypeNotFound, - getTest, - } = getTestSuiteFactory(esArchiver, supertest); - - describe('get', () => { - getTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - getTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(), - }, - hiddenType: { - statusCode: 404, - response: expectHiddenTypeNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, - }); + const { addTests, createTestDefinitions } = getTestSuiteFactory(esArchiver, supertest); + const createTests = () => { + const { normalTypes, hiddenType, allTypes } = createTestCases(); + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenType, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; - getTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - getTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, - }); - - getTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, - }); - - getTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, - }); - - getTest(`rbac user with read globally`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, - }); - - getTest(`rbac user with all at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - getTest(`rbac user with read at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - getTest(`rbac user with all at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); + describe('_get', () => { + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized, superuser } = createTests(); + const _addTests = (user: TestUser, tests: GetTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; - getTest(`rbac user with read at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.dualRead, users.allGlobally, users.readGlobally].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts index 770410dcfed81..5a6e530b02939 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts @@ -4,223 +4,87 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { importTestSuiteFactory } from '../../common/suites/import'; +import { + importTestSuiteFactory, + TEST_CASES as CASES, + ImportTestDefinition, +} from '../../common/suites/import'; + +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = () => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const importableTypes = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409() }, + CASES.SINGLE_NAMESPACE_SPACE_1, + CASES.SINGLE_NAMESPACE_SPACE_2, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409() }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const nonImportableTypes = [ + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, + { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + ]; + const allTypes = importableTypes.concat(nonImportableTypes); + return { importableTypes, nonImportableTypes, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - importTest, - createExpectResults, - expectRbacForbidden, - expectUnknownTypeUnsupported, - expectResultsWithUnsupportedHiddenType, - } = importTestSuiteFactory(es, esArchiver, supertest); + const { addTests, createTestDefinitions, expectForbidden } = importTestSuiteFactory( + es, + esArchiver, + supertest + ); + const createTests = () => { + const { importableTypes, nonImportableTypes, allTypes } = createTestCases(); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: [ + createTestDefinitions(importableTypes, true), + createTestDefinitions(nonImportableTypes, false, { singleRequest: true }), + createTestDefinitions(allTypes, true, { + singleRequest: true, + responseBodyOverride: expectForbidden(['dashboard', 'globaltype', 'isolatedtype']), + }), + ].flat(), + authorized: createTestDefinitions(allTypes, false, { singleRequest: true }), + }; + }; describe('_import', () => { - importTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - hiddenType: { - // import filters out the space type, so the remaining objects will import successfully - statusCode: 200, - response: expectResultsWithUnsupportedHiddenType, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - importTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - hiddenType: { - // import filters out the space type, so the remaining objects will import successfully - statusCode: 200, - response: expectResultsWithUnsupportedHiddenType, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - importTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - hiddenType: { - // import filters out the space type, so the remaining objects will import successfully - statusCode: 200, - response: expectResultsWithUnsupportedHiddenType, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - importTest(`rbac readonly user`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`rbac user with all at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`rbac user with read at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`rbac user with all at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized } = createTests(); + const _addTests = (user: TestUser, tests: ImportTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; - importTest(`rbac user with read at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally, users.superuser].forEach(user => { + _addTests(user, authorized); + }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/index.ts b/x-pack/test/saved_object_api_integration/security_only/apis/index.ts index bb637a9bc4c90..f581e18ff17af 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/index.ts @@ -20,6 +20,7 @@ export default function({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./bulk_create')); loadTestFile(require.resolve('./bulk_get')); + loadTestFile(require.resolve('./bulk_update')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./export')); @@ -28,6 +29,5 @@ export default function({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./import')); loadTestFile(require.resolve('./resolve_import_errors')); loadTestFile(require.resolve('./update')); - loadTestFile(require.resolve('./bulk_update')); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts index 59d50c16c259a..f945d2b64c432 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts @@ -4,221 +4,88 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { resolveImportErrorsTestSuiteFactory } from '../../common/suites/resolve_import_errors'; +import { + resolveImportErrorsTestSuiteFactory, + TEST_CASES as CASES, + ResolveImportErrorsTestDefinition, +} from '../../common/suites/resolve_import_errors'; + +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (overwrite: boolean) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const importableTypes = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) }, + CASES.SINGLE_NAMESPACE_SPACE_1, + CASES.SINGLE_NAMESPACE_SPACE_2, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const nonImportableTypes = [ + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, + { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + ]; + const allTypes = importableTypes.concat(nonImportableTypes); + return { importableTypes, nonImportableTypes, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - resolveImportErrorsTest, - createExpectResults, - - expectRbacForbidden, - expectUnknownTypeUnsupported, - expectHiddenTypeUnsupported, - } = resolveImportErrorsTestSuiteFactory(es, esArchiver, supertest); + const { addTests, createTestDefinitions, expectForbidden } = resolveImportErrorsTestSuiteFactory( + es, + esArchiver, + supertest + ); + const createTests = (overwrite: boolean) => { + const { importableTypes, nonImportableTypes, allTypes } = createTestCases(overwrite); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: [ + createTestDefinitions(importableTypes, true, overwrite), + createTestDefinitions(nonImportableTypes, false, overwrite, { singleRequest: true }), + createTestDefinitions(allTypes, true, overwrite, { + singleRequest: true, + responseBodyOverride: expectForbidden(['dashboard', 'globaltype', 'isolatedtype']), + }), + ].flat(), + authorized: createTestDefinitions(allTypes, false, overwrite, { singleRequest: true }), + }; + }; describe('_resolve_import_errors', () => { - resolveImportErrorsTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - resolveImportErrorsTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - resolveImportErrorsTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - resolveImportErrorsTest(`rbac readonly user`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest(`rbac user with all at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest(`rbac user with read at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest(`rbac user with all at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); + getTestScenarios([false, true]).security.forEach(({ users, modifier: overwrite }) => { + const suffix = overwrite ? ' with overwrite enabled' : ''; + const { unauthorized, authorized } = createTests(overwrite!); + const _addTests = (user: TestUser, tests: ResolveImportErrorsTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, tests }); + }; - resolveImportErrorsTest(`rbac user with read at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally, users.superuser].forEach(user => { + _addTests(user, authorized); + }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts index 8564296bdf558..e1e3a5f8a7dc7 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts @@ -4,267 +4,75 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { updateTestSuiteFactory } from '../../common/suites/update'; +import { + updateTestSuiteFactory, + TEST_CASES as CASES, + UpdateTestDefinition, +} from '../../common/suites/update'; + +const { fail404 } = testCaseFailures; + +const createTestCases = () => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('update', () => { - const { - createExpectDoesntExistNotFound, - expectDoesntExistRbacForbidden, - expectNotSpaceAwareResults, - expectNotSpaceAwareRbacForbidden, - expectSpaceAwareRbacForbidden, - expectSpaceAwareResults, - expectSpaceNotFound, - expectHiddenTypeRbacForbidden, - updateTest, - } = updateTestSuiteFactory(esArchiver, supertest); - - updateTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - updateTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 404, - response: expectSpaceNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, - }); + const { addTests, createTestDefinitions } = updateTestSuiteFactory(esArchiver, supertest); + const createTests = () => { + const { normalTypes, hiddenType, allTypes } = createTestCases(); + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenType, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; - updateTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - updateTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, - }); - - updateTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - updateTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, - }); - - updateTest(`rbac user with read globally`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - updateTest(`rbac user with all at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - updateTest(`rbac user with read at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - updateTest(`rbac user with all at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); + describe('_update', () => { + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized, superuser } = createTests(); + const _addTests = (user: TestUser, tests: UpdateTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; - updateTest(`rbac user with read at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts index 690e358b744d5..70d74822a8b0f 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -6,83 +6,72 @@ import expect from '@kbn/expect'; import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkCreateTestSuiteFactory } from '../../common/suites/bulk_create'; +import { bulkCreateTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/bulk_create'; -const expectNamespaceSpecifiedBadRequest = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - error: 'Bad Request', - message: '[request body.0.namespace]: definition for this key is missing', - statusCode: 400, - }); -}; +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (overwrite: boolean, spaceId: string) => [ + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + { + ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + { ...CASES.HIDDEN, ...fail400() }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_MULTI_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, +]; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - bulkCreateTest, - createExpectResults, - expectBadRequestForHiddenType, - } = bulkCreateTestSuiteFactory(es, esArchiver, supertest); - - describe('_bulk_create', () => { - bulkCreateTest('in the current space (space_1)', { - ...SPACES.SPACE_1, - tests: { - default: { - statusCode: 200, - response: createExpectResults(SPACES.SPACE_1.spaceId), - }, - includingSpace: { - statusCode: 200, - response: expectBadRequestForHiddenType, - }, - custom: { - description: 'when a namespace is specified on the saved object', - requestBody: [ - { - type: 'visualization', - namespace: 'space_1', - attributes: { - title: 'something', - }, - }, - ], - statusCode: 400, - response: expectNamespaceSpecifiedBadRequest, + const { addTests, createTestDefinitions } = bulkCreateTestSuiteFactory(es, esArchiver, supertest); + const createTests = (overwrite: boolean, spaceId: string) => { + const testCases = createTestCases(overwrite, spaceId); + return createTestDefinitions(testCases, false, overwrite, { + spaceId, + singleRequest: true, + }).concat( + ['namespace', 'namespaces'].map(key => ({ + title: `(bad request) when ${key} is specified on the saved object`, + request: [{ type: 'isolatedtype', id: 'some-id', [key]: 'any-value' }] as any, + responseStatusCode: 400, + responseBody: async (response: Record) => { + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: `[request body.0.${key}]: definition for this key is missing`, + }); }, - }, - }); + overwrite, + })) + ); + }; - bulkCreateTest('in the default space', { - ...SPACES.DEFAULT, - tests: { - default: { - statusCode: 200, - response: createExpectResults(SPACES.DEFAULT.spaceId), - }, - includingSpace: { - statusCode: 200, - response: expectBadRequestForHiddenType, - }, - custom: { - description: 'when a namespace is specified on the saved object', - requestBody: [ - { - type: 'visualization', - namespace: 'space_1', - attributes: { - title: 'something', - }, - }, - ], - statusCode: 400, - response: expectNamespaceSpecifiedBadRequest, - }, - }, + describe('_bulk_create', () => { + getTestScenarios([false, true]).spaces.forEach(({ spaceId, modifier: overwrite }) => { + const suffix = overwrite ? ' with overwrite enabled' : ''; + const tests = createTests(overwrite!, spaceId); + addTests(`within the ${spaceId} space${suffix}`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts index bed29f8eb39a1..ad10719750585 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts @@ -5,48 +5,48 @@ */ import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkGetTestSuiteFactory } from '../../common/suites/bulk_get'; +import { bulkGetTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/bulk_get'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => [ + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, +]; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const { - bulkGetTest, - createExpectResults, - createExpectNotFoundResults, - expectBadRequestForHiddenType, - } = bulkGetTestSuiteFactory(esArchiver, supertest); + const { addTests, createTestDefinitions } = bulkGetTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false, { singleRequest: true }); + }; describe('_bulk_get', () => { - bulkGetTest(`objects within the current space (space_1)`, { - ...SPACES.SPACE_1, - tests: { - default: { - statusCode: 200, - response: createExpectResults(SPACES.SPACE_1.spaceId), - }, - includingHiddenType: { - statusCode: 200, - response: expectBadRequestForHiddenType, - }, - }, - }); - - bulkGetTest(`objects within another space`, { - ...SPACES.SPACE_1, - otherSpaceId: SPACES.SPACE_2.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectNotFoundResults(SPACES.SPACE_2.spaceId), - }, - includingHiddenType: { - statusCode: 200, - response: expectBadRequestForHiddenType, - }, - }, + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts index 0681908a765ce..be6ce9e30c56e 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts @@ -5,88 +5,48 @@ */ import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkUpdateTestSuiteFactory } from '../../common/suites/bulk_update'; +import { bulkUpdateTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/bulk_update'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => [ + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.HIDDEN, ...fail404() }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, +]; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - describe('bulkUpdate', () => { - const { - createExpectSpaceAwareNotFound, - expectSpaceAwareResults, - createExpectDoesntExistNotFound, - expectNotSpaceAwareResults, - expectSpaceNotFound, - bulkUpdateTest, - } = bulkUpdateTestSuiteFactory(esArchiver, supertest); - - bulkUpdateTest(`in the default space`, { - spaceId: SPACES.DEFAULT.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 200, - response: expectSpaceNotFound, - }, - doesntExist: { - statusCode: 200, - response: createExpectDoesntExistNotFound(SPACES.DEFAULT.spaceId), - }, - }, - }); - - bulkUpdateTest('in the current space (space_1)', { - spaceId: SPACES.SPACE_1.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 200, - response: expectSpaceNotFound, - }, - doesntExist: { - statusCode: 200, - response: createExpectDoesntExistNotFound(SPACES.SPACE_1.spaceId), - }, - }, - }); + const { addTests, createTestDefinitions } = bulkUpdateTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false, { singleRequest: true }); + }; - bulkUpdateTest('objects that exist in another space (space_1)', { - spaceId: SPACES.DEFAULT.spaceId, - otherSpaceId: SPACES.SPACE_1.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareNotFound(SPACES.SPACE_1.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 200, - response: expectSpaceNotFound, - }, - doesntExist: { - statusCode: 200, - response: createExpectDoesntExistNotFound(), - }, - }, + describe('_bulk_update', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts index 3bd4019649363..d0c6a21e73971 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts @@ -4,90 +4,56 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { createTestSuiteFactory } from '../../common/suites/create'; +import { createTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/create'; -const expectNamespaceSpecifiedBadRequest = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - error: 'Bad Request', - message: '[request body.namespace]: definition for this key is missing', - statusCode: 400, - }); -}; +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (overwrite: boolean, spaceId: string) => [ + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + { + ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + { ...CASES.HIDDEN, ...fail400() }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_MULTI_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, +]; export default function({ getService }: FtrProviderContext) { - const supertestWithoutAuth = getService('supertestWithoutAuth'); - const es = getService('legacyEs'); + const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); + const es = getService('legacyEs'); - const { - createTest, - createExpectSpaceAwareResults, - expectNotSpaceAwareResults, - expectBadRequestForHiddenType, - } = createTestSuiteFactory(es, esArchiver, supertestWithoutAuth); - - describe('create', () => { - createTest('in the current space (space_1)', { - ...SPACES.SPACE_1, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(SPACES.SPACE_1.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 400, - response: expectBadRequestForHiddenType, - }, - custom: { - description: 'when a namespace is specified on the saved object', - type: 'visualization', - requestBody: { - namespace: 'space_1', - attributes: { - title: 'something', - }, - }, - statusCode: 400, - response: expectNamespaceSpecifiedBadRequest, - }, - }, - }); + const { addTests, createTestDefinitions } = createTestSuiteFactory(es, esArchiver, supertest); + const createTests = (overwrite: boolean, spaceId: string) => { + const testCases = createTestCases(overwrite, spaceId); + return createTestDefinitions(testCases, false, overwrite, { spaceId }); + }; - createTest('in the default space', { - ...SPACES.DEFAULT, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(SPACES.DEFAULT.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 400, - response: expectBadRequestForHiddenType, - }, - custom: { - description: 'when a namespace is specified on the saved object', - type: 'visualization', - requestBody: { - namespace: 'space_1', - attributes: { - title: 'something', - }, - }, - statusCode: 400, - response: expectNamespaceSpecifiedBadRequest, - }, - }, + describe('_create', () => { + getTestScenarios([false, true]).spaces.forEach(({ spaceId, modifier: overwrite }) => { + const suffix = overwrite ? ' with overwrite enabled' : ''; + const tests = createTests(overwrite!, spaceId); + addTests(`within the ${spaceId} space${suffix}`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts index 437b3f95024c6..bb48e06d93a09 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts @@ -5,87 +5,48 @@ */ import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { deleteTestSuiteFactory } from '../../common/suites/delete'; +import { deleteTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/delete'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => [ + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.HIDDEN, ...fail404() }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, +]; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - describe('delete', () => { - const { - createExpectSpaceAwareNotFound, - createExpectUnknownDocNotFound, - deleteTest, - expectEmpty, - expectGenericNotFound, - } = deleteTestSuiteFactory(esArchiver, supertest); - - deleteTest(`in the default space`, { - ...SPACES.DEFAULT, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 404, - response: expectGenericNotFound, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(SPACES.DEFAULT.spaceId), - }, - }, - }); - - deleteTest(`in the current space (space_1)`, { - ...SPACES.SPACE_1, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 404, - response: expectGenericNotFound, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(SPACES.SPACE_1.spaceId), - }, - }, - }); + const { addTests, createTestDefinitions } = deleteTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false, { spaceId }); + }; - deleteTest(`in another space (space_2)`, { - spaceId: SPACES.SPACE_1.spaceId, - otherSpaceId: SPACES.SPACE_2.spaceId, - tests: { - spaceAware: { - statusCode: 404, - response: createExpectSpaceAwareNotFound(SPACES.SPACE_2.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 404, - response: expectGenericNotFound, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(SPACES.SPACE_2.spaceId), - }, - }, + describe('_delete', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/export.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/export.ts index f4be02c05ac88..25d4fbfae990b 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/export.ts @@ -4,62 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SPACES } from '../../common/lib/spaces'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { exportTestSuiteFactory } from '../../common/suites/export'; +import { exportTestSuiteFactory, getTestCases } from '../../common/suites/export'; + +const createTestCases = (spaceId: string) => { + const cases = getTestCases(spaceId); + return Object.values(cases); +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const { - expectTypeOrObjectsRequired, - createExpectVisualizationResults, - expectInvalidTypeSpecified, - exportTest, - } = exportTestSuiteFactory(esArchiver, supertest); - - describe('export', () => { - exportTest('objects only within the current space (space_1)', { - ...SPACES.SPACE_1, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(SPACES.SPACE_1.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); + const { addTests, createTestDefinitions } = exportTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false); + }; - exportTest('objects only within the current space (default)', { - ...SPACES.DEFAULT, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(SPACES.DEFAULT.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, + describe('_export', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts index a07d3edf834e9..a15f7de404db8 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts @@ -4,154 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SPACES } from '../../common/lib/spaces'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { findTestSuiteFactory } from '../../common/suites/find'; +import { findTestSuiteFactory, getTestCases } from '../../common/suites/find'; + +const createTestCases = (spaceId: string) => { + const cases = getTestCases(spaceId); + return Object.values(cases); +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const { - createExpectEmpty, - createExpectVisualizationResults, - expectFilterWrongTypeError, - expectNotSpaceAwareResults, - expectTypeRequired, - findTest, - } = findTestSuiteFactory(esArchiver, supertest); - - describe('find', () => { - findTest(`objects only within the current space (space_1)`, { - ...SPACES.SPACE_1, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(SPACES.SPACE_1.spaceId), - }, - notSpaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - unknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - filterWithUnknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); + const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false); + }; - findTest(`objects only within the current space (default)`, { - ...SPACES.DEFAULT, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(SPACES.DEFAULT.spaceId), - }, - notSpaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - unknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - filterWithUnknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, + describe('_find', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts index 592bedd92b4d7..512ae968dd0dd 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts @@ -5,88 +5,48 @@ */ import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { getTestSuiteFactory } from '../../common/suites/get'; +import { getTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/get'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => [ + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.HIDDEN, ...fail404() }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, +]; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const { - createExpectDoesntExistNotFound, - createExpectSpaceAwareNotFound, - createExpectSpaceAwareResults, - createExpectNotSpaceAwareResults, - expectHiddenTypeNotFound: expectHiddenTypeNotFound, - getTest, - } = getTestSuiteFactory(esArchiver, supertest); - - describe('get', () => { - getTest(`can access objects belonging to the current space (default)`, { - ...SPACES.DEFAULT, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(SPACES.DEFAULT.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(SPACES.DEFAULT.spaceId), - }, - hiddenType: { - statusCode: 404, - response: expectHiddenTypeNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(SPACES.DEFAULT.spaceId), - }, - }, - }); - - getTest(`can access objects belonging to the current space (space_1)`, { - ...SPACES.SPACE_1, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(SPACES.SPACE_1.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(SPACES.SPACE_1.spaceId), - }, - hiddenType: { - statusCode: 404, - response: expectHiddenTypeNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(SPACES.SPACE_1.spaceId), - }, - }, - }); + const { addTests, createTestDefinitions } = getTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false, { spaceId }); + }; - getTest(`can't access space aware objects belonging to another space (space_1)`, { - spaceId: SPACES.DEFAULT.spaceId, - otherSpaceId: SPACES.SPACE_1.spaceId, - tests: { - spaceAware: { - statusCode: 404, - response: createExpectSpaceAwareNotFound(SPACES.SPACE_1.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(SPACES.SPACE_1.spaceId), - }, - hiddenType: { - statusCode: 404, - response: expectHiddenTypeNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(SPACES.SPACE_1.spaceId), - }, - }, + describe('_get', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts index c78a0e1cc2cce..5fe4b08d91b54 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts @@ -5,56 +5,48 @@ */ import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { importTestSuiteFactory } from '../../common/suites/import'; +import { importTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/import'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (spaceId: string) => [ + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(spaceId === DEFAULT_SPACE_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(spaceId === SPACE_2_ID) }, + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409() }, + { ...CASES.HIDDEN, ...fail400() }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, +]; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - importTest, - createExpectResults, - expectUnknownTypeUnsupported, - expectHiddenTypeUnsupported, - } = importTestSuiteFactory(es, esArchiver, supertest); + const { addTests, createTestDefinitions } = importTestSuiteFactory(es, esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false, { spaceId, singleRequest: true }); + }; describe('_import', () => { - importTest('in the current space (space_1)', { - ...SPACES.SPACE_1, - tests: { - default: { - statusCode: 200, - response: createExpectResults(SPACES.SPACE_1.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - importTest('in the default space', { - ...SPACES.DEFAULT, - tests: { - default: { - statusCode: 200, - response: createExpectResults(SPACES.DEFAULT.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts index bb481e0c98bc1..c2f8339d38c97 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts @@ -12,6 +12,7 @@ export default function({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./bulk_create')); loadTestFile(require.resolve('./bulk_get')); + loadTestFile(require.resolve('./bulk_update')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./export')); @@ -20,6 +21,5 @@ export default function({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./import')); loadTestFile(require.resolve('./resolve_import_errors')); loadTestFile(require.resolve('./update')); - loadTestFile(require.resolve('./bulk_update')); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts index 22a7ab81e5530..04f9ac8414afd 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts @@ -5,56 +5,59 @@ */ import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { resolveImportErrorsTestSuiteFactory } from '../../common/suites/resolve_import_errors'; +import { + resolveImportErrorsTestSuiteFactory, + TEST_CASES as CASES, +} from '../../common/suites/resolve_import_errors'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (overwrite: boolean, spaceId: string) => [ + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + { + ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + { ...CASES.HIDDEN, ...fail400() }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, +]; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - resolveImportErrorsTest, - createExpectResults, - expectUnknownTypeUnsupported, - expectHiddenTypeUnsupported, - } = resolveImportErrorsTestSuiteFactory(es, esArchiver, supertest); + const { addTests, createTestDefinitions } = resolveImportErrorsTestSuiteFactory( + es, + esArchiver, + supertest + ); + const createTests = (overwrite: boolean, spaceId: string) => { + const testCases = createTestCases(overwrite, spaceId); + return createTestDefinitions(testCases, false, overwrite, { spaceId, singleRequest: true }); + }; describe('_resolve_import_errors', () => { - resolveImportErrorsTest('in the current space (space_1)', { - ...SPACES.SPACE_1, - tests: { - default: { - statusCode: 200, - response: createExpectResults(SPACES.SPACE_1.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - resolveImportErrorsTest('in the default space', { - ...SPACES.DEFAULT, - tests: { - default: { - statusCode: 200, - response: createExpectResults(SPACES.DEFAULT.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, + getTestScenarios([false, true]).spaces.forEach(({ spaceId, modifier: overwrite }) => { + const suffix = overwrite ? ' with overwrite enabled' : ''; + const tests = createTests(overwrite!, spaceId); + addTests(`within the ${spaceId} space${suffix}`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts index 6ffbda511d871..381861e33b68d 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts @@ -5,88 +5,48 @@ */ import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { updateTestSuiteFactory } from '../../common/suites/update'; +import { updateTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/update'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => [ + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.HIDDEN, ...fail404() }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, +]; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - describe('update', () => { - const { - createExpectSpaceAwareNotFound, - expectSpaceAwareResults, - createExpectDoesntExistNotFound, - expectNotSpaceAwareResults, - expectSpaceNotFound, - updateTest, - } = updateTestSuiteFactory(esArchiver, supertest); - - updateTest(`in the default space`, { - spaceId: SPACES.DEFAULT.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 404, - response: expectSpaceNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(SPACES.DEFAULT.spaceId), - }, - }, - }); - - updateTest('in the current space (space_1)', { - spaceId: SPACES.SPACE_1.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 404, - response: expectSpaceNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(SPACES.SPACE_1.spaceId), - }, - }, - }); + const { addTests, createTestDefinitions } = updateTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false); + }; - updateTest('objects that exist in another space (space_1)', { - spaceId: SPACES.DEFAULT.spaceId, - otherSpaceId: SPACES.SPACE_1.spaceId, - tests: { - spaceAware: { - statusCode: 404, - response: createExpectSpaceAwareNotFound(SPACES.SPACE_1.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 404, - response: expectSpaceNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, + describe('_update', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/spaces_api_integration/common/config.ts b/x-pack/test/spaces_api_integration/common/config.ts index dffc6c524cc6e..19743e09f9420 100644 --- a/x-pack/test/spaces_api_integration/common/config.ts +++ b/x-pack/test/spaces_api_integration/common/config.ts @@ -67,6 +67,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) // disable anonymouse access so that we're testing both on and off in different suites '--status.allowAnonymous=false', '--server.xsrf.disableProtection=true', + `--plugin-path=${path.join(__dirname, 'fixtures', 'shared_type_plugin')}`, ...disabledPlugins.map(key => `--xpack.${key}.enabled=false`), ], }, diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 64c1be0b90071..9a8a0a1fdda14 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -376,3 +376,123 @@ "type": "_doc" } } + +{ + "type": "doc", + "value": { + "id": "sharedtype:default_space_only", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in the default space" + }, + "type": "sharedtype", + "namespaces": ["default"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:space_1_only", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in the space_1 space" + }, + "type": "sharedtype", + "namespaces": ["space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:space_2_only", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in the space_2 space" + }, + "type": "sharedtype", + "namespaces": ["space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:default_and_space_1", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in the default and space_1 spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:default_and_space_2", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in the default and space_2 spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:space_1_and_space_2", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in the space_1 and space_2 spaces" + }, + "type": "sharedtype", + "namespaces": ["space_1", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:all_spaces", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in the default, space_1, and space_2 spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index 1440585b625b9..508de68c32f70 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -159,6 +159,9 @@ "namespace": { "type": "keyword" }, + "namespaces": { + "type": "keyword" + }, "search": { "properties": { "columns": { @@ -320,6 +323,19 @@ "type": "text" } } + }, + "sharedtype": { + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } } } }, diff --git a/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/index.js b/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/index.js new file mode 100644 index 0000000000000..91a24fb9f4f56 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/index.js @@ -0,0 +1,27 @@ +/* + * 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 mappings from './mappings.json'; + +export default function(kibana) { + return new kibana.Plugin({ + require: ['kibana', 'elasticsearch', 'xpack_main'], + name: 'shared_type_plugin', + uiExports: { + savedObjectsManagement: {}, + savedObjectSchemas: { + sharedtype: { + multiNamespace: true, + }, + }, + mappings, + }, + + config() {}, + + init() {}, // need empty init for plugin to load + }); +} diff --git a/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/mappings.json b/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/mappings.json new file mode 100644 index 0000000000000..918958aec0d6d --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/mappings.json @@ -0,0 +1,15 @@ +{ + "sharedtype": { + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + } +} diff --git a/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/package.json b/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/package.json new file mode 100644 index 0000000000000..c52f4256c5c06 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "shared_type_plugin", + "version": "0.0.0", + "kibana": { + "version": "kibana" + } +} diff --git a/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts b/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts new file mode 100644 index 0000000000000..67f5d737ba010 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES = Object.freeze({ + DEFAULT_SPACE_ONLY: Object.freeze({ + id: 'default_space_only', + existingNamespaces: ['default'], + }), + SPACE_1_ONLY: Object.freeze({ + id: 'space_1_only', + existingNamespaces: ['space_1'], + }), + SPACE_2_ONLY: Object.freeze({ + id: 'space_2_only', + existingNamespaces: ['space_2'], + }), + DEFAULT_AND_SPACE_1: Object.freeze({ + id: 'default_and_space_1', + existingNamespaces: ['default', 'space_1'], + }), + DEFAULT_AND_SPACE_2: Object.freeze({ + id: 'default_and_space_2', + existingNamespaces: ['default', 'space_2'], + }), + SPACE_1_AND_SPACE_2: Object.freeze({ + id: 'space_1_and_space_2', + existingNamespaces: ['space_1', 'space_2'], + }), + ALL_SPACES: Object.freeze({ + id: 'all_spaces', + existingNamespaces: ['default', 'space_1', 'space_2'], + }), + DOES_NOT_EXIST: Object.freeze({ + id: 'does_not_exist', + existingNamespaces: [] as string[], + }), +}); diff --git a/x-pack/test/spaces_api_integration/common/suites/delete.ts b/x-pack/test/spaces_api_integration/common/suites/delete.ts index 9036fcbf7a8dd..0d8728fdf622e 100644 --- a/x-pack/test/spaces_api_integration/common/suites/delete.ts +++ b/x-pack/test/spaces_api_integration/common/suites/delete.ts @@ -6,6 +6,7 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import { getUrlPrefix } from '../lib/space_test_utils'; +import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; interface DeleteTest { @@ -128,6 +129,25 @@ export function deleteTestSuiteFactory(es: any, esArchiver: any, supertest: Supe ]; expect(buckets).to.eql(expectedBuckets); + + // There were seven multi-namespace objects. + // Since Space 2 was deleted, any multi-namespace objects that existed in that space + // are updated to remove it, and of those, any that don't exist in any space are deleted. + const multiNamespaceResponse = await es.search({ + index: '.kibana', + body: { query: { terms: { type: ['sharedtype'] } } }, + }); + const docs: [Record] = multiNamespaceResponse.hits.hits; + expect(docs).length(6); // just six results, since spaces_2_only got deleted + Object.values(CASES).forEach(({ id, existingNamespaces }) => { + const remainingNamespaces = existingNamespaces.filter(x => x !== 'space_2'); + const doc = docs.find(x => x._id === `sharedtype:${id}`); + if (remainingNamespaces.length > 0) { + expect(doc?._source?.namespaces).to.eql(remainingNamespaces); + } else { + expect(doc).to.be(undefined); + } + }); }; const expectNotFound = (resp: { [key: string]: any }) => { diff --git a/x-pack/test/spaces_api_integration/common/suites/share_add.ts b/x-pack/test/spaces_api_integration/common/suites/share_add.ts new file mode 100644 index 0000000000000..b9a012b606da3 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/suites/share_add.ts @@ -0,0 +1,118 @@ +/* + * 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 { SuperTest } from 'supertest'; +import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; +import { SPACES } from '../lib/spaces'; +import { + expectResponses, + getUrlPrefix, +} from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { + ExpectResponseBody, + TestDefinition, + TestSuite, +} from '../../../saved_object_api_integration/common/lib/types'; + +export interface ShareAddTestDefinition extends TestDefinition { + request: { spaces: string[]; object: { type: string; id: string } }; +} +export type ShareAddTestSuite = TestSuite; +export interface ShareAddTestCase { + id: string; + namespaces: string[]; + failure?: 400 | 403 | 404; + fail400Param?: string; + fail403Param?: string; +} + +const TYPE = 'sharedtype'; +const createRequest = ({ id, namespaces }: ShareAddTestCase) => ({ + spaces: namespaces, + object: { type: TYPE, id }, +}); +const getTestTitle = ({ id, namespaces }: ShareAddTestCase) => + `{id: ${id}, namespaces: [${namespaces.join(',')}]}`; + +export function shareAddTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const expectResponseBody = (testCase: ShareAddTestCase): ExpectResponseBody => async ( + response: Record + ) => { + const { id, failure, fail400Param, fail403Param } = testCase; + const object = response.body; + if (failure === 403) { + await expectResponses.forbidden(fail403Param!)(TYPE)(response); + } else if (failure) { + let error: any; + if (failure === 400) { + error = SavedObjectsErrorHelpers.createBadRequestError( + `${id} already exists in the following namespace(s): ${fail400Param}` + ); + } else if (failure === 404) { + error = SavedObjectsErrorHelpers.createGenericNotFoundError(TYPE, id); + } + expect(object.error).to.eql(error.output.payload.error); + expect(object.statusCode).to.eql(error.output.payload.statusCode); + } else { + // success + expect(object).to.eql({}); + } + }; + const createTestDefinitions = ( + testCases: ShareAddTestCase | ShareAddTestCase[], + forbidden: boolean, + options?: { + responseBodyOverride?: ExpectResponseBody; + fail403Param?: string; + } + ): ShareAddTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map(x => ({ ...x, failure: 403, fail403Param: options?.fail403Param })); + } + return cases.map(x => ({ + title: getTestTitle(x), + responseStatusCode: x.failure ?? 204, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x), + })); + }; + + const makeShareAddTest = (describeFn: Mocha.SuiteFunction) => ( + description: string, + definition: ShareAddTestSuite + ) => { + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; + + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const requestBody = test.request; + await supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_share_saved_object_add`) + .auth(user?.username, user?.password) + .send(requestBody) + .expect(test.responseStatusCode) + .then(test.responseBody); + }); + } + }); + }; + + const addTests = makeShareAddTest(describe); + // @ts-ignore + addTests.only = makeShareAddTest(describe.only); + + return { + addTests, + createTestDefinitions, + }; +} diff --git a/x-pack/test/spaces_api_integration/common/suites/share_remove.ts b/x-pack/test/spaces_api_integration/common/suites/share_remove.ts new file mode 100644 index 0000000000000..b5fcbe5a1cf2c --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/suites/share_remove.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { SuperTest } from 'supertest'; +import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; +import { SPACES } from '../lib/spaces'; +import { + expectResponses, + getUrlPrefix, + getTestTitle, +} from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { + ExpectResponseBody, + TestDefinition, + TestSuite, +} from '../../../saved_object_api_integration/common/lib/types'; + +export interface ShareRemoveTestDefinition extends TestDefinition { + request: { spaces: string[]; object: { type: string; id: string } }; +} +export type ShareRemoveTestSuite = TestSuite; +export interface ShareRemoveTestCase { + id: string; + namespaces: string[]; + failure?: 400 | 403 | 404; + fail400Param?: string; +} + +const TYPE = 'sharedtype'; +const createRequest = ({ id, namespaces }: ShareRemoveTestCase) => ({ + spaces: namespaces, + object: { type: TYPE, id }, +}); + +export function shareRemoveTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const expectForbidden = expectResponses.forbidden('delete'); + const expectResponseBody = (testCase: ShareRemoveTestCase): ExpectResponseBody => async ( + response: Record + ) => { + const { id, failure, fail400Param } = testCase; + const object = response.body; + if (failure === 403) { + await expectForbidden(TYPE)(response); + } else if (failure) { + let error: any; + if (failure === 400) { + error = SavedObjectsErrorHelpers.createBadRequestError( + `${id} doesn't exist in the following namespace(s): ${fail400Param}` + ); + } else if (failure === 404) { + error = SavedObjectsErrorHelpers.createGenericNotFoundError(TYPE, id); + } + expect(object.error).to.eql(error.output.payload.error); + expect(object.statusCode).to.eql(error.output.payload.statusCode); + } else { + // success + expect(object).to.eql({}); + } + }; + const createTestDefinitions = ( + testCases: ShareRemoveTestCase | ShareRemoveTestCase[], + forbidden: boolean, + options?: { + responseBodyOverride?: ExpectResponseBody; + } + ): ShareRemoveTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map(x => ({ ...x, failure: 403 })); + } + return cases.map(x => ({ + title: getTestTitle({ ...x, type: TYPE }), + responseStatusCode: x.failure ?? 204, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x), + })); + }; + + const makeShareRemoveTest = (describeFn: Mocha.SuiteFunction) => ( + description: string, + definition: ShareRemoveTestSuite + ) => { + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; + + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const requestBody = test.request; + await supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_share_saved_object_remove`) + .auth(user?.username, user?.password) + .send(requestBody) + .expect(test.responseStatusCode) + .then(test.responseBody); + }); + } + }); + }; + + const addTests = makeShareRemoveTest(describe); + // @ts-ignore + addTests.only = makeShareRemoveTest(describe.only); + + return { + addTests, + createTestDefinitions, + }; +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts index e918ab0b53841..8d85d95e6812f 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts @@ -25,6 +25,8 @@ export default function({ loadTestFile, getService }: TestInvoker) { loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./get_all')); loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./share_add')); + loadTestFile(require.resolve('./share_remove')); loadTestFile(require.resolve('./update')); }); } diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts new file mode 100644 index 0000000000000..c7e65ac424776 --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SPACES } from '../../common/lib/spaces'; +import { + testCaseFailures, + getTestScenarios, +} from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { TestUser } from '../../../saved_object_api_integration/common/lib/types'; +import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../../common/lib/saved_object_test_cases'; +import { TestInvoker } from '../../common/lib/types'; +import { shareAddTestSuiteFactory, ShareAddTestDefinition } from '../../common/suites/share_add'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + const namespaces = [spaceId]; + return [ + // Test cases to check adding the target namespace to different saved objects + { ...CASES.DEFAULT_SPACE_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SPACE_1_ONLY, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SPACE_2_ONLY, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, + { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, + { ...CASES.DEFAULT_AND_SPACE_2, namespaces, ...fail404(spaceId === SPACE_1_ID) }, + { ...CASES.SPACE_1_AND_SPACE_2, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, + { ...CASES.ALL_SPACES, namespaces }, + { ...CASES.DOES_NOT_EXIST, namespaces, ...fail404() }, + // Test cases to check adding multiple namespaces to different saved objects that exist in one space + // These are non-exhaustive, they only check cases for adding two additional namespaces to a saved object + // More permutations are covered in the corresponding spaces_only test suite + { + ...CASES.DEFAULT_SPACE_ONLY, + namespaces: [SPACE_1_ID, SPACE_2_ID], + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.SPACE_1_ONLY, + namespaces: [DEFAULT_SPACE_ID, SPACE_2_ID], + ...fail404(spaceId !== SPACE_1_ID), + }, + { + ...CASES.SPACE_2_ONLY, + namespaces: [DEFAULT_SPACE_ID, SPACE_1_ID], + ...fail404(spaceId !== SPACE_2_ID), + }, + ]; +}; +const calculateSingleSpaceAuthZ = ( + testCases: ReturnType, + spaceId: string +) => { + const targetsOtherSpace = testCases.filter( + x => !x.namespaces.includes(spaceId) || x.namespaces.length > 1 + ); + const tmp = testCases.filter(x => !targetsOtherSpace.includes(x)); // doesn't target other space + const doesntExistInThisSpace = tmp.filter(x => !x.existingNamespaces.includes(spaceId)); + const existsInThisSpace = tmp.filter(x => x.existingNamespaces.includes(spaceId)); + return { targetsOtherSpace, doesntExistInThisSpace, existsInThisSpace }; +}; +// eslint-disable-next-line import/no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = shareAddTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + const thisSpace = calculateSingleSpaceAuthZ(testCases, spaceId); + const otherSpaceId = spaceId === DEFAULT_SPACE_ID ? SPACE_1_ID : DEFAULT_SPACE_ID; + const otherSpace = calculateSingleSpaceAuthZ(testCases, otherSpaceId); + return { + unauthorized: createTestDefinitions(testCases, true, { fail403Param: 'create' }), + authorizedInSpace: [ + createTestDefinitions(thisSpace.targetsOtherSpace, true, { fail403Param: 'create' }), + createTestDefinitions(thisSpace.doesntExistInThisSpace, false), + createTestDefinitions(thisSpace.existsInThisSpace, false), + ].flat(), + authorizedInOtherSpace: [ + createTestDefinitions(otherSpace.targetsOtherSpace, true, { fail403Param: 'create' }), + // If the preflight GET request fails, it will return a 404 error; users who are authorized to create saved objects in the target + // space(s) but are not authorized to update saved objects in this space will see a 403 error instead of 404. This is a safeguard to + // prevent potential information disclosure of the spaces that a given saved object may exist in. + createTestDefinitions(otherSpace.doesntExistInThisSpace, true, { fail403Param: 'update' }), + createTestDefinitions(otherSpace.existsInThisSpace, false), + ].flat(), + authorized: createTestDefinitions(testCases, false), + }; + }; + + describe('_share_saved_object_add', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` targeting the ${spaceId} space`; + const { unauthorized, authorizedInSpace, authorizedInOtherSpace, authorized } = createTests( + spaceId + ); + const _addTests = (user: TestUser, tests: ShareAddTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; + + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + ].forEach(user => { + _addTests(user, unauthorized); + }); + _addTests(users.allAtSpace, authorizedInSpace); + _addTests(users.allAtOtherSpace, authorizedInOtherSpace); + [users.dualAll, users.allGlobally, users.superuser].forEach(user => { + _addTests(user, authorized); + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts new file mode 100644 index 0000000000000..3a8d42f620a3e --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SPACES } from '../../common/lib/spaces'; +import { + testCaseFailures, + getTestScenarios, +} from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { TestUser } from '../../../saved_object_api_integration/common/lib/types'; +import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../../common/lib/saved_object_test_cases'; +import { TestInvoker } from '../../common/lib/types'; +import { + shareRemoveTestSuiteFactory, + ShareRemoveTestCase, + ShareRemoveTestDefinition, +} from '../../common/suites/share_remove'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + // Test cases to check removing the target namespace from different saved objects + let namespaces = [spaceId]; + const singleSpace = [ + { id: CASES.DEFAULT_SPACE_ONLY.id, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { id: CASES.SPACE_1_ONLY.id, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, + { id: CASES.SPACE_2_ONLY.id, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, + { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces, ...fail404(spaceId === SPACE_2_ID) }, + { id: CASES.DEFAULT_AND_SPACE_2.id, namespaces, ...fail404(spaceId === SPACE_1_ID) }, + { id: CASES.SPACE_1_AND_SPACE_2.id, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, + { id: CASES.ALL_SPACES.id, namespaces }, + { id: CASES.DOES_NOT_EXIST.id, namespaces, ...fail404() }, + ] as ShareRemoveTestCase[]; + + // Test cases to check removing all three namespaces from different saved objects that exist in two spaces + // These are non-exhaustive, they only check some cases -- each object will result in a 404, either because + // it never existed in the target namespace, or it was removed in one of the test cases above + // More permutations are covered in the corresponding spaces_only test suite + namespaces = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; + const multipleSpaces = [ + { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces, ...fail404() }, + { id: CASES.DEFAULT_AND_SPACE_2.id, namespaces, ...fail404() }, + { id: CASES.SPACE_1_AND_SPACE_2.id, namespaces, ...fail404() }, + ] as ShareRemoveTestCase[]; + + const allCases = singleSpace.concat(multipleSpaces); + return { singleSpace, multipleSpaces, allCases }; +}; + +// eslint-disable-next-line import/no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = shareRemoveTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const { singleSpace, multipleSpaces, allCases } = createTestCases(spaceId); + return { + unauthorized: createTestDefinitions(allCases, true), + authorizedThisSpace: [ + createTestDefinitions(singleSpace, false), + createTestDefinitions(multipleSpaces, true), + ].flat(), + authorizedGlobally: createTestDefinitions(allCases, false), + }; + }; + + describe('_share_saved_object_remove', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` targeting the ${spaceId} space`; + const { unauthorized, authorizedThisSpace, authorizedGlobally } = createTests(spaceId); + const _addTests = (user: TestUser, tests: ShareRemoveTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; + + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach(user => { + _addTests(user, unauthorized); + }); + _addTests(users.allAtSpace, authorizedThisSpace); + [users.dualAll, users.allGlobally, users.superuser].forEach(user => { + _addTests(user, authorizedGlobally); + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts index 1182f6bdabcff..b02dc35b58b56 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts @@ -17,6 +17,8 @@ export default function spacesOnlyTestSuite({ loadTestFile }: TestInvoker) { loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./get_all')); loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./share_add')); + loadTestFile(require.resolve('./share_remove')); loadTestFile(require.resolve('./update')); }); } diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts new file mode 100644 index 0000000000000..f1e603836fa21 --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts @@ -0,0 +1,84 @@ +/* + * 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 { SPACES } from '../../common/lib/spaces'; +import { + testCaseFailures, + getTestScenarios, +} from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { TestInvoker } from '../../common/lib/types'; +import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../../common/lib/saved_object_test_cases'; +import { shareAddTestSuiteFactory } from '../../common/suites/share_add'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +/** + * Single-namespace test cases + * @param spaceId the namespace to add to each saved object + */ +const createSingleTestCases = (spaceId: string) => { + const namespaces = ['some-space-id']; + return [ + { ...CASES.DEFAULT_SPACE_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SPACE_1_ONLY, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SPACE_2_ONLY, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, + { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, + { ...CASES.DEFAULT_AND_SPACE_2, namespaces, ...fail404(spaceId === SPACE_1_ID) }, + { ...CASES.SPACE_1_AND_SPACE_2, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, + { ...CASES.ALL_SPACES, namespaces }, + { ...CASES.DOES_NOT_EXIST, namespaces, ...fail404() }, + ]; +}; +/** + * Multi-namespace test cases + * These are non-exhaustive, but they check different permutations of saved objects and spaces to add + */ +const createMultiTestCases = () => { + const allSpaces = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; + let id = CASES.DEFAULT_SPACE_ONLY.id; + const one = [{ id, namespaces: allSpaces }]; + id = CASES.DEFAULT_AND_SPACE_1.id; + const two = [{ id, namespaces: allSpaces }]; + id = CASES.ALL_SPACES.id; + const three = [{ id, namespaces: allSpaces }]; + return { one, two, three }; +}; + +// eslint-disable-next-line import/no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = shareAddTestSuiteFactory(esArchiver, supertest); + const createSingleTests = (spaceId: string) => { + const testCases = createSingleTestCases(spaceId); + return createTestDefinitions(testCases, false); + }; + const createMultiTests = () => { + const testCases = createMultiTestCases(); + return { + one: createTestDefinitions(testCases.one, false), + two: createTestDefinitions(testCases.two, false), + three: createTestDefinitions(testCases.three, false), + }; + }; + + describe('_share_saved_object_add', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createSingleTests(spaceId); + addTests(`targeting the ${spaceId} space`, { spaceId, tests }); + }); + const { one, two, three } = createMultiTests(); + addTests('for a saved object in the default space', { tests: one }); + addTests('for a saved object in the default and space_1 spaces', { tests: two }); + addTests('for a saved object in the default, space_1, and space_2 spaces', { tests: three }); + }); +} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts new file mode 100644 index 0000000000000..15be72c9f09ac --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SPACES } from '../../common/lib/spaces'; +import { + testCaseFailures, + getTestScenarios, +} from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { TestInvoker } from '../../common/lib/types'; +import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../../common/lib/saved_object_test_cases'; +import { shareRemoveTestSuiteFactory } from '../../common/suites/share_remove'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +/** + * Single-namespace test cases + * @param spaceId the namespace to remove from each saved object + */ +const createSingleTestCases = (spaceId: string) => { + const namespaces = [spaceId]; + return [ + { ...CASES.DEFAULT_SPACE_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SPACE_1_ONLY, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SPACE_2_ONLY, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, + { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, + { ...CASES.DEFAULT_AND_SPACE_2, namespaces, ...fail404(spaceId === SPACE_1_ID) }, + { ...CASES.SPACE_1_AND_SPACE_2, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, + { ...CASES.ALL_SPACES, namespaces }, + { ...CASES.DOES_NOT_EXIST, namespaces, ...fail404() }, + ]; +}; +/** + * Multi-namespace test cases + * These are non-exhaustive, but they check different permutations of saved objects and spaces to remove + */ +const createMultiTestCases = () => { + const nonExistentSpaceId = 'does_not_exist'; // space that doesn't exist + let id = CASES.DEFAULT_SPACE_ONLY.id; + const one = [ + { id, namespaces: [nonExistentSpaceId] }, + { id, namespaces: [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID] }, + { id, namespaces: [DEFAULT_SPACE_ID], ...fail404() }, // this saved object no longer exists + ]; + id = CASES.DEFAULT_AND_SPACE_1.id; + const two = [ + { id, namespaces: [DEFAULT_SPACE_ID, nonExistentSpaceId] }, + // this saved object will not be found in the context of the current namespace ('default') + { id, namespaces: [DEFAULT_SPACE_ID], ...fail404() }, // this object's namespaces no longer contains DEFAULT_SPACE_ID + { id, namespaces: [SPACE_1_ID], ...fail404() }, // this object's namespaces does contain SPACE_1_ID + ]; + id = CASES.ALL_SPACES.id; + const three = [ + { id, namespaces: [DEFAULT_SPACE_ID, SPACE_1_ID, nonExistentSpaceId] }, + // this saved object will not be found in the context of the current namespace ('default') + { id, namespaces: [DEFAULT_SPACE_ID], ...fail404() }, // this object's namespaces no longer contains DEFAULT_SPACE_ID + { id, namespaces: [SPACE_1_ID], ...fail404() }, // this object's namespaces no longer contains SPACE_1_ID + { id, namespaces: [SPACE_2_ID], ...fail404() }, // this object's namespaces does contain SPACE_2_ID + ]; + return { one, two, three }; +}; + +// eslint-disable-next-line import/no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = shareRemoveTestSuiteFactory(esArchiver, supertest); + const createSingleTests = (spaceId: string) => { + const testCases = createSingleTestCases(spaceId); + return createTestDefinitions(testCases, false); + }; + const createMultiTests = () => { + const testCases = createMultiTestCases(); + return { + one: createTestDefinitions(testCases.one, false), + two: createTestDefinitions(testCases.two, false), + three: createTestDefinitions(testCases.three, false), + }; + }; + + describe('_share_saved_object_remove', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createSingleTests(spaceId); + addTests(`targeting the ${spaceId} space`, { spaceId, tests }); + }); + const { one, two, three } = createMultiTests(); + addTests('for a saved object in the default space', { tests: one }); + addTests('for a saved object in the default and space_1 spaces', { tests: two }); + addTests('for a saved object in the default, space_1, and space_2 spaces', { tests: three }); + }); +} diff --git a/x-pack/typings/rison_node.d.ts b/x-pack/typings/rison_node.d.ts index ec8e5c1f407ad..f830adc897445 100644 --- a/x-pack/typings/rison_node.d.ts +++ b/x-pack/typings/rison_node.d.ts @@ -5,7 +5,7 @@ */ declare module 'rison-node' { - export type RisonValue = null | boolean | number | string | RisonObject | RisonArray; + export type RisonValue = undefined | null | boolean | number | string | RisonObject | RisonArray; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface RisonArray extends Array {} diff --git a/yarn.lock b/yarn.lock index 3f04b2d26a013..34ee6f393f25a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -61,7 +61,7 @@ semver "^5.4.1" source-map "^0.5.0" -"@babel/core@^7.0.0", "@babel/core@^7.0.1", "@babel/core@^7.1.0", "@babel/core@^7.4.3", "@babel/core@^7.9.0": +"@babel/core@^7.0.0", "@babel/core@^7.0.1", "@babel/core@^7.1.0", "@babel/core@^7.4.3", "@babel/core@^7.7.5", "@babel/core@^7.9.0": version "7.9.0" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.9.0.tgz#ac977b538b77e132ff706f3b8a4dbad09c03c56e" integrity sha512-kWc7L0fw1xwvI0zi8OKVBuxRVefwGOrKSQMvrQ3dW+bIIavBY3/NpXmpjMy7bQnLgwgzWQZ8TlM57YHpHNHz4w== @@ -83,7 +83,7 @@ semver "^5.4.1" source-map "^0.5.0" -"@babel/generator@^7.0.0", "@babel/generator@^7.4.0", "@babel/generator@^7.5.5", "@babel/generator@^7.9.0": +"@babel/generator@^7.0.0", "@babel/generator@^7.5.5", "@babel/generator@^7.9.0": version "7.9.4" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.9.4.tgz#12441e90c3b3c4159cdecf312075bf1a8ce2dbce" integrity sha512-rjP8ahaDy/ouhrvCoU1E5mqaitWrxwuNGU+dy1EpaoK48jZay4MdkskKGIMHLZNewg8sAsqpGSREJwP0zH3YQA== @@ -93,6 +93,16 @@ lodash "^4.17.13" source-map "^0.5.0" +"@babel/generator@^7.9.5": + version "7.9.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.9.5.tgz#27f0917741acc41e6eaaced6d68f96c3fa9afaf9" + integrity sha512-GbNIxVB3ZJe3tLeDm1HSn2AhuD/mVcyLDpgtLXa5tplmWrJdF/elxB56XNqCuD6szyNkDi6wuoKXln3QeBmCHQ== + dependencies: + "@babel/types" "^7.9.5" + jsesc "^2.5.1" + lodash "^4.17.13" + source-map "^0.5.0" + "@babel/helper-annotate-as-pure@^7.0.0", "@babel/helper-annotate-as-pure@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.8.3.tgz#60bc0bc657f63a0924ff9a4b4a0b24a13cf4deee" @@ -183,6 +193,15 @@ "@babel/template" "^7.8.3" "@babel/types" "^7.8.3" +"@babel/helper-function-name@^7.9.5": + version "7.9.5" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.9.5.tgz#2b53820d35275120e1874a82e5aabe1376920a5c" + integrity sha512-JVcQZeXM59Cd1qanDUxv9fgJpt3NeKUaqBqUEvfmQ+BCOKq2xUgaWZW2hr0dkbyJgezYuplEoh5knmrnS68efw== + dependencies: + "@babel/helper-get-function-arity" "^7.8.3" + "@babel/template" "^7.8.3" + "@babel/types" "^7.9.5" + "@babel/helper-get-function-arity@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz#b894b947bd004381ce63ea1db9f08547e920abd5" @@ -284,6 +303,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.0.tgz#ad53562a7fc29b3b9a91bbf7d10397fd146346ed" integrity sha512-6G8bQKjOh+of4PV/ThDm/rRqlU7+IGoJuofpagU5GlEl29Vv0RGqqt86ZGRV8ZuSOY3o+8yXl5y782SMcG7SHw== +"@babel/helper-validator-identifier@^7.9.5": + version "7.9.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.5.tgz#90977a8e6fbf6b431a7dc31752eee233bf052d80" + integrity sha512-/8arLKUFq882w4tWGj9JYzRpAlZgiWUJ+dtteNTDqrRBz9Iguck9Rn3ykuBDoUwh2TO4tSAJlrxDUOXWklJe4g== + "@babel/helper-wrap-function@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.8.3.tgz#9dbdb2bb55ef14aaa01fe8c99b629bd5352d8610" @@ -312,7 +336,7 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.2.0", "@babel/parser@^7.4.3", "@babel/parser@^7.5.5", "@babel/parser@^7.8.6", "@babel/parser@^7.9.0", "@babel/parser@^7.9.3": +"@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.2.0", "@babel/parser@^7.5.5", "@babel/parser@^7.7.5", "@babel/parser@^7.8.6", "@babel/parser@^7.9.0", "@babel/parser@^7.9.3": version "7.9.4" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.9.4.tgz#68a35e6b0319bbc014465be43828300113f2f2e8" integrity sha512-bC49otXX6N0/VYhgOMh4gnP26E9xnDZK3TmbNpxYzzz9BQLBosQwfyOe9/cXUU3txYhTzLCbcqd5c8y/OmCjHA== @@ -1101,7 +1125,7 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/template@^7.0.0", "@babel/template@^7.4.0", "@babel/template@^7.4.4", "@babel/template@^7.8.3", "@babel/template@^7.8.6": +"@babel/template@^7.0.0", "@babel/template@^7.4.4", "@babel/template@^7.7.4", "@babel/template@^7.8.3", "@babel/template@^7.8.6": version "7.8.6" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b" integrity sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg== @@ -1110,7 +1134,7 @@ "@babel/parser" "^7.8.6" "@babel/types" "^7.8.6" -"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.1.6", "@babel/traverse@^7.4.3", "@babel/traverse@^7.4.5", "@babel/traverse@^7.5.5", "@babel/traverse@^7.8.3", "@babel/traverse@^7.8.6", "@babel/traverse@^7.9.0": +"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.1.6", "@babel/traverse@^7.4.5", "@babel/traverse@^7.5.5", "@babel/traverse@^7.8.3", "@babel/traverse@^7.8.6", "@babel/traverse@^7.9.0": version "7.9.0" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.9.0.tgz#d3882c2830e513f4fe4cec9fe76ea1cc78747892" integrity sha512-jAZQj0+kn4WTHO5dUZkZKhbFrqZE7K5LAQ5JysMnmvGij+wOdr+8lWqPeW0BcF4wFwrEXXtdGO7wcV6YPJcf3w== @@ -1125,6 +1149,21 @@ globals "^11.1.0" lodash "^4.17.13" +"@babel/traverse@^7.7.4": + version "7.9.5" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.9.5.tgz#6e7c56b44e2ac7011a948c21e283ddd9d9db97a2" + integrity sha512-c4gH3jsvSuGUezlP6rzSJ6jf8fYjLj3hsMZRx/nX0h+fmHN0w+ekubRrHPqnMec0meycA2nwCsJ7dC8IPem2FQ== + dependencies: + "@babel/code-frame" "^7.8.3" + "@babel/generator" "^7.9.5" + "@babel/helper-function-name" "^7.9.5" + "@babel/helper-split-export-declaration" "^7.8.3" + "@babel/parser" "^7.9.0" + "@babel/types" "^7.9.5" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.13" + "@babel/types@^7.0.0", "@babel/types@^7.2.0", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.4.4", "@babel/types@^7.5.5", "@babel/types@^7.8.3", "@babel/types@^7.8.6", "@babel/types@^7.9.0": version "7.9.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.9.0.tgz#00b064c3df83ad32b2dbf5ff07312b15c7f1efb5" @@ -1134,6 +1173,15 @@ lodash "^4.17.13" to-fast-properties "^2.0.0" +"@babel/types@^7.9.5": + version "7.9.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.9.5.tgz#89231f82915a8a566a703b3b20133f73da6b9444" + integrity sha512-XjnvNqenk818r5zMaba+sLQjnbda31UfUURv3ei0qPQw4u+j2jMyJ5b11y8ZHYTRSI3NnInQkkkRT4fLqqPdHg== + dependencies: + "@babel/helper-validator-identifier" "^7.9.5" + lodash "^4.17.13" + to-fast-properties "^2.0.0" + "@cnakazawa/watch@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.3.tgz#099139eaec7ebf07a27c1786a3ff64f39464d2ef" @@ -1197,10 +1245,10 @@ dependencies: "@elastic/apm-rum-core" "^4.7.0" -"@elastic/charts@^18.1.1": - version "18.2.0" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-18.2.0.tgz#e141151b4d7ecc71c9f6f235f8ce141665c67195" - integrity sha512-OWsARaHI/4Ict/GkeKIO3a+e2c86esGw3FtSGRLPFVgzpwBXdjvjYyraGntKOIVs/NAGNVWYj5XoRRb5C6cMlQ== +"@elastic/charts@18.3.0": + version "18.3.0" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-18.3.0.tgz#cbdeec1860af274edc7a5f5b9dd26ec48c64bb64" + integrity sha512-4kSlSwdDRsVKVX8vRUkwxOu1IT6WIepgLnP0OZT7cFjgrC1SV/16c3YLw2NZDaVe0M/H4rpeNWW30VyrzZVhyw== dependencies: classnames "^2.2.6" d3-array "^1.2.4" @@ -1353,10 +1401,10 @@ resolved "https://registry.yarnpkg.com/@elastic/numeral/-/numeral-2.4.0.tgz#883197b7f4bf3c2dd994f53b274769ddfa2bf79a" integrity sha512-uGBKGCNghTgUZPHClji/00v+AKt5nidPTGOIbcT+lbTPVxNB6QPpPLGWtXyrg3QZAxobPM/LAZB1mAqtJeq44Q== -"@elastic/request-crypto@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@elastic/request-crypto/-/request-crypto-1.1.2.tgz#2e323550f546f6286994126d462a9ea480a3bfb1" - integrity sha512-i73wjj1Qi8dGJIy170Z8xyJ760mFNjTbdmcp/nEczqWD0miNW6I5wZ5MNrv7M6CXn2m1wMXiT6qzDYd93Hv1Dw== +"@elastic/request-crypto@1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@elastic/request-crypto/-/request-crypto-1.1.4.tgz#2189d5fea65f7afe1de9f5fa3d0dd420e93e3124" + integrity sha512-D5CzSGKkM6BdrVB/HRRTheMsNQOcd2FMUup0O/1hIGUBE8zHh2AYbmSNSpD6LyQAgY39mGkARUi/x+SO0ccVvg== dependencies: "@elastic/node-crypto" "1.1.1" "@types/node-jose" "1.1.0" @@ -1542,6 +1590,21 @@ resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8" integrity sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw== +"@istanbuljs/load-nyc-config@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz#10602de5570baea82f8afbfa2630b24e7a8cfe5b" + integrity sha512-ZR0rq/f/E4f4XcgnDvtMWXCUJpi8eO0rssVhmztsZqLIEFA9UUP9zmpE0VxlM+kv/E1ul2I876Fwil2ayptDVg== + dependencies: + camelcase "^5.3.1" + find-up "^4.1.0" + js-yaml "^3.13.1" + resolve-from "^5.0.0" + +"@istanbuljs/schema@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd" + integrity sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw== + "@jest/console@^24.7.1": version "24.7.1" resolved "https://registry.yarnpkg.com/@jest/console/-/console-24.7.1.tgz#32a9e42535a97aedfe037e725bd67e954b459545" @@ -2494,6 +2557,11 @@ resolved "https://registry.yarnpkg.com/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.2.tgz#8013f2af54a2b7d735f71560ff360d3a8176a87b" integrity sha512-vTCdPp/T/Q3oSqwHmZ5Kpa9oI7iLtGl3RQaA/NyLHikvcrPxACkkKVr/XzkSPJWXHRhKGzVvb0urJsbMlRxi1Q== +"@sindresorhus/is@^0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" + integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== + "@sindresorhus/is@^0.7.0": version "0.7.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd" @@ -3403,6 +3471,13 @@ "@svgr/plugin-svgo" "^4.2.0" loader-utils "^1.2.3" +"@szmarczak/http-timer@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" + integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA== + dependencies: + defer-to-connect "^1.0.1" + "@szmarczak/http-timer@^4.0.0": version "4.0.5" resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-4.0.5.tgz#bfbd50211e9dfa51ba07da58a14cdfd333205152" @@ -4173,7 +4248,7 @@ dependencies: "@types/node" "*" -"@types/keyv@*": +"@types/keyv@*", "@types/keyv@^3.1.1": version "3.1.1" resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.1.tgz#e45a45324fca9dab716ab1230ee249c9fb52cfa7" integrity sha512-MPtoySlAZQ37VoLaPcTHCu1RWJ4llDkULYZIzOYxlhxBqYPB0RsRlmMU0R6tahtFe27mIdkHV+551ZWV4PLmVw== @@ -4673,7 +4748,7 @@ resolved "https://registry.yarnpkg.com/@types/seedrandom/-/seedrandom-2.4.28.tgz#9ce8fa048c1e8c85cb71d7fe4d704e000226036f" integrity sha512-SMA+fUwULwK7sd/ZJicUztiPs8F1yCPwF3O23Z9uQ32ME5Ha0NmDK9+QTsYE4O2tHXChzXomSWWeIhCnoN1LqA== -"@types/selenium-webdriver@4.0.9": +"@types/selenium-webdriver@^4.0.9": version "4.0.9" resolved "https://registry.yarnpkg.com/@types/selenium-webdriver/-/selenium-webdriver-4.0.9.tgz#12621e55b2ef8f6c98bd17fe23fa720c6cba16bd" integrity sha512-HopIwBE7GUXsscmt/J0DhnFXLSmO04AfxT6b8HAprknwka7pqEWquWDMXxCjd+NUHK9MkCe1SDKKsMiNmCItbQ== @@ -4998,6 +5073,13 @@ dependencies: "@types/yargs-parser" "*" +"@types/yauzl@^2.9.1": + version "2.9.1" + resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.9.1.tgz#d10f69f9f522eef3cf98e30afb684a1e1ec923af" + integrity sha512-A1b8SU4D10uoPjwb0lnHmmu8wZhR9d+9o2PKBQT2jU5YPTKsxac6M2qGAdY7VcL+dHHhARVUDmeg0rOrcd9EjA== + dependencies: + "@types/node" "*" + "@types/zen-observable@^0.8.0": version "0.8.0" resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d" @@ -5897,6 +5979,40 @@ anymatch@~3.1.1: normalize-path "^3.0.0" picomatch "^2.0.4" +apidoc-core@^0.11.1: + version "0.11.1" + resolved "https://registry.yarnpkg.com/apidoc-core/-/apidoc-core-0.11.1.tgz#b04a7e0292e4ac0d714b40789f1b92f414486c81" + integrity sha512-pt/ICBdFQCZTgL38Aw1XB3G9AajDU1JA5E3yoDEgg0mqbPTCkOL8AyWdysjvNtQS/kkXgSPazCZaZzZYqrPHog== + dependencies: + fs-extra "^8.1.0" + glob "^7.1.4" + iconv-lite "^0.5.0" + klaw-sync "^6.0.0" + lodash "~4.17.15" + semver "~6.3.0" + +apidoc-markdown@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/apidoc-markdown/-/apidoc-markdown-5.0.0.tgz#e2d59d7cbbaa10402b09cec3e8ec17a03a27be59" + integrity sha512-gp4I4MvtgJvZPikEd7lwn149jjnC454CanPhm5demROdHCuakY+3YtIKEgVrJOqnS2iwbeeF+u4riB9CoO11+A== + dependencies: + ejs "^3.0.1" + semver "^7.1.3" + yargs "^15.1.0" + +apidoc@^0.20.1: + version "0.20.1" + resolved "https://registry.yarnpkg.com/apidoc/-/apidoc-0.20.1.tgz#b29a2e2ae47e2df6a29e1f1527b94edf91853072" + integrity sha512-V54vkZ2lDFBiGn0qusZmHbMi4svuFBq0rjZAIe3nwYvBY7iztW78vKOyHyTr9ASaTB7EGe8hhLbpEnYAIO31TQ== + dependencies: + apidoc-core "^0.11.1" + commander "^2.20.0" + fs-extra "^8.1.0" + lodash "^4.17.15" + markdown-it "^10.0.0" + nodemon "^2.0.2" + winston "^3.2.1" + apollo-cache-control@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/apollo-cache-control/-/apollo-cache-control-0.1.1.tgz#173d14ceb3eb9e7cb53de7eb8b61bee6159d4171" @@ -6109,12 +6225,12 @@ append-buffer@^1.0.2: dependencies: buffer-equal "^1.0.0" -append-transform@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-1.0.0.tgz#046a52ae582a228bd72f58acfbe2967c678759ab" - integrity sha512-P009oYkeHyU742iSZJzZZywj4QRJdnTWffaKuJQLablCZ1uz6/cW4yaRgcDaoQ+uwOxxnt0gRUcwfsNP2ri0gw== +append-transform@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-2.0.0.tgz#99d9d29c7b38391e6f428d28ce136551f0b77e12" + integrity sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg== dependencies: - default-require-extensions "^2.0.0" + default-require-extensions "^3.0.0" aproba@^1.0.3, aproba@^1.1.1: version "1.2.0" @@ -6867,15 +6983,16 @@ babel-plugin-istanbul@^5.1.0: istanbul-lib-instrument "^3.0.0" test-exclude "^5.0.0" -babel-plugin-istanbul@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-5.2.0.tgz#df4ade83d897a92df069c4d9a25cf2671293c854" - integrity sha512-5LphC0USA8t4i1zCtjbbNb6jJj/9+X6P37Qfirc/70EQ34xKlMW+a1RHGwxGI+SwWpNwZ27HqvzAobeqaXwiZw== +babel-plugin-istanbul@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.0.0.tgz#e159ccdc9af95e0b570c75b4573b7c34d671d765" + integrity sha512-AF55rZXpe7trmEylbaE1Gv54wn6rwU03aptvRoVIGP8YykoSxqdVLV1TfwflBCE/QtHmqtP8SWlTENqbK8GCSQ== dependencies: "@babel/helper-plugin-utils" "^7.0.0" - find-up "^3.0.0" - istanbul-lib-instrument "^3.3.0" - test-exclude "^5.2.3" + "@istanbuljs/load-nyc-config" "^1.0.0" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-instrument "^4.0.0" + test-exclude "^6.0.0" babel-plugin-jest-hoist@^24.9.0: version "24.9.0" @@ -7399,18 +7516,13 @@ binaryextensions@2: resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.1.1.tgz#3209a51ca4a4ad541a3b8d3d6a6d5b83a2485935" integrity sha512-XBaoWE9RW8pPdPQNibZsW2zh8TW6gcarXp1FZPwT8Uop8ScSNldJEWf2k9l3HeTqdrEwsOsFcq74RiJECW34yA== -bindings@1, bindings@^1.5.0: +bindings@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== dependencies: file-uri-to-path "1.0.0" -bindings@~1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.2.1.tgz#14ad6113812d2d37d72e67b4cacb4bb726505f11" - integrity sha1-FK1hE4EtLTfXLme0ystLtyZQXxE= - bit-twiddle@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/bit-twiddle/-/bit-twiddle-1.0.2.tgz#0c6c1fabe2b23d17173d9a61b7b7093eb9e1769e" @@ -7635,6 +7747,20 @@ boxen@^3.0.0: type-fest "^0.3.0" widest-line "^2.0.0" +boxen@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/boxen/-/boxen-4.2.0.tgz#e411b62357d6d6d36587c8ac3d5d974daa070e64" + integrity sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ== + dependencies: + ansi-align "^3.0.0" + camelcase "^5.3.1" + chalk "^3.0.0" + cli-boxes "^2.2.0" + string-width "^4.1.0" + term-size "^2.1.0" + type-fest "^0.8.1" + widest-line "^3.1.0" + brace-expansion@^1.1.7: version "1.1.8" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.8.tgz#c07b211c7c952ec1f8efd51a77ef0d1d3990a292" @@ -7944,7 +8070,7 @@ buffer@^5.1.0, buffer@^5.2.0: base64-js "^1.0.2" ieee754 "^1.1.4" -builtin-modules@^1.0.0, builtin-modules@^1.1.1: +builtin-modules@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" integrity sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8= @@ -8092,10 +8218,11 @@ cache-loader@^4.1.0: schema-utils "^2.0.0" cacheable-lookup@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-2.0.0.tgz#33b1e56f17507f5cf9bb46075112d65473fb7713" - integrity sha512-s2piO6LvA7xnL1AR03wuEdSx3BZT3tIJpZ56/lcJwzO/6DTJZlTs7X3lrvPxk6d1PlDe6PrVe2TjlUIZNFglAQ== + version "2.0.1" + resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-2.0.1.tgz#87be64a18b925234875e10a9bb1ebca4adce6b38" + integrity sha512-EMMbsiOTcdngM/K6gV/OxF2x0t07+vMOWxZNSCRQMjO2MY2nhZQ6OYhOOpyQrbhqsgtvKGI7hcq6xjnA92USjg== dependencies: + "@types/keyv" "^3.1.1" keyv "^4.0.0" cacheable-request@^2.1.1: @@ -8111,6 +8238,19 @@ cacheable-request@^2.1.1: normalize-url "2.0.1" responselike "1.0.2" +cacheable-request@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912" + integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg== + dependencies: + clone-response "^1.0.2" + get-stream "^5.1.0" + http-cache-semantics "^4.0.0" + keyv "^3.0.0" + lowercase-keys "^2.0.0" + normalize-url "^4.1.0" + responselike "^1.0.2" + cacheable-request@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.1.tgz#062031c2856232782ed694a257fa35da93942a58" @@ -8129,15 +8269,15 @@ cachedir@2.3.0: resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.3.0.tgz#0c75892a052198f0b21c7c1804d8331edfcae0e8" integrity sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw== -caching-transform@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/caching-transform/-/caching-transform-3.0.2.tgz#601d46b91eca87687a281e71cef99791b0efca70" - integrity sha512-Mtgcv3lh3U0zRii/6qVgQODdPA4G3zhG+jtbCWj39RXuUFTMzH0vcdMtaJS1jPowd+It2Pqr6y3NJMQqOqCE2w== +caching-transform@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/caching-transform/-/caching-transform-4.0.0.tgz#00d297a4206d71e2163c39eaffa8157ac0651f0f" + integrity sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA== dependencies: - hasha "^3.0.0" - make-dir "^2.0.0" - package-hash "^3.0.0" - write-file-atomic "^2.4.2" + hasha "^5.0.0" + make-dir "^3.0.0" + package-hash "^4.0.0" + write-file-atomic "^3.0.0" call-me-maybe@^1.0.1: version "1.0.1" @@ -8630,6 +8770,21 @@ chokidar@^2.0.0, chokidar@^2.1.2, chokidar@^2.1.8: optionalDependencies: fsevents "^1.2.7" +chokidar@^3.2.2: + version "3.3.1" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.1.tgz#c84e5b3d18d9a4d77558fef466b1bf16bbeb3450" + integrity sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.3.0" + optionalDependencies: + fsevents "~2.1.2" + chownr@^1.0.1, chownr@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.1.tgz#54726b8b8fff4df053c42187e801fb4412df1494" @@ -8659,16 +8814,16 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -chromedriver@^80.0.1: - version "80.0.1" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-80.0.1.tgz#35c1642e2d864b9e8262f291003e455b0e422917" - integrity sha512-VfRtZUpBUIjeypS+xM40+VD9g4Drv7L2VibG/4+0zX3mMx4KayN6gfKETycPfO6JwQXTLSxEr58fRcrsa8r5xQ== +chromedriver@^81.0.0: + version "81.0.0" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-81.0.0.tgz#690ba333aedf2b4c4933b6590c3242d3e5f28f3c" + integrity sha512-BA++IQ7O1FzHmNpzMlOfLiSBvPZ946uuhtJjZHEIr/Gb+Ha9jiuGbHiT45l6O3XGbQ8BAwvbmdisjl4rTxro4A== dependencies: "@testim/chrome-version" "^1.0.7" axios "^0.19.2" del "^5.1.0" - extract-zip "^1.6.7" - mkdirp "^1.0.3" + extract-zip "^2.0.0" + mkdirp "^1.0.4" tcp-port-used "^1.0.1" ci-info@^1.0.0: @@ -9216,16 +9371,16 @@ commander@4.1.0: resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.0.tgz#545983a0603fe425bc672d66c9e3c89c42121a83" integrity sha512-NIQrwvv9V39FHgGFm36+U9SMQzbiHvU79k+iADraJTpmrFFfx7Ds0IvDoAdZsDrknlkRk14OYoWXb57uTh7/sw== -commander@^2.12.1, commander@^2.20.0, commander@^2.7.1: - version "2.20.3" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" - integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== - commander@^2.13.0, commander@^2.15.1, commander@^2.16.0, commander@^2.19.0: version "2.20.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422" integrity sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ== +commander@^2.20.0, commander@^2.7.1: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + commander@^2.8.1: version "2.18.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.18.0.tgz#2bf063ddee7c7891176981a2cc798e5754bc6970" @@ -9418,6 +9573,18 @@ configstore@^3.1.2: write-file-atomic "^2.0.0" xdg-basedir "^3.0.0" +configstore@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96" + integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA== + dependencies: + dot-prop "^5.2.0" + graceful-fs "^4.1.2" + make-dir "^3.0.0" + unique-string "^2.0.0" + write-file-atomic "^3.0.0" + xdg-basedir "^4.0.0" + connect-history-api-fallback@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc" @@ -9564,7 +9731,7 @@ convert-source-map@^0.3.3: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-0.3.5.tgz#f1d802950af7dd2631a1febe0596550c86ab3190" integrity sha1-8dgClQr33SYxof6+BZZVDIarMZA= -convert-source-map@^1.5.1, convert-source-map@^1.6.0: +convert-source-map@^1.5.1: version "1.6.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" integrity sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A== @@ -9768,17 +9935,6 @@ cosmiconfig@^5.2.0, cosmiconfig@^5.2.1: js-yaml "^3.13.1" parse-json "^4.0.0" -cp-file@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/cp-file/-/cp-file-6.2.0.tgz#40d5ea4a1def2a9acdd07ba5c0b0246ef73dc10d" - integrity sha512-fmvV4caBnofhPe8kOcitBwSn2f39QLjnAnGq3gO9dfd75mUytzKNZB1hde6QHunW2Rt+OwuBOMc3i1tNElbszA== - dependencies: - graceful-fs "^4.1.2" - make-dir "^2.0.0" - nested-error-stacks "^2.0.0" - pify "^4.0.1" - safe-buffer "^5.0.1" - cp-file@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/cp-file/-/cp-file-7.0.0.tgz#b9454cfd07fe3b974ab9ea0e5f29655791a9b8cd" @@ -9948,7 +10104,7 @@ cross-spawn@^3.0.0: lru-cache "^4.0.1" which "^1.2.9" -cross-spawn@^4, cross-spawn@^4.0.2: +cross-spawn@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-4.0.2.tgz#7b9247621c23adfdd3856004a823cbe397424d41" integrity sha1-e5JHYhwjrf3ThWAEqCPL45dCTUE= @@ -10027,6 +10183,11 @@ crypto-random-string@^1.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e" integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4= +crypto-random-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" + integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== + cson-parser@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/cson-parser/-/cson-parser-1.3.5.tgz#7ec675e039145533bf2a6a856073f1599d9c2d24" @@ -10641,7 +10802,7 @@ debug-fabulous@1.X: memoizee "0.4.X" object-assign "4.X" -debug@2, debug@2.6.9, debug@^2.0.0, debug@^2.1.0, debug@^2.1.1, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.8, debug@^2.6.9: +debug@2.6.9, debug@^2.0.0, debug@^2.1.0, debug@^2.1.1, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.8, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== @@ -10847,12 +11008,12 @@ default-gateway@^4.2.0: execa "^1.0.0" ip-regex "^2.1.0" -default-require-extensions@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/default-require-extensions/-/default-require-extensions-2.0.0.tgz#f5f8fbb18a7d6d50b21f641f649ebb522cfe24f7" - integrity sha1-9fj7sYp9bVCyH2QfZJ67Uiz+JPc= +default-require-extensions@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/default-require-extensions/-/default-require-extensions-3.0.0.tgz#e03f93aac9b2b6443fc52e5e4a37b3ad9ad8df96" + integrity sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg== dependencies: - strip-bom "^3.0.0" + strip-bom "^4.0.0" default-resolution@^2.0.0: version "2.0.0" @@ -10871,6 +11032,11 @@ defaults@^1.0.3: dependencies: clone "^1.0.2" +defer-to-connect@^1.0.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591" + integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ== + defer-to-connect@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.0.tgz#83d6b199db041593ac84d781b5222308ccf4c2c1" @@ -11424,6 +11590,13 @@ dot-prop@^4.1.0, dot-prop@^4.1.1: dependencies: is-obj "^1.0.0" +dot-prop@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.2.0.tgz#c34ecc29556dc45f1f4c22697b6f4904e0cc4fcb" + integrity sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A== + dependencies: + is-obj "^2.0.0" + dotenv-defaults@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/dotenv-defaults/-/dotenv-defaults-1.0.2.tgz#441cf5f067653fca4bbdce9dd3b803f6f84c585d" @@ -11621,6 +11794,11 @@ ejs@^2.6.1: resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.6.1.tgz#498ec0d495655abc6f23cd61868d926464071aa0" integrity sha512-0xy4A/twfrRCnkhfk8ErDi5DqdAsAqeGxht4xkCUrsvhhbQNs7E+4jV0CN7+NKIY0aHE72+XvqtBIXzD31ZbXQ== +ejs@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.0.2.tgz#745b01cdcfe38c1c6a2da3bbb2d9957060a31226" + integrity sha512-IncmUpn1yN84hy2shb0POJ80FWrfGNY0cxO9f4v+/sG7qcBvAtVWUA1IdzY/8EYUmOVhoKJVdJjNd3AZcnxOjA== + elastic-apm-http-client@^9.2.0: version "9.2.1" resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-9.2.1.tgz#e0e980ceb9975ff770bdbf2f5cdaac39fd70e8e6" @@ -12155,6 +12333,11 @@ es6-weak-map@^2.0.1, es6-weak-map@^2.0.2: es6-iterator "^2.0.1" es6-symbol "^3.1.1" +escape-goat@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" + integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== + escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" @@ -13098,7 +13281,7 @@ extract-zip@1.6.7: mkdirp "0.5.1" yauzl "2.4.1" -extract-zip@^1.6.6, extract-zip@^1.6.7, extract-zip@^1.7.0: +extract-zip@^1.6.6, extract-zip@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927" integrity sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA== @@ -13108,6 +13291,17 @@ extract-zip@^1.6.6, extract-zip@^1.6.7, extract-zip@^1.7.0: mkdirp "^0.5.4" yauzl "^2.10.0" +extract-zip@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.0.tgz#f53b71d44f4ff5a4527a2259ade000fb8b303492" + integrity sha512-i42GQ498yibjdvIhivUsRslx608whtGoFIhF26Z7O4MYncBxp8CwalOs1lnHy21A9sIohWO2+uiE4SRtC9JXDg== + dependencies: + debug "^4.1.1" + get-stream "^5.1.0" + yauzl "^2.10.0" + optionalDependencies: + "@types/yauzl" "^2.9.1" + extsprintf@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" @@ -13317,17 +13511,6 @@ fetch-mock@^7.3.9: path-to-regexp "^2.2.1" whatwg-url "^6.5.0" -ffi@^2.0.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/ffi/-/ffi-2.3.0.tgz#fa1a7b3d85c0fa8c83d96947a64b5192bc47f858" - integrity sha512-vkPA9Hf9CVuQ5HeMZykYvrZF2QNJ/iKGLkyDkisBnoOOFeFXZQhUPxBARPBIZMJVulvBI2R+jgofW03gyPpJcQ== - dependencies: - bindings "~1.2.0" - debug "2" - nan "2" - ref "1" - ref-struct "1" - figgy-pudding@^3.5.1: version "3.5.1" resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" @@ -13842,13 +14025,13 @@ foreachasync@^3.0.0: resolved "https://registry.yarnpkg.com/foreachasync/-/foreachasync-3.0.0.tgz#5502987dc8714be3392097f32e0071c9dee07cf6" integrity sha1-VQKYfchxS+M5IJfzLgBxyd7gfPY= -foreground-child@^1.5.6: - version "1.5.6" - resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-1.5.6.tgz#4fd71ad2dfde96789b980a5c0a295937cb2f5ce9" - integrity sha1-T9ca0t/elnibmApcCilZN8svXOk= +foreground-child@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-2.0.0.tgz#71b32800c9f15aa8f2f83f4a6bd9bff35d861a53" + integrity sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA== dependencies: - cross-spawn "^4" - signal-exit "^3.0.0" + cross-spawn "^7.0.0" + signal-exit "^3.0.2" forever-agent@~0.6.1: version "0.6.1" @@ -13954,6 +14137,11 @@ from2@^2.1.0, from2@^2.1.1, from2@^2.3.0: inherits "^2.0.1" readable-stream "^2.0.0" +fromentries@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/fromentries/-/fromentries-1.2.0.tgz#e6aa06f240d6267f913cea422075ef88b63e7897" + integrity sha512-33X7H/wdfO99GdRLLgkjUrD4geAFdq/Uv0kl3HD4da6HDixd2GUg8Mw7dahLCV9r/EARkmtYBB6Tch4EEokFTQ== + front-matter@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/front-matter/-/front-matter-2.1.2.tgz#f75983b9f2f413be658c93dfd7bd8ce4078f5cdb" @@ -13978,7 +14166,7 @@ fs-exists-sync@^0.1.0: resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add" integrity sha1-mC1ok6+RjnLQjeyehnP/K1qNat0= -fs-extra@8.1.0, fs-extra@^8.0.1: +fs-extra@8.1.0, fs-extra@^8.0.1, fs-extra@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== @@ -14075,7 +14263,7 @@ fsevents@^1.2.7: bindings "^1.5.0" nan "^2.12.1" -fsevents@~2.1.0, fsevents@~2.1.1: +fsevents@~2.1.0, fsevents@~2.1.1, fsevents@~2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805" integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA== @@ -14307,7 +14495,7 @@ get-stream@^2.2.0: object-assign "^4.0.1" pinkie-promise "^2.0.0" -get-stream@^4.0.0: +get-stream@^4.0.0, get-stream@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== @@ -14561,6 +14749,13 @@ global-dirs@^0.1.0: dependencies: ini "^1.3.4" +global-dirs@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-2.0.1.tgz#acdf3bb6685bcd55cb35e8a052266569e9469201" + integrity sha512-5HqUqdhkEovj2Of/ms3IeS/EekcO54ytHRLV4PEY2rhRwrHXLQjeVEES0Lhka0xwNDtGYn58wyC4s5+MHsOO6A== + dependencies: + ini "^1.3.5" + global-modules@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" @@ -14805,10 +15000,10 @@ got@5.6.0: unzip-response "^1.0.0" url-parse-lax "^1.0.0" -got@^10.6.0: - version "10.6.0" - resolved "https://registry.yarnpkg.com/got/-/got-10.6.0.tgz#ac3876261a4d8e5fc4f81186f79955ce7b0501dc" - integrity sha512-3LIdJNTdCFbbJc+h/EH0V5lpNpbJ6Bfwykk21lcQvQsEcrzdi/ltCyQehFHLzJ/ka0UMH4Slg0hkYvAZN9qUDg== +got@^10.7.0: + version "10.7.0" + resolved "https://registry.yarnpkg.com/got/-/got-10.7.0.tgz#62889dbcd6cca32cd6a154cc2d0c6895121d091f" + integrity sha512-aWTDeNw9g+XqEZNcTjMMZSy7B7yE9toWOFYip7ofFTLleJhvZwUxxTxkTpKvF+p1SAA4VHmuEy7PiHTHyq8tJg== dependencies: "@sindresorhus/is" "^2.0.0" "@szmarczak/http-timer" "^4.0.0" @@ -14902,6 +15097,23 @@ got@^8.3.1, got@^8.3.2: url-parse-lax "^3.0.0" url-to-options "^1.0.1" +got@^9.6.0: + version "9.6.0" + resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85" + integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q== + dependencies: + "@sindresorhus/is" "^0.14.0" + "@szmarczak/http-timer" "^1.1.2" + cacheable-request "^6.0.0" + decompress-response "^3.3.0" + duplexer3 "^0.1.4" + get-stream "^4.1.0" + lowercase-keys "^1.0.1" + mimic-response "^1.0.1" + p-cancelable "^1.0.0" + to-readable-stream "^1.0.0" + url-parse-lax "^3.0.0" + graceful-fs@4.X, graceful-fs@^4.0.0, graceful-fs@^4.1.10, graceful-fs@^4.1.11, graceful-fs@^4.1.4: version "4.1.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" @@ -15606,6 +15818,11 @@ has-values@^1.0.0: is-number "^3.0.0" kind-of "^4.0.0" +has-yarn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77" + integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw== + has@^1.0.1, has@^1.0.3, has@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" @@ -15636,12 +15853,13 @@ hash.js@^1.0.0, hash.js@^1.0.3: inherits "^2.0.3" minimalistic-assert "^1.0.0" -hasha@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/hasha/-/hasha-3.0.0.tgz#52a32fab8569d41ca69a61ff1a214f8eb7c8bd39" - integrity sha1-UqMvq4Vp1BymmmH/GiFPjrfIvTk= +hasha@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/hasha/-/hasha-5.2.0.tgz#33094d1f69c40a4a6ac7be53d5fe3ff95a269e0c" + integrity sha512-2W+jKdQbAdSIrggA8Q35Br8qKadTrqCTC8+XZvBWepKDK6m9XkX6Iz1a2yh2KP01kzAR/dpuMeUnocoLYDcskw== dependencies: - is-stream "^1.0.1" + is-stream "^2.0.0" + type-fest "^0.8.0" hast-util-from-parse5@^5.0.0: version "5.0.0" @@ -15845,6 +16063,11 @@ html-entities@^1.2.0, html-entities@^1.2.1: resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.1.tgz#0df29351f0721163515dfb9e5543e5f6eed5162f" integrity sha1-DfKTUfByEWNRXfueVUPl9u7VFi8= +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + html-loader@^0.5.5: version "0.5.5" resolved "https://registry.yarnpkg.com/html-loader/-/html-loader-0.5.5.tgz#6356dbeb0c49756d8ebd5ca327f16ff06ab5faea" @@ -16190,11 +16413,21 @@ ieee754@^1.1.12, ieee754@^1.1.4: resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== +if-async@^3.7.4: + version "3.7.4" + resolved "https://registry.yarnpkg.com/if-async/-/if-async-3.7.4.tgz#55868deb0093d3c67bf7166e745353fb9bcb21a2" + integrity sha1-VYaN6wCT08Z79xZudFNT+5vLIaI= + iferr@^0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= +ignore-by-default@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" + integrity sha1-SMptcvbGo68Aqa1K5odr44ieKwk= + ignore@^3.1.2, ignore@^3.3.5: version "3.3.10" resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043" @@ -17015,6 +17248,14 @@ is-installed-globally@0.1.0, is-installed-globally@^0.1.0: global-dirs "^0.1.0" is-path-inside "^1.0.0" +is-installed-globally@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" + integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g== + dependencies: + global-dirs "^2.0.1" + is-path-inside "^3.0.1" + is-integer@^1.0.6: version "1.0.7" resolved "https://registry.yarnpkg.com/is-integer/-/is-integer-1.0.7.tgz#6bde81aacddf78b659b6629d629cadc51a886d5c" @@ -17085,6 +17326,11 @@ is-npm@^1.0.0: resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4" integrity sha1-8vtjpl5JBbQGyGBydloaTceTufQ= +is-npm@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-4.0.0.tgz#c90dd8380696df87a7a6d823c20d0b12bbe3c84d" + integrity sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig== + is-number-object@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197" @@ -17117,6 +17363,11 @@ is-obj@^1.0.0, is-obj@^1.0.1: resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8= +is-obj@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" + integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== + is-object@^1.0.1, is-object@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.1.tgz#8952688c5ec2ffd6b03ecc85e769e02903083470" @@ -17320,7 +17571,7 @@ is-symbol@^1.0.2: dependencies: has-symbols "^1.0.0" -is-typedarray@~1.0.0: +is-typedarray@^1.0.0, is-typedarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= @@ -17381,6 +17632,11 @@ is-wsl@^1.1.0: resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= +is-yarn-global@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" + integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== + is2@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/is2/-/is2-2.0.1.tgz#8ac355644840921ce435d94f05d3a94634d3481a" @@ -17482,17 +17738,17 @@ istanbul-lib-coverage@^2.0.2, istanbul-lib-coverage@^2.0.3: resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#0b891e5ad42312c2b9488554f603795f9a2211ba" integrity sha512-dKWuzRGCs4G+67VfW9pBFFz2Jpi4vSp/k7zBcJ888ofV5Mi1g5CUML5GvMvV6u9Cjybftu+E8Cgp+k0dI1E5lw== -istanbul-lib-coverage@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz#675f0ab69503fad4b1d849f736baaca803344f49" - integrity sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA== +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.0.0-alpha.1: + version "3.0.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz#f5944a37c70b550b02a78a5c3b2055b280cec8ec" + integrity sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg== -istanbul-lib-hook@^2.0.7: - version "2.0.7" - resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-2.0.7.tgz#c95695f383d4f8f60df1f04252a9550e15b5b133" - integrity sha512-vrRztU9VRRFDyC+aklfLoeXyNdTfga2EI3udDGn4cZ6fpSXpHLV9X6CHvfoMCPtggg8zvDDmC4b9xfu0z6/llA== +istanbul-lib-hook@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz#8f84c9434888cc6b1d0a9d7092a76d239ebf0cc6" + integrity sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ== dependencies: - append-transform "^1.0.0" + append-transform "^2.0.0" istanbul-lib-instrument@^1.7.3: version "1.10.2" @@ -17520,18 +17776,31 @@ istanbul-lib-instrument@^3.0.0, istanbul-lib-instrument@^3.0.1: istanbul-lib-coverage "^2.0.3" semver "^5.5.0" -istanbul-lib-instrument@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-3.3.0.tgz#a5f63d91f0bbc0c3e479ef4c5de027335ec6d630" - integrity sha512-5nnIN4vo5xQZHdXno/YDXJ0G+I3dAm4XgzfSVTPLQpj/zAV2dV6Juy0yaf10/zrJOJeHoN3fraFe+XRq2bFVZA== +istanbul-lib-instrument@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.1.tgz#61f13ac2c96cfefb076fe7131156cc05907874e6" + integrity sha512-imIchxnodll7pvQBYOqUu88EufLCU56LMeFPZZM/fJZ1irYcYdqroaV+ACK1Ila8ls09iEYArp+nqyC6lW1Vfg== + dependencies: + "@babel/core" "^7.7.5" + "@babel/parser" "^7.7.5" + "@babel/template" "^7.7.4" + "@babel/traverse" "^7.7.4" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-coverage "^3.0.0" + semver "^6.3.0" + +istanbul-lib-processinfo@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.2.tgz#e1426514662244b2f25df728e8fd1ba35fe53b9c" + integrity sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw== dependencies: - "@babel/generator" "^7.4.0" - "@babel/parser" "^7.4.3" - "@babel/template" "^7.4.0" - "@babel/traverse" "^7.4.3" - "@babel/types" "^7.4.0" - istanbul-lib-coverage "^2.0.5" - semver "^6.0.0" + archy "^1.0.0" + cross-spawn "^7.0.0" + istanbul-lib-coverage "^3.0.0-alpha.1" + make-dir "^3.0.0" + p-map "^3.0.0" + rimraf "^3.0.0" + uuid "^3.3.3" istanbul-lib-report@^2.0.4: version "2.0.4" @@ -17542,14 +17811,14 @@ istanbul-lib-report@^2.0.4: make-dir "^1.3.0" supports-color "^6.0.0" -istanbul-lib-report@^2.0.8: - version "2.0.8" - resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-2.0.8.tgz#5a8113cd746d43c4889eba36ab10e7d50c9b4f33" - integrity sha512-fHBeG573EIihhAblwgxrSenp0Dby6tJMFR/HvlerBsrCTD5bkUuoNtn3gVh29ZCS824cGGBPn7Sg7cNk+2xUsQ== +istanbul-lib-report@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#7518fe52ea44de372f460a76b5ecda9ffb73d8a6" + integrity sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw== dependencies: - istanbul-lib-coverage "^2.0.5" - make-dir "^2.1.0" - supports-color "^6.1.0" + istanbul-lib-coverage "^3.0.0" + make-dir "^3.0.0" + supports-color "^7.1.0" istanbul-lib-source-maps@^3.0.1: version "3.0.2" @@ -17562,24 +17831,30 @@ istanbul-lib-source-maps@^3.0.1: rimraf "^2.6.2" source-map "^0.6.1" -istanbul-lib-source-maps@^3.0.6: - version "3.0.6" - resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-3.0.6.tgz#284997c48211752ec486253da97e3879defba8c8" - integrity sha512-R47KzMtDJH6X4/YW9XTx+jrLnZnscW4VpNN+1PViSYTejLVPWv7oov+Duf8YQSPyVRUvueQqz1TcsC6mooZTXw== +istanbul-lib-source-maps@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.0.tgz#75743ce6d96bb86dc7ee4352cf6366a23f0b1ad9" + integrity sha512-c16LpFRkR8vQXyHZ5nLpY35JZtzj1PQY1iZmesUbf1FZHbIupcWfjgOXBY9YHkLEQ6puz1u4Dgj6qmU/DisrZg== dependencies: debug "^4.1.1" - istanbul-lib-coverage "^2.0.5" - make-dir "^2.1.0" - rimraf "^2.6.3" + istanbul-lib-coverage "^3.0.0" source-map "^0.6.1" -istanbul-reports@^2.2.4, istanbul-reports@^2.2.6: +istanbul-reports@^2.2.6: version "2.2.6" resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-2.2.6.tgz#7b4f2660d82b29303a8fe6091f8ca4bf058da1af" integrity sha512-SKi4rnMyLBKe0Jy2uUdx28h8oG7ph2PPuQPvIAh31d+Ci+lSiEu4C+h3oBPuJ9+mPKhOyW0M8gY4U5NM1WLeXA== dependencies: handlebars "^4.1.2" +istanbul-reports@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.0.2.tgz#d593210e5000683750cb09fc0644e4b6e27fd53b" + integrity sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + istanbul@^0.4.0: version "0.4.5" resolved "https://registry.yarnpkg.com/istanbul/-/istanbul-0.4.5.tgz#65c7d73d4c4da84d4f3ac310b918fb0b8033733b" @@ -18485,9 +18760,9 @@ jsx-to-string@^1.4.0: react "^0.14.0" jszip@^3.2.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.2.2.tgz#b143816df7e106a9597a94c77493385adca5bd1d" - integrity sha512-NmKajvAFQpbg3taXQXr/ccS2wcucR1AZ+NtyWp2Nq7HHVsXhcJFR8p0Baf32C2yVvBylFWVeKf+WI2AnvlPhpA== + version "3.3.0" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.3.0.tgz#29d72c21a54990fa885b11fc843db320640d5271" + integrity sha512-EJ9k766htB1ZWnsV5ZMDkKLgA+201r/ouFF8R2OigVjVdcm2rurcBrrdXaeqBJbqnUVMko512PYmlncBKE1Huw== dependencies: lie "~3.3.0" pako "~1.0.2" @@ -18660,6 +18935,13 @@ keyv@3.0.0: dependencies: json-buffer "3.0.0" +keyv@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" + integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA== + dependencies: + json-buffer "3.0.0" + keyv@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.0.0.tgz#2d1dab694926b2d427e4c74804a10850be44c12f" @@ -18703,6 +18985,13 @@ kind-of@^6.0.0, kind-of@^6.0.2: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== +klaw-sync@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c" + integrity sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ== + dependencies: + graceful-fs "^4.1.11" + klaw@^1.0.0: version "1.3.1" resolved "https://registry.yarnpkg.com/klaw/-/klaw-1.3.1.tgz#4088433b46b3b1ba259d78785d8e96f73ba02439" @@ -18763,6 +19052,13 @@ latest-version@^3.0.0, latest-version@^3.1.0: dependencies: package-json "^4.0.0" +latest-version@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face" + integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA== + dependencies: + package-json "^6.3.0" + lazy-ass@1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/lazy-ass/-/lazy-ass-1.6.0.tgz#7999655e8646c17f089fdd187d150d3324d54513" @@ -19630,7 +19926,7 @@ lowercase-keys@1.0.0: resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306" integrity sha1-TjNms55/VFfjXxMkvfb4jQv8cwY= -lowercase-keys@^1.0.0: +lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== @@ -20121,13 +20417,6 @@ merge-source-map@1.0.4: dependencies: source-map "^0.5.6" -merge-source-map@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/merge-source-map/-/merge-source-map-1.1.0.tgz#2fdde7e6020939f70906a68f2d7ae685e4c8c646" - integrity sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw== - dependencies: - source-map "^0.6.1" - merge-stream@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-1.0.1.tgz#4041202d508a342ba00174008df0c251b8c135e1" @@ -20290,7 +20579,7 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -mimic-response@^1.0.0: +mimic-response@^1.0.0, mimic-response@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== @@ -20546,10 +20835,10 @@ mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.4, mkd dependencies: minimist "^1.2.5" -mkdirp@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.3.tgz#4cf2e30ad45959dddea53ad97d518b6c8205e1ea" - integrity sha512-6uCP4Qc0sWsgMLy1EOqqS/3rjDHOEnsStVr/4vtAIK2Y5i2kA7lFFejYrpIyiN9w0pYf4ckeCYT9f1r1P9KX5g== +mkdirp@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== mocha-junit-reporter@^1.23.1: version "1.23.1" @@ -20733,18 +21022,16 @@ move-concurrently@^1.0.1: rimraf "^2.5.4" run-queue "^1.0.3" -ms-chromium-edge-driver@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/ms-chromium-edge-driver/-/ms-chromium-edge-driver-0.2.0.tgz#0e0c6fd9fd1d1d36db97b2b3d7e9d4ba4d2de456" - integrity sha512-RkDsBPnMLjRna7q4LlvtLb+CHPei9gZapnlxm3ayWKk3Ab6HmDsz/17xG2eyqkKX5UcKeo04YlLZ345tO7OolA== +ms-chromium-edge-driver@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/ms-chromium-edge-driver/-/ms-chromium-edge-driver-0.2.3.tgz#74effa9280c112a447d8dd4d4c1589fce398a5b6" + integrity sha512-dgGxRdYyz69yhAdJk4BGFY4o5TnKe+LOceTnRQMIl5Qww1pL+1meUuoAIAPVRd5V7kB7ZfgYQNxtxQj/fVUmUA== dependencies: - extract-zip "^1.6.7" - got "^10.6.0" - lodash "4.17.15" - tslint "^6.1.0" - tslint-config-prettier "^1.18.0" + extract-zip "^2.0.0" + got "^10.7.0" + lodash "^4.17.15" + regedit "^3.0.3" util "^0.12.2" - windows-registry "^0.1.5" ms@2.0.0: version "2.0.0" @@ -20858,7 +21145,7 @@ mute-stream@0.0.8: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== -nan@2, nan@^2.12.1, nan@^2.13.2: +nan@^2.12.1, nan@^2.13.2: version "2.14.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== @@ -21224,6 +21511,13 @@ node-notifier@^5.4.2: shellwords "^0.1.1" which "^1.3.0" +node-preload@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/node-preload/-/node-preload-0.2.1.tgz#c03043bb327f417a18fee7ab7ee57b408a144301" + integrity sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ== + dependencies: + process-on-spawn "^1.0.0" + node-releases@^1.1.25, node-releases@^1.1.46: version "1.1.47" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.47.tgz#c59ef739a1fd7ecbd9f0b7cf5b7871e8a8b591e4" @@ -21278,6 +21572,22 @@ nodemailer@^4.7.0: resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.7.0.tgz#4420e06abfffd77d0618f184ea49047db84f4ad8" integrity sha512-IludxDypFpYw4xpzKdMAozBSkzKHmNBvGanUREjJItgJ2NYcK/s8+PggVhj7c2yGFQykKsnnmv1+Aqo0ZfjHmw== +nodemon@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.3.tgz#e9c64df8740ceaef1cb00e1f3da57c0a93ef3714" + integrity sha512-lLQLPS90Lqwc99IHe0U94rDgvjo+G9I4uEIxRG3evSLROcqQ9hwc0AxlSHKS4T1JW/IMj/7N5mthiN58NL/5kw== + dependencies: + chokidar "^3.2.2" + debug "^3.2.6" + ignore-by-default "^1.0.1" + minimatch "^3.0.4" + pstree.remy "^1.1.7" + semver "^5.7.1" + supports-color "^5.5.0" + touch "^3.1.0" + undefsafe "^2.0.2" + update-notifier "^4.0.0" + "nomnom@>= 1.5.x": version "1.8.1" resolved "https://registry.yarnpkg.com/nomnom/-/nomnom-1.8.1.tgz#2151f722472ba79e50a76fc125bb8c8f2e4dc2a7" @@ -21300,6 +21610,13 @@ nopt@^2.2.0: dependencies: abbrev "1" +nopt@~1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" + integrity sha1-bd0hvSoxQXuScn3Vhfim83YI6+4= + dependencies: + abbrev "1" + normalize-package-data@^2.0.0, normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: version "2.4.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f" @@ -21490,36 +21807,37 @@ nwsapi@^2.2.0: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7" integrity sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ== -nyc@^14.1.1: - version "14.1.1" - resolved "https://registry.yarnpkg.com/nyc/-/nyc-14.1.1.tgz#151d64a6a9f9f5908a1b73233931e4a0a3075eeb" - integrity sha512-OI0vm6ZGUnoGZv/tLdZ2esSVzDwUC88SNs+6JoSOMVxA+gKMB8Tk7jBwgemLx4O40lhhvZCVw1C+OYLOBOPXWw== +nyc@^15.0.1: + version "15.0.1" + resolved "https://registry.yarnpkg.com/nyc/-/nyc-15.0.1.tgz#bd4d5c2b17f2ec04370365a5ca1fc0ed26f9f93d" + integrity sha512-n0MBXYBYRqa67IVt62qW1r/d9UH/Qtr7SF1w/nQLJ9KxvWF6b2xCHImRAixHN9tnMMYHC2P14uo6KddNGwMgGg== dependencies: - archy "^1.0.0" - caching-transform "^3.0.2" - convert-source-map "^1.6.0" - cp-file "^6.2.0" - find-cache-dir "^2.1.0" - find-up "^3.0.0" - foreground-child "^1.5.6" - glob "^7.1.3" - istanbul-lib-coverage "^2.0.5" - istanbul-lib-hook "^2.0.7" - istanbul-lib-instrument "^3.3.0" - istanbul-lib-report "^2.0.8" - istanbul-lib-source-maps "^3.0.6" - istanbul-reports "^2.2.4" - js-yaml "^3.13.1" - make-dir "^2.1.0" - merge-source-map "^1.1.0" - resolve-from "^4.0.0" - rimraf "^2.6.3" + "@istanbuljs/load-nyc-config" "^1.0.0" + "@istanbuljs/schema" "^0.1.2" + caching-transform "^4.0.0" + convert-source-map "^1.7.0" + decamelize "^1.2.0" + find-cache-dir "^3.2.0" + find-up "^4.1.0" + foreground-child "^2.0.0" + glob "^7.1.6" + istanbul-lib-coverage "^3.0.0" + istanbul-lib-hook "^3.0.0" + istanbul-lib-instrument "^4.0.0" + istanbul-lib-processinfo "^2.0.2" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^4.0.0" + istanbul-reports "^3.0.2" + make-dir "^3.0.0" + node-preload "^0.2.1" + p-map "^3.0.0" + process-on-spawn "^1.0.0" + resolve-from "^5.0.0" + rimraf "^3.0.0" signal-exit "^3.0.2" - spawn-wrap "^1.4.2" - test-exclude "^5.2.3" - uuid "^3.3.2" - yargs "^13.2.2" - yargs-parser "^13.0.0" + spawn-wrap "^2.0.0" + test-exclude "^6.0.0" + yargs "^15.0.2" oauth-sign@~0.8.1, oauth-sign@~0.8.2: version "0.8.2" @@ -21958,7 +22276,7 @@ os-browserify@^0.3.0: resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= -os-homedir@^1.0.0, os-homedir@^1.0.1: +os-homedir@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= @@ -22051,6 +22369,11 @@ p-cancelable@^0.4.0: resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.4.1.tgz#35f363d67d52081c8d9585e37bcceb7e0bbcb2a0" integrity sha512-HNa1A8LvB1kie7cERyy21VNeHb2CWJJYqyyC2o3klWFfMGlFmWv2Z7sFgZH8ZiaYL95ydToKTFVXgMV/Os0bBQ== +p-cancelable@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" + integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== + p-cancelable@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.0.0.tgz#4a3740f5bdaf5ed5d7c3e34882c6fb5d6b266a6e" @@ -22200,13 +22523,13 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== -package-hash@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/package-hash/-/package-hash-3.0.0.tgz#50183f2d36c9e3e528ea0a8605dff57ce976f88e" - integrity sha512-lOtmukMDVvtkL84rJHI7dpTYq+0rli8N2wlnqUcBuDWCfVhRUfOmnR9SsoHFMLpACvEV60dX7rd0rFaYDZI+FA== +package-hash@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/package-hash/-/package-hash-4.0.0.tgz#3537f654665ec3cc38827387fc904c163c54f506" + integrity sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ== dependencies: graceful-fs "^4.1.15" - hasha "^3.0.0" + hasha "^5.0.0" lodash.flattendeep "^4.4.0" release-zalgo "^1.0.0" @@ -22238,6 +22561,16 @@ package-json@^5.0.0: registry-url "^3.1.0" semver "^5.5.0" +package-json@^6.3.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0" + integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ== + dependencies: + got "^9.6.0" + registry-auth-token "^4.0.0" + registry-url "^5.0.0" + semver "^6.2.0" + pad-component@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/pad-component/-/pad-component-0.0.1.tgz#ad1f22ce1bf0fdc0d6ddd908af17f351a404b8ac" @@ -22741,6 +23074,11 @@ picomatch@^2.0.4, picomatch@^2.0.5: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.0.7.tgz#514169d8c7cd0bdbeecc8a2609e34a7163de69f6" integrity sha512-oLHIdio3tZ0qH76NybpeneBhYVj0QFTfXEFTc/B3zKQspYfYYkWYgFsmzo+4kvId/bQRcNkVeguI3y+CD22BtA== +picomatch@^2.0.7: + version "2.2.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" + integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== + pify@^2.0.0, pify@^2.2.0, pify@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -23202,6 +23540,13 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== +process-on-spawn@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/process-on-spawn/-/process-on-spawn-1.0.0.tgz#95b05a23073d30a17acfdc92a440efd2baefdc93" + integrity sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg== + dependencies: + fromentries "^1.2.0" + process@^0.11.10: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" @@ -23378,6 +23723,11 @@ psl@^1.1.28: resolved "https://registry.yarnpkg.com/psl/-/psl-1.4.0.tgz#5dd26156cdb69fa1fdb8ab1991667d3f80ced7c2" integrity sha512-HZzqCGPecFLyoRj5HLfuDSKYTJkAfB5thKBIkRHtGjWwY7p1dAyveIbXIq4tO0KYfDF2tHqPUgY9SDnGm00uFw== +pstree.remy@^1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.7.tgz#c76963a28047ed61542dc361aa26ee55a7fa15f3" + integrity sha512-xsMgrUwRpuGskEzBFkH8NmTimbZ5PcPup0LA8JJkHIm2IMUbQcpo3yeLNWVrufEYjh8YwtSVh0xz6UeWc5Oh5A== + public-encrypt@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.0.tgz#39f699f3a46560dd5ebacbca693caf7c65c18cc6" @@ -23565,6 +23915,13 @@ punycode@^1.2.4, punycode@^1.4.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= +pupa@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.0.1.tgz#dbdc9ff48ffbea4a26a069b6f9f7abb051008726" + integrity sha512-hEJH0s8PXLY/cdXh66tNEQGndDrIKNqNC5xmrysZy3i5C3oEoLna7YAOad+7u125+zH1HNXUmGEkrhb3c2VriA== + dependencies: + escape-goat "^2.0.0" + puppeteer-core@^1.19.0: version "1.19.0" resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-1.19.0.tgz#3c3f98edb5862583e3a9c19cbc0da57ccc63ba5c" @@ -23822,7 +24179,7 @@ raw-loader@~0.5.1: resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-0.5.1.tgz#0c3d0beaed8a01c966d9787bf778281252a979aa" integrity sha1-DD0L6u2KAclm2Xh793goElKpeao= -rc@^1.0.1, rc@^1.1.6, rc@^1.2.7: +rc@^1.0.1, rc@^1.1.6, rc@^1.2.7, rc@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== @@ -24819,7 +25176,7 @@ read-pkg@^5.1.1, read-pkg@^5.2.0: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@1.0: +readable-stream@1.0, "readable-stream@>=1.0.33-1 <1.1.0-0": version "1.0.34" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" integrity sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw= @@ -24906,6 +25263,13 @@ readdirp@~3.2.0: dependencies: picomatch "^2.0.4" +readdirp@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.3.0.tgz#984458d13a1e42e2e9f5841b129e162f369aff17" + integrity sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ== + dependencies: + picomatch "^2.0.7" + readline2@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/readline2/-/readline2-1.0.1.tgz#41059608ffc154757b715d9989d199ffbf372e35" @@ -25117,31 +25481,6 @@ redux@^4.0.5: loose-envify "^1.4.0" symbol-observable "^1.2.0" -ref-struct@1, ref-struct@^1.0.2: - version "1.1.0" - resolved "https://registry.yarnpkg.com/ref-struct/-/ref-struct-1.1.0.tgz#5d5ee65ad41cefc3a5c5feb40587261e479edc13" - integrity sha1-XV7mWtQc78Olxf60BYcmHkee3BM= - dependencies: - debug "2" - ref "1" - -ref-union@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/ref-union/-/ref-union-1.0.1.tgz#3a2397f862f1e75171d687268f43b3f17729f120" - integrity sha1-OiOX+GLx51Fx1ocmj0Oz8Xcp8SA= - dependencies: - debug "2" - ref "1" - -ref@1, ref@^1.2.0: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ref/-/ref-1.3.5.tgz#0e33f080cdb94a3d95312b2b3b1fd0f82044ca0f" - integrity sha512-2cBCniTtxcGUjDpvFfVpw323a83/0RLSGJJY5l5lcomZWhYpU2cuLdsvYqMixvsdLJ9+sTdzEkju8J8ZHDM2nA== - dependencies: - bindings "1" - debug "2" - nan "2" - reflect.ownkeys@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460" @@ -25156,6 +25495,16 @@ refractor@^2.4.1: parse-entities "^1.1.2" prismjs "~1.16.0" +regedit@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/regedit/-/regedit-3.0.3.tgz#0c2188e15f670de7d5740c5cea9bbebe99497749" + integrity sha512-SpHmMKOtiEYx0MiRRC48apBsmThoZ4svZNsYoK8leHd5bdUHV1nYb8pk8gh6Moou7/S9EDi1QsjBTpyXVQrPuQ== + dependencies: + debug "^4.1.0" + if-async "^3.7.4" + stream-slicer "0.0.6" + through2 "^0.6.3" + regenerate-unicode-properties@^8.2.0: version "8.2.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec" @@ -25268,6 +25617,13 @@ registry-auth-token@^3.0.1, registry-auth-token@^3.3.2: rc "^1.1.6" safe-buffer "^5.0.1" +registry-auth-token@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.1.1.tgz#40a33be1e82539460f94328b0f7f0f84c16d9479" + integrity sha512-9bKS7nTl9+/A1s7tnPeGrUpRcVY+LUh7bfFgzpndALdPfXQBfQV77rQVtqgUV3ti4vc/Ik81Ex8UJDWDQ12zQA== + dependencies: + rc "^1.2.8" + registry-url@^3.0.0, registry-url@^3.0.3, registry-url@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942" @@ -25275,6 +25631,13 @@ registry-url@^3.0.0, registry-url@^3.0.3, registry-url@^3.1.0: dependencies: rc "^1.0.1" +registry-url@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009" + integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw== + dependencies: + rc "^1.2.8" + regjsgen@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.1.tgz#48f0bf1a5ea205196929c0d9798b42d1ed98443c" @@ -25874,7 +26237,7 @@ resolve@~1.10.1: dependencies: path-parse "^1.0.6" -responselike@1.0.2: +responselike@1.0.2, responselike@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" integrity sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec= @@ -26469,6 +26832,13 @@ semver-diff@^2.0.0: dependencies: semver "^5.0.3" +semver-diff@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" + integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg== + dependencies: + semver "^6.3.0" + semver-greatest-satisfied-range@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/semver-greatest-satisfied-range/-/semver-greatest-satisfied-range-1.1.0.tgz#13e8c2658ab9691cb0cd71093240280d36f77a5b" @@ -26518,7 +26888,7 @@ semver@^5.5.1: resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.1.tgz#7dfdd8814bdb7cabc7be0fb1d734cfb66c940477" integrity sha512-PqpAxfrEhlSUWge8dwIp4tZnQ25DIOthpiaHNIthsjEFQD6EvqUKUDM7L8O2rShkFccYo1VjJR0coWfNkCubRw== -semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: +semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0, semver@~6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== @@ -26528,6 +26898,11 @@ semver@^6.1.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.1.1.tgz#53f53da9b30b2103cd4f15eab3a18ecbcb210c9b" integrity sha512-rWYq2e5iYW+fFe/oPPtYJxYgjBm8sC4rmoGdUOgBB7VnwKt6HrL793l2voH1UlsyYZpJ4g0wfjnTEO1s1NP2eQ== +semver@^7.1.3: + version "7.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.0.tgz#91f7c70ec944a63e5dc7a74cde2da375d8e0853c" + integrity sha512-uyvgU/igkrMgNHwLgXvlpD9jEADbJhB0+JXSywoO47JgJ6c16iau9F9cjtc/E5o0PoqRYTiTIAPRKaYe84z6eQ== + semver@~5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" @@ -27207,17 +27582,17 @@ spawn-sync@^1.0.15: concat-stream "^1.4.7" os-shim "^0.1.2" -spawn-wrap@^1.4.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/spawn-wrap/-/spawn-wrap-1.4.2.tgz#cff58e73a8224617b6561abdc32586ea0c82248c" - integrity sha512-vMwR3OmmDhnxCVxM8M+xO/FtIp6Ju/mNaDfCMMW7FDcLRTPFWUswec4LXJHTJE2hwTI9O0YBfygu4DalFl7Ylg== +spawn-wrap@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/spawn-wrap/-/spawn-wrap-2.0.0.tgz#103685b8b8f9b79771318827aa78650a610d457e" + integrity sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg== dependencies: - foreground-child "^1.5.6" - mkdirp "^0.5.0" - os-homedir "^1.0.1" - rimraf "^2.6.2" + foreground-child "^2.0.0" + is-windows "^1.0.2" + make-dir "^3.0.0" + rimraf "^3.0.0" signal-exit "^3.0.2" - which "^1.3.0" + which "^2.0.1" spdx-compare@^0.1.2: version "0.1.2" @@ -27605,6 +27980,11 @@ stream-shift@^1.0.0: resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952" integrity sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI= +stream-slicer@0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/stream-slicer/-/stream-slicer-0.0.6.tgz#f86b2ac5c2440b7a0a87b71f33665c0788046138" + integrity sha1-+GsqxcJEC3oKh7cfM2ZcB4gEYTg= + stream-spigot@~2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/stream-spigot/-/stream-spigot-2.1.2.tgz#7de145e819f8dd0db45090d13dcf73a8ed3cc035" @@ -27681,7 +28061,7 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" -string-width@^4.1.0, string-width@^4.2.0: +string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5" integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg== @@ -28459,6 +28839,11 @@ term-size@^1.2.0: dependencies: execa "^0.7.0" +term-size@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.0.tgz#1f16adedfe9bdc18800e1776821734086fcc6753" + integrity sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw== + terser-webpack-plugin@^1.2.4: version "1.4.1" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.1.tgz#61b18e40eaee5be97e771cdbb10ed1280888c2b4" @@ -28536,7 +28921,7 @@ terser@^4.4.3: source-map "~0.6.1" source-map-support "~0.5.12" -test-exclude@^5.0.0, test-exclude@^5.2.3: +test-exclude@^5.0.0: version "5.2.3" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-5.2.3.tgz#c3d3e1e311eb7ee405e092dac10aefd09091eac0" integrity sha512-M+oxtseCFO3EDtAaGH7iiej3CBkzXqFMbzqYAACdzKui4eZA+pq3tZEwChvOdNfa7xxy8BfbmgJSIr43cC/+2g== @@ -28546,6 +28931,15 @@ test-exclude@^5.0.0, test-exclude@^5.2.3: read-pkg-up "^4.0.0" require-main-filename "^2.0.0" +test-exclude@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" + integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^7.1.4" + minimatch "^3.0.4" + text-hex@1.0.x: version "1.0.0" resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" @@ -28609,6 +29003,14 @@ through2@2.X, through2@^2.0.0, through2@^2.0.3, through2@~2.0.0: readable-stream "^2.1.5" xtend "~4.0.1" +through2@^0.6.3: + version "0.6.5" + resolved "https://registry.yarnpkg.com/through2/-/through2-0.6.5.tgz#41ab9c67b29d57209071410e1d7a7a968cd3ad48" + integrity sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg= + dependencies: + readable-stream ">=1.0.33-1 <1.1.0-0" + xtend ">=4.0.0 <4.1.0-0" + through2@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/through2/-/through2-3.0.1.tgz#39276e713c3302edf9e388dd9c812dd3b825bd5a" @@ -28844,6 +29246,11 @@ to-object-path@^0.3.0: dependencies: kind-of "^3.0.2" +to-readable-stream@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771" + integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q== + to-readable-stream@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-2.1.0.tgz#82880316121bea662cdc226adb30addb50cb06e8" @@ -28933,6 +29340,13 @@ topojson-client@3.0.0, topojson-client@^3.0.0: dependencies: commander "2" +touch@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b" + integrity sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA== + dependencies: + nopt "~1.0.10" + tough-cookie@>=2.3.3, tough-cookie@^2.0.0, tough-cookie@^2.3.3, tough-cookie@~2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" @@ -29128,37 +29542,6 @@ tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.2, tslib@^1.9.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ== -tslint-config-prettier@^1.18.0: - version "1.18.0" - resolved "https://registry.yarnpkg.com/tslint-config-prettier/-/tslint-config-prettier-1.18.0.tgz#75f140bde947d35d8f0d238e0ebf809d64592c37" - integrity sha512-xPw9PgNPLG3iKRxmK7DWr+Ea/SzrvfHtjFt5LBl61gk2UBG/DB9kCXRjv+xyIU1rUtnayLeMUVJBcMX8Z17nDg== - -tslint@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/tslint/-/tslint-6.1.0.tgz#c6c611b8ba0eed1549bf5a59ba05a7732133d851" - integrity sha512-fXjYd/61vU6da04E505OZQGb2VCN2Mq3doeWcOIryuG+eqdmFUXTYVwdhnbEu2k46LNLgUYt9bI5icQze/j0bQ== - dependencies: - "@babel/code-frame" "^7.0.0" - builtin-modules "^1.1.1" - chalk "^2.3.0" - commander "^2.12.1" - diff "^4.0.1" - glob "^7.1.1" - js-yaml "^3.13.1" - minimatch "^3.0.4" - mkdirp "^0.5.1" - resolve "^1.3.2" - semver "^5.3.0" - tslib "^1.10.0" - tsutils "^2.29.0" - -tsutils@^2.29.0: - version "2.29.0" - resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-2.29.0.tgz#32b488501467acbedd4b85498673a0812aca0b99" - integrity sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA== - dependencies: - tslib "^1.8.1" - tsutils@^3.17.1: version "3.17.1" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759" @@ -29688,7 +30071,7 @@ type-fest@^0.6.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg== -type-fest@^0.8.1: +type-fest@^0.8.0, type-fest@^0.8.1: version "0.8.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== @@ -29726,6 +30109,13 @@ typed-styles@^0.0.7: resolved "https://registry.yarnpkg.com/typed-styles/-/typed-styles-0.0.7.tgz#93392a008794c4595119ff62dde6809dbc40a3d9" integrity sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q== +typedarray-to-buffer@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" + integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== + dependencies: + is-typedarray "^1.0.0" + typedarray@^0.0.6, typedarray@~0.0.5: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" @@ -29840,6 +30230,13 @@ unc-path-regex@^0.1.2: resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo= +undefsafe@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.3.tgz#6b166e7094ad46313b2202da7ecc2cd7cc6e7aae" + integrity sha512-nrXZwwXrD/T/JXeygJqdCO6NZZ1L66HrxM/Z7mIq2oPanoN0F1nLx3lwJMu6AwJY69hdixaFQOuoYsMjE5/C2A== + dependencies: + debug "^2.2.0" + underscore.string@~3.3.4: version "3.3.5" resolved "https://registry.yarnpkg.com/underscore.string/-/underscore.string-3.3.5.tgz#fc2ad255b8bd309e239cbc5816fd23a9b7ea4023" @@ -30027,6 +30424,13 @@ unique-string@^1.0.0: dependencies: crypto-random-string "^1.0.0" +unique-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" + integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg== + dependencies: + crypto-random-string "^2.0.0" + unist-util-is@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-2.1.1.tgz#0c312629e3f960c66e931e812d3d80e77010947b" @@ -30196,6 +30600,25 @@ update-notifier@^2.5.0: semver-diff "^2.0.0" xdg-basedir "^3.0.0" +update-notifier@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-4.1.0.tgz#4866b98c3bc5b5473c020b1250583628f9a328f3" + integrity sha512-w3doE1qtI0/ZmgeoDoARmI5fjDoT93IfKgEGqm26dGUOh8oNpaSTsGNdYRN/SjOuo10jcJGwkEL3mroKzktkew== + dependencies: + boxen "^4.2.0" + chalk "^3.0.0" + configstore "^5.0.1" + has-yarn "^2.1.0" + import-lazy "^2.1.0" + is-ci "^2.0.0" + is-installed-globally "^0.3.1" + is-npm "^4.0.0" + is-yarn-global "^0.3.0" + latest-version "^5.0.0" + pupa "^2.0.1" + semver-diff "^3.1.1" + xdg-basedir "^4.0.0" + upper-case-first@^1.1.0, upper-case-first@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/upper-case-first/-/upper-case-first-1.1.2.tgz#5d79bedcff14419518fd2edb0a0507c9b6859115" @@ -30453,6 +30876,11 @@ uuid@^3.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04" integrity sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g== +uuid@^3.3.3: + version "3.4.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== + v8-compile-cache@2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.0.3.tgz#00f7494d2ae2b688cfe2899df6ed2c54bef91dbe" @@ -31502,6 +31930,13 @@ widest-line@^2.0.1: dependencies: string-width "^2.1.1" +widest-line@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" + integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== + dependencies: + string-width "^4.0.0" + win-release@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/win-release/-/win-release-1.1.1.tgz#5fa55e02be7ca934edfc12665632e849b72e5209" @@ -31524,17 +31959,6 @@ window-size@^0.2.0: resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.2.0.tgz#b4315bb4214a3d7058ebeee892e13fa24d98b075" integrity sha1-tDFbtCFKPXBY6+7okuE/ok2YsHU= -windows-registry@^0.1.5: - version "0.1.5" - resolved "https://registry.yarnpkg.com/windows-registry/-/windows-registry-0.1.5.tgz#92c25c960884b0d215e69395f52d8dfaa0ba4ad0" - integrity sha512-gMN3ets1fbdP+TApEbbX2TIfBK3MIH5+p9GMvIFS3CNLr7U0Khe5mRj/T5zvwo/pKdhJgDrCLYyaNSs7HYiBCw== - dependencies: - debug "^2.2.0" - ffi "^2.0.0" - ref "^1.2.0" - ref-struct "^1.0.2" - ref-union "^1.0.0" - windows-release@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.2.0.tgz#8122dad5afc303d833422380680a79cdfa91785f" @@ -31713,6 +32137,16 @@ write-file-atomic@^2.4.2: imurmurhash "^0.1.4" signal-exit "^3.0.2" +write-file-atomic@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" + integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== + dependencies: + imurmurhash "^0.1.4" + is-typedarray "^1.0.0" + signal-exit "^3.0.2" + typedarray-to-buffer "^3.1.5" + write-json-file@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/write-json-file/-/write-json-file-3.2.0.tgz#65bbdc9ecd8a1458e15952770ccbadfcff5fe62a" @@ -31800,6 +32234,11 @@ xdg-basedir@^3.0.0: resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4" integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ= +xdg-basedir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" + integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== + xhr@^2.0.1: version "2.4.1" resolved "https://registry.yarnpkg.com/xhr/-/xhr-2.4.1.tgz#ba982cced205ae5eec387169ac9dc77ca4853d38" @@ -31902,16 +32341,16 @@ xregexp@4.2.4: dependencies: "@babel/runtime-corejs2" "^7.2.0" +"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68= -xtend@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" - integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== - xxhashjs@^0.2.1: version "0.2.2" resolved "https://registry.yarnpkg.com/xxhashjs/-/xxhashjs-0.2.2.tgz#8a6251567621a1c46a5ae204da0249c7f8caa9d8" @@ -31954,7 +32393,7 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yargs-parser@13.1.2, yargs-parser@^13.0.0, yargs-parser@^13.1.0, yargs-parser@^13.1.1, yargs-parser@^13.1.2: +yargs-parser@13.1.2, yargs-parser@^13.1.0, yargs-parser@^13.1.1, yargs-parser@^13.1.2: version "13.1.2" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== @@ -32121,7 +32560,7 @@ yargs@^13.2.2, yargs@^13.3.0: y18n "^4.0.0" yargs-parser "^13.1.1" -yargs@^15.3.1: +yargs@^15.0.2, yargs@^15.1.0, yargs@^15.3.1: version "15.3.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.3.1.tgz#9505b472763963e54afe60148ad27a330818e98b" integrity sha512-92O1HWEjw27sBfgmXiixJWT5hRBp2eobqXicLtPBIDBhYB+1HpwZlXmbW2luivBJHBzki+7VyCLRtAkScbTBQA==