diff --git a/docs/api/alerting/create_rule.asciidoc b/docs/api/alerting/create_rule.asciidoc index 01b6dfc40fcf6..59b17c5c3b5e1 100644 --- a/docs/api/alerting/create_rule.asciidoc +++ b/docs/api/alerting/create_rule.asciidoc @@ -6,6 +6,8 @@ Create {kib} rules. +WARNING: This API supports <> only. + [[create-rule-api-request]] ==== Request diff --git a/docs/api/alerting/enable_rule.asciidoc b/docs/api/alerting/enable_rule.asciidoc index 60f18b3510904..112d4bbf61faa 100644 --- a/docs/api/alerting/enable_rule.asciidoc +++ b/docs/api/alerting/enable_rule.asciidoc @@ -6,6 +6,8 @@ Enable a rule. +WARNING: This API supports <> only. + [[enable-rule-api-request]] ==== Request diff --git a/docs/api/alerting/update_rule.asciidoc b/docs/api/alerting/update_rule.asciidoc index 76c88a009be01..ec82e60a8e879 100644 --- a/docs/api/alerting/update_rule.asciidoc +++ b/docs/api/alerting/update_rule.asciidoc @@ -6,6 +6,8 @@ Update the attributes for an existing rule. +WARNING: This API supports <> only. + [[update-rule-api-request]] ==== Request diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 217bb03549343..6e2d41e5ed679 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -67,6 +67,7 @@ yarn kbn watch-bazel - @kbn/babel-code-parser - @kbn/babel-preset - @kbn/config-schema +- @kbn/expect - @kbn/std - @kbn/tinymath - @kbn/utility-types diff --git a/docs/development/core/public/kibana-plugin-core-public.app_wrapper_class.md b/docs/development/core/public/kibana-plugin-core-public.app_wrapper_class.md new file mode 100644 index 0000000000000..577c7edbeef4a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.app_wrapper_class.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [APP\_WRAPPER\_CLASS](./kibana-plugin-core-public.app_wrapper_class.md) + +## APP\_WRAPPER\_CLASS variable + +The class name for top level \*and\* nested application wrappers to ensure proper layout + +Signature: + +```typescript +APP_WRAPPER_CLASS = "kbnAppWrapper" +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 39e554f5492ac..b868a7f8216df 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -138,6 +138,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | Variable | Description | | --- | --- | +| [APP\_WRAPPER\_CLASS](./kibana-plugin-core-public.app_wrapper_class.md) | The class name for top level \*and\* nested application wrappers to ensure proper layout | | [URL\_MAX\_LENGTH](./kibana-plugin-core-public.url_max_length.md) | The max URL length allowed by the current browser. Should be used to display warnings to users when query parameters cause URL to exceed this limit. | ## Type Aliases diff --git a/docs/development/core/server/kibana-plugin-core-server.app_wrapper_class.md b/docs/development/core/server/kibana-plugin-core-server.app_wrapper_class.md new file mode 100644 index 0000000000000..cdb0b909bf79d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.app_wrapper_class.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [APP\_WRAPPER\_CLASS](./kibana-plugin-core-server.app_wrapper_class.md) + +## APP\_WRAPPER\_CLASS variable + +The class name for top level \*and\* nested application wrappers to ensure proper layout + +Signature: + +```typescript +APP_WRAPPER_CLASS = "kbnAppWrapper" +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md b/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md new file mode 100644 index 0000000000000..bbd97ab517d29 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CustomHttpResponseOptions](./kibana-plugin-core-server.customhttpresponseoptions.md) > [bypassErrorFormat](./kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md) + +## CustomHttpResponseOptions.bypassErrorFormat property + +Bypass the default error formatting + +Signature: + +```typescript +bypassErrorFormat?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.md b/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.md index 67242bbd4e2ef..82089c831d718 100644 --- a/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.md @@ -17,6 +17,7 @@ export interface CustomHttpResponseOptionsT | HTTP message to send to the client | +| [bypassErrorFormat](./kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md) | boolean | Bypass the default error formatting | | [headers](./kibana-plugin-core-server.customhttpresponseoptions.headers.md) | ResponseHeaders | HTTP Headers with additional information about response | | [statusCode](./kibana-plugin-core-server.customhttpresponseoptions.statuscode.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md b/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md new file mode 100644 index 0000000000000..98792c47d564f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpResponseOptions](./kibana-plugin-core-server.httpresponseoptions.md) > [bypassErrorFormat](./kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md) + +## HttpResponseOptions.bypassErrorFormat property + +Bypass the default error formatting + +Signature: + +```typescript +bypassErrorFormat?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.md b/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.md index 9f31e86175f79..497adc6a5ec5d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.md @@ -17,5 +17,6 @@ export interface HttpResponseOptions | Property | Type | Description | | --- | --- | --- | | [body](./kibana-plugin-core-server.httpresponseoptions.body.md) | HttpResponsePayload | HTTP message to send to the client | +| [bypassErrorFormat](./kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md) | boolean | Bypass the default error formatting | | [headers](./kibana-plugin-core-server.httpresponseoptions.headers.md) | ResponseHeaders | HTTP Headers with additional information about response | diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index e33e9472d42a9..4df8d074ba9c8 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -230,6 +230,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | Variable | Description | | --- | --- | +| [APP\_WRAPPER\_CLASS](./kibana-plugin-core-server.app_wrapper_class.md) | The class name for top level \*and\* nested application wrappers to ensure proper layout | | [kibanaResponseFactory](./kibana-plugin-core-server.kibanaresponsefactory.md) | Set of helpers used to create KibanaResponse to form HTTP response on an incoming request. Should be returned as a result of [RequestHandler](./kibana-plugin-core-server.requesthandler.md) execution. | | [ServiceStatusLevels](./kibana-plugin-core-server.servicestatuslevels.md) | The current "level" of availability of a service. | | [validBodyOutput](./kibana-plugin-core-server.validbodyoutput.md) | The set of valid body.output | diff --git a/docs/settings/url-drilldown-settings.asciidoc b/docs/settings/url-drilldown-settings.asciidoc new file mode 100644 index 0000000000000..8be3a21bfbffc --- /dev/null +++ b/docs/settings/url-drilldown-settings.asciidoc @@ -0,0 +1,31 @@ +[[url-drilldown-settings-kb]] +=== URL drilldown settings in {kib} +++++ +URL drilldown settings +++++ + +Configure the URL drilldown settings in your `kibana.yml` configuration file. + +[cols="2*<"] +|=== +| [[url-drilldown-enabled]] `url_drilldown.enabled` + | When `true`, enables URL drilldowns on your {kib} instance. + +| [[external-URL-policy]] `externalUrl.policy` + | Configures the external URL policies. URL drilldowns respect the global *External URL* service, which you can use to deny or allow external URLs. +By default all external URLs are allowed. +|=== + +For example, to allow external URLs only to the `example.com` domain with the `https` scheme, except for the `danger.example.com` sub-domain, +which is denied even when `https` scheme is used: + +["source","yml"] +----------- +externalUrl.policy: + - allow: false + host: danger.example.com + - allow: true + host: example.com + protocol: https +----------- + diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 1b027739169ad..0aab86fb5a9e2 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -756,3 +756,4 @@ include::{kib-repo-dir}/settings/security-settings.asciidoc[] include::{kib-repo-dir}/settings/spaces-settings.asciidoc[] include::{kib-repo-dir}/settings/task-manager-settings.asciidoc[] include::{kib-repo-dir}/settings/telemetry-settings.asciidoc[] +include::{kib-repo-dir}/settings/url-drilldown-settings.asciidoc[] diff --git a/docs/user/dashboard/drilldowns.asciidoc b/docs/user/dashboard/drilldowns.asciidoc index cbe47f23fcbaf..fc25f84030ee2 100644 --- a/docs/user/dashboard/drilldowns.asciidoc +++ b/docs/user/dashboard/drilldowns.asciidoc @@ -2,8 +2,8 @@ [[drilldowns]] == Create custom dashboard actions -Custom dashboard actions, also known as drilldowns, allow you to create -workflows for analyzing and troubleshooting your data. Drilldowns apply only to the panel that you created the drilldown from, and are not shared across all of the panels. Each panel can have multiple drilldowns. +Custom dashboard actions, or _drilldowns_, allow you to create workflows for analyzing and troubleshooting your data. +Drilldowns apply only to the panel that you created the drilldown from, and are not shared across all panels. Each panel can have multiple drilldowns. Third-party developers can create drilldowns. To learn how to code drilldowns, refer to {kib-repo}blob/{branch}/x-pack/examples/ui_actions_enhanced_examples[this example plugin]. @@ -11,27 +11,23 @@ Third-party developers can create drilldowns. To learn how to code drilldowns, r [[supported-drilldowns]] === Supported drilldowns -{kib} supports two types of drilldowns. - -[NOTE] -===================================== -Some drilldowns are paid subscription features, while others are free. -For a comparison of the Elastic subscription levels, -refer https://www.elastic.co/subscriptions[the subscription page]. -===================================== +{kib} supports dashboard and URL drilldowns. [float] [[dashboard-drilldowns]] ==== Dashboard drilldowns Dashboard drilldowns enable you to open a dashboard from another dashboard, -taking the time range, filters, and other parameters with you, +taking the time range, filters, and other parameters with you so the context remains the same. Dashboard drilldowns help you to continue your analysis from a new perspective. For example, if you have a dashboard that shows the overall status of multiple data center, you can create a drilldown that navigates from the overall status dashboard to a dashboard that shows a single data center or server. +[role="screenshot"] +image:images/drilldown_on_piechart.gif[Drilldown on pie chart that navigates to another dashboard] + [float] [[url-drilldowns]] ==== URL drilldowns @@ -39,45 +35,25 @@ that shows a single data center or server. URL drilldowns enable you to navigate from a dashboard to internal or external URLs. Destination URLs can be dynamic, depending on the dashboard context or user interaction with a panel. For example, if you have a dashboard that shows data from a Github repository, you can create a URL drilldown -that opens Github from the dashboard. +that opens Github from the dashboard panel. + +[role="screenshot"] +image:images/url_drilldown_go_to_github.gif[Drilldown on pie chart that navigates to Github] Some panels support multiple interactions, also known as triggers. The <> you use to create a <> depends on the trigger you choose. URL drilldowns support these types of triggers: -* *Single click* — A single data point in the visualization. +* *Single click* — A single data point in the panel. -* *Range selection* — A range of values in a visualization. +* *Range selection* — A range of values in a panel. For example, *Single click* has `{{event.value}}` and *Range selection* has `{{event.from}}` and `{{event.to}}`. -To disable URL drilldowns on your {kib} instance, add the following line to `kibana.yml` config file: - -["source","yml"] ------------ -url_drilldown.enabled: false ------------ - -URL drilldown also respects the global *External URL* service, which can be used to deny/allow external URLs. -By default all external URLs are allowed. To configure external URL policies you need to use `externalUrl.policy` setting in `kibana.yml`, for example: - -["source","yml"] ------------ -externalUrl.policy: - - allow: false - host: danger.example.com - - allow: true - host: example.com - protocol: https ------------ - -The above rules allow external URLs only to `example.com` domain with `https` scheme, except for `danger.example.com` sub-domain, -which is denied even when `https` scheme is used. - [float] [[dashboard-drilldown-supported-panels]] -=== Supported panels +=== Supported panel types -The following panels support dashboard and URL drilldowns. +The following panel types support drilldowns. [options="header"] |=== @@ -138,7 +114,7 @@ The following panels support dashboard and URL drilldowns. | TSVB ^| X -^| +^| X | Tag Cloud ^| X @@ -160,25 +136,23 @@ The following panels support dashboard and URL drilldowns. [float] [[drilldowns-example]] -=== Try it: Create a dashboard drilldown +=== Create a dashboard drilldown To create dashboard drilldowns, you create or locate the dashboards you want to connect, then configure the drilldown that allows you to easily open one dashboard from the other dashboard. -image:images/drilldown_on_piechart.gif[Drilldown on pie chart that navigates to another dashboard] - [float] ==== Create the dashboard . Add the *Sample web logs* data. -. Create a new dashboard, then add the following panels: +. Create a new dashboard, then add the following panels from the *Visualize Library*: * *[Logs] Heatmap* * *[Logs] Host, Visits, and Bytes Table* * *[Logs] Total Requests and Bytes* * *[Logs] Visitors by OS* + -If you don’t see data for a panel, try changing the <>. +If you don’t see the data on a panel, try changing the <>. . Save the dashboard. In the *Title* field, enter `Host Overview`. @@ -197,79 +171,82 @@ Filter: `geo.src: CN` . Open the *[Logs] Visitors by OS* panel menu, then select *Create drilldown*. -. Give the drilldown a name, then select *Go to dashboard*. +. Click *Go to dashboard*. -. From the *Choose a destination dashboard* dropdown, select *Host Overview*. +.. Give the drilldown a name. For example, `My Drilldown`. -. To carry over the filter, query, and date range, make sure that *Use filters and query from origin dashboard* and *Use date range from origin dashboard* are selected. -+ -[role="screenshot"] -image::images/drilldown_create.png[Create drilldown with entries for drilldown name and destination] +.. From the *Choose a destination dashboard* dropdown, select *Host Overview*. -. Click *Create drilldown*. -+ -The drilldown is stored as dashboard metadata. +.. To use the geo.src filter, KQL query, and time filter, select *Use filters and query from origin dashboard* and *Use date range from origin dashboard*. + +.. Click *Create drilldown*. . Save the dashboard. -+ -If you fail to save the dashboard, the drilldown is lost when you navigate away from the dashboard. -. In the *[Logs] Visitors by OS* panel, click *win 8*, then select the drilldown. +. In the *[Logs] Visitors by OS* panel, click *win 8*, then select `My Drilldown`. + [role="screenshot"] image::images/drilldown_on_panel.png[Drilldown on pie chart that navigates to another dashboard] -. On the *Host Overview* dashboard, verify that the search query, filters, -and date range are carried over. +. On the *Host Overview* dashboard, verify that the geo.src filter, KQL query, and time filter are applied. [float] [[create-a-url-drilldown]] -=== Try it: Create a URL drilldown +=== Create a URL drilldown To create URL drilldowns, you add <> to a URL template, which configures the behavior of the drilldown. -image:images/url_drilldown_go_to_github.gif[Drilldown on pie chart that navigates to Github] - . Add the *Sample web logs* data. -. Open the *[Logs] Web traffic* dashboard. This isn’t data from Github, but works for demonstration purposes. +. Open the *[Logs] Web traffic* dashboard. . In the toolbar, click *Edit*. . Open the *[Logs] Visitors by OS* panel menu, then select *Create drilldown*. -.. In the *Name* field, enter `Show on Github`. +. Click *Go to URL*. + +.. Give the drilldown a name. For example, `Show on Github`. -.. Select *Go to URL*. +.. For the *Trigger*, select *Single click*. -.. Enter the URL template: +.. To navigate to the {kib} repository Github issues, enter the following in the *Enter URL* field: + [source, bash] ---- https://github.com/elastic/kibana/issues?q=is:issue+is:open+{{event.value}} ---- + -The example URL navigates to {kib} issues on Github. `{{event.value}}` is substituted with a value associated with a selected pie slice. -+ -[role="screenshot"] -image:images/url_drilldown_url_template.png[URL template input] +`{{event.value}}` is substituted with a value associated with a selected pie slice. .. Click *Create drilldown*. -+ -The drilldown is stored as dashboard metadata. . Save the dashboard. -+ -If you fail to save the dashboard, the drilldown is lost when you navigate away from the dashboard. . On the *[Logs] Visitors by OS* panel, click any chart slice, then select *Show on Github*. + [role="screenshot"] image:images/url_drilldown_popup.png[URL drilldown popup] -. On the page that lists the issues in the {kib} repository, verify the slice value appears in Github. +. In the list of {kib} repository issues, verify that the slice value appears. + [role="screenshot"] image:images/url_drilldown_github.png[Github] +[float] +[[manage-drilldowns]] +=== Manage drilldowns + +Make changes to your drilldowns, make a copy of your drilldowns for another panel, and delete drilldowns. + +. Open the panel menu that includes the drilldown, then click *Manage drilldowns*. + +. On the *Manage* tab, use the following options: + +* To change drilldowns, click *Edit* next to the drilldown you want to change, make your changes, then click *Save*. + +* To make a copy, click *Copy* next to the drilldown you want to change, enter the drilldown name, then click *Create drilldown*. + +* To delete a drilldown, select the drilldown you want to delete, then click *Delete*. + include::url-drilldown.asciidoc[] diff --git a/package.json b/package.json index 6af5c256c57fa..24355e25f6a8b 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "dependencies": { "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/charts": "29.0.0", + "@elastic/charts": "29.1.0", "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath/npm_module", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.4", "@elastic/ems-client": "7.13.0", @@ -446,7 +446,7 @@ "@kbn/es-archiver": "link:packages/kbn-es-archiver", "@kbn/eslint-import-resolver-kibana": "link:packages/kbn-eslint-import-resolver-kibana", "@kbn/eslint-plugin-eslint": "link:packages/kbn-eslint-plugin-eslint", - "@kbn/expect": "link:packages/kbn-expect", + "@kbn/expect": "link:bazel-bin/packages/kbn-expect/npm_module", "@kbn/optimizer": "link:packages/kbn-optimizer", "@kbn/plugin-generator": "link:packages/kbn-plugin-generator", "@kbn/plugin-helpers": "link:packages/kbn-plugin-helpers", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 7f5182e907107..902c2804ee012 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -9,6 +9,7 @@ filegroup( "//packages/kbn-babel-code-parser:build", "//packages/kbn-babel-preset:build", "//packages/kbn-config-schema:build", + "//packages/kbn-expect:build", "//packages/kbn-std:build", "//packages/kbn-tinymath:build", "//packages/kbn-utility-types:build", diff --git a/packages/kbn-expect/BUILD.bazel b/packages/kbn-expect/BUILD.bazel new file mode 100644 index 0000000000000..82e6200e9688a --- /dev/null +++ b/packages/kbn-expect/BUILD.bazel @@ -0,0 +1,46 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-expect" +PKG_REQUIRE_NAME = "@kbn/expect" + +SOURCE_FILES = glob([ + "expect.js", + "expect.js.d.ts", +]) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "LICENSE.txt", + "package.json", + "README.md", +] + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES + [ + ":srcs", + ], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-expect/tsconfig.json b/packages/kbn-expect/tsconfig.json index ae7e9ff090cc2..7baae093bc3a9 100644 --- a/packages/kbn-expect/tsconfig.json +++ b/packages/kbn-expect/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "tsBuildInfoFile": "../../build/tsbuildinfo/packages/kbn-expect" + "incremental": false, }, "include": [ "expect.js.d.ts" diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 2a7a02b8e7f2f..95bf3f8f251b7 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -92,7 +92,7 @@ pageLoadAssetSize: visTypeTable: 94934 visTypeTagcloud: 37575 visTypeTimelion: 68883 - visTypeTimeseries: 155203 + visTypeTimeseries: 55203 visTypeVega: 153573 visTypeVislib: 242838 visTypeXy: 113478 diff --git a/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js b/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js index a43d3a09c7d70..f92d01d6454d5 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js +++ b/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js @@ -38,7 +38,7 @@ export async function runKibanaServer({ procs, config, options }) { ...extendNodeOptions(installDir), }, cwd: installDir || KIBANA_ROOT, - wait: /http server running/, + wait: /\[Kibana\]\[http\] http server running/, }); } diff --git a/packages/kbn-test/src/kbn_client/kbn_client_requester.ts b/packages/kbn-test/src/kbn_client/kbn_client_requester.ts index 31cd3a6899568..af75137d148e9 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_requester.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_requester.ts @@ -19,6 +19,10 @@ const isConcliftOnGetError = (error: any) => { ); }; +const isIgnorableError = (error: any, ignorableErrors: number[] = []) => { + return isAxiosResponseError(error) && ignorableErrors.includes(error.response.status); +}; + export const uriencode = ( strings: TemplateStringsArray, ...values: Array @@ -53,6 +57,7 @@ export interface ReqOptions { body?: any; retries?: number; headers?: Record; + ignoreErrors?: number[]; responseType?: ResponseType; } @@ -125,6 +130,10 @@ export class KbnClientRequester { const requestedRetries = options.retries !== undefined; const failedToGetResponse = isAxiosRequestError(error); + if (isIgnorableError(error, options.ignoreErrors)) { + return error.response; + } + let errorMessage; if (conflictOnGet) { errorMessage = `Conflict on GET (path=${options.path}, attempt=${attempt}/${maxAttempts})`; diff --git a/packages/kbn-test/src/kbn_client/kbn_client_status.ts b/packages/kbn-test/src/kbn_client/kbn_client_status.ts index 7e14e58309fa2..26c46917ae8dd 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_status.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_status.ts @@ -44,6 +44,8 @@ export class KbnClientStatus { const { data } = await this.requester.request({ method: 'GET', path: 'api/status', + // Status endpoint returns 503 if any services are in an unavailable state + ignoreErrors: [503], }); return data; } diff --git a/src/core/public/chrome/ui/header/_banner.scss b/src/core/public/chrome/ui/header/_banner.scss index 5bb70b8e53321..41ec7b08c6c04 100644 --- a/src/core/public/chrome/ui/header/_banner.scss +++ b/src/core/public/chrome/ui/header/_banner.scss @@ -1,6 +1,7 @@ .header__topBanner { position: fixed; top: 0; + left: 0; height: $kbnHeaderBannerHeight; width: 100%; z-index: $euiZHeader; diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index f2979d06338f1..1c4e78f0a5c2e 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -199,7 +199,7 @@ describe('#start()', () => { root.innerHTML = '

foo bar

'; await startCore(root); expect(root.innerHTML).toMatchInlineSnapshot( - `"
"` + `"
"` ); }); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index b68a7ced118d2..f0ea1e62fc33f 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -176,6 +176,7 @@ export class CoreSystem { const coreUiTargetDomElement = document.createElement('div'); coreUiTargetDomElement.id = 'kibana-body'; + coreUiTargetDomElement.dataset.testSubj = 'kibanaChrome'; const notificationsTargetDomElement = document.createElement('div'); const overlayTargetDomElement = document.createElement('div'); diff --git a/src/core/public/index.ts b/src/core/public/index.ts index ca432d6b8269f..17ba37d075b78 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -74,7 +74,7 @@ export type { DomainDeprecationDetails, } from '../server/types'; export type { CoreContext, CoreSystem } from './core_system'; -export { DEFAULT_APP_CATEGORIES } from '../utils'; +export { DEFAULT_APP_CATEGORIES, APP_WRAPPER_CLASS } from '../utils'; export type { AppCategory, UiSettingsParams, diff --git a/src/core/public/overlays/banners/_banners_list.scss b/src/core/public/overlays/banners/_banners_list.scss index 9d4df065a0a4f..3d10a71c84a95 100644 --- a/src/core/public/overlays/banners/_banners_list.scss +++ b/src/core/public/overlays/banners/_banners_list.scss @@ -1,7 +1,3 @@ -.kbnGlobalBannerList { - padding: $euiSize; -} - .kbnGlobalBannerList__item + .kbnGlobalBannerList__item { margin-top: $euiSizeS; } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index b3ded52a98171..26e2986abb928 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -73,6 +73,9 @@ export interface App { updater$?: Observable; } +// @public +export const APP_WRAPPER_CLASS = "kbnAppWrapper"; + // @public export interface AppCategory { ariaLabel?: string; diff --git a/src/core/public/rendering/_base.scss b/src/core/public/rendering/_base.scss index ed2d9bc0b3917..936b41e7682bb 100644 --- a/src/core/public/rendering/_base.scss +++ b/src/core/public/rendering/_base.scss @@ -1,16 +1,20 @@ @import '../mixins'; /** - * stretch the root element of the Kibana application to set the base-size that + * Stretch the root element of the Kibana application to set the base-size that * flexed children should keep. Only works when paired with root styles applied * by core service from new platform */ -// SASSTODO: Naming here is too embedded and high up that changing them could cause major breaks + #kibana-body { - overflow-x: hidden; + // DO NOT ADD ANY OVERFLOW BEHAVIORS HERE + // It will break the sticky navigation min-height: 100%; + display: flex; + flex-direction: column; } +// Affixes a div to restrict the position of charts tooltip to the visible viewport minus the header #app-fixed-viewport { pointer-events: none; visibility: hidden; @@ -21,26 +25,17 @@ left: 0; } -.app-wrapper { +.kbnAppWrapper { + // DO NOT ADD ANY OTHER STYLES TO THIS SELECTOR + // This a very nested dependency happnening in "all" apps display: flex; flex-flow: column nowrap; - margin: 0 auto; - - @include kibanaFullBodyMinHeight(); -} - -.app-wrapper-panel { - display: flex; flex-grow: 1; - flex-shrink: 0; - flex-basis: auto; - flex-direction: column; - - > * { - flex-shrink: 0; - } + z-index: 0; // This effectively puts every high z-index inside the scope of this wrapper to it doesn't interfere with the header and/or overlay mask + position: relative; // This is temporary for apps that relied on this being present on `.application` } +// TODO: This is problematic because it doesn't stay in line with EUI: // adapted from euiHeaderAffordForFixed as we need to handle the top banner @mixin kbnAffordForHeader($headerHeight) { padding-top: $headerHeight; diff --git a/src/core/public/rendering/app_containers.test.tsx b/src/core/public/rendering/app_containers.test.tsx index 9ef01258509cb..193e393f268f0 100644 --- a/src/core/public/rendering/app_containers.test.tsx +++ b/src/core/public/rendering/app_containers.test.tsx @@ -6,21 +6,25 @@ * Side Public License, v 1. */ -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { act } from 'react-dom/test-utils'; import { mount } from 'enzyme'; import React from 'react'; -import { AppWrapper, AppContainer } from './app_containers'; +import { AppWrapper } from './app_containers'; describe('AppWrapper', () => { it('toggles the `hidden-chrome` class depending on the chrome visibility state', () => { const chromeVisible$ = new BehaviorSubject(true); - const component = mount(app-content); + const component = mount( + + app-content + + ); expect(component.getDOMNode()).toMatchInlineSnapshot(`
app-content
@@ -30,7 +34,7 @@ describe('AppWrapper', () => { component.update(); expect(component.getDOMNode()).toMatchInlineSnapshot(`
app-content
@@ -40,22 +44,25 @@ describe('AppWrapper', () => { component.update(); expect(component.getDOMNode()).toMatchInlineSnapshot(`
app-content
`); }); -}); -describe('AppContainer', () => { it('adds classes supplied by chrome', () => { + const chromeVisible$ = new BehaviorSubject(true); const appClasses$ = new BehaviorSubject([]); - const component = mount(app-content); + const component = mount( + + app-content + + ); expect(component.getDOMNode()).toMatchInlineSnapshot(`
app-content
@@ -65,7 +72,7 @@ describe('AppContainer', () => { component.update(); expect(component.getDOMNode()).toMatchInlineSnapshot(`
app-content
@@ -75,7 +82,7 @@ describe('AppContainer', () => { component.update(); expect(component.getDOMNode()).toMatchInlineSnapshot(`
app-content
@@ -85,7 +92,7 @@ describe('AppContainer', () => { component.update(); expect(component.getDOMNode()).toMatchInlineSnapshot(`
app-content
diff --git a/src/core/public/rendering/app_containers.tsx b/src/core/public/rendering/app_containers.tsx index 0d715a6752694..64d64d2caad75 100644 --- a/src/core/public/rendering/app_containers.tsx +++ b/src/core/public/rendering/app_containers.tsx @@ -10,17 +10,23 @@ import React from 'react'; import { Observable } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; import classNames from 'classnames'; +import { APP_WRAPPER_CLASS } from '../../utils'; export const AppWrapper: React.FunctionComponent<{ chromeVisible$: Observable; -}> = ({ chromeVisible$, children }) => { - const visible = useObservable(chromeVisible$); - return
{children}
; -}; - -export const AppContainer: React.FunctionComponent<{ classes$: Observable; -}> = ({ classes$, children }) => { - const classes = useObservable(classes$); - return
{children}
; +}> = ({ chromeVisible$, classes$, children }) => { + const visible = useObservable(chromeVisible$); + const classes = useObservable(classes$, ['']); + return ( +
+ {children} +
+ ); }; diff --git a/src/core/public/rendering/rendering_service.test.tsx b/src/core/public/rendering/rendering_service.test.tsx index d293e2d44ba6a..d9eb764fc9f0d 100644 --- a/src/core/public/rendering/rendering_service.test.tsx +++ b/src/core/public/rendering/rendering_service.test.tsx @@ -13,7 +13,7 @@ import { RenderingService } from './rendering_service'; import { applicationServiceMock } from '../application/application_service.mock'; import { chromeServiceMock } from '../chrome/chrome_service.mock'; import { overlayServiceMock } from '../overlays/overlay_service.mock'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; describe('RenderingService#start', () => { let application: ReturnType; @@ -28,6 +28,7 @@ describe('RenderingService#start', () => { chrome = chromeServiceMock.createStartContract(); chrome.getHeaderComponent.mockReturnValue(
Hello chrome!
); + chrome.getApplicationClasses$.mockReturnValue(of([])); overlays = overlayServiceMock.createStartContract(); overlays.banners.getComponent.mockReturnValue(
I'm a banner!
); @@ -48,54 +49,58 @@ describe('RenderingService#start', () => { it('renders application service into provided DOM element', () => { startService(); - expect(targetDomElement.querySelector('div.application')).toMatchInlineSnapshot(` -
-
- Hello application! -
-
- `); + expect(targetDomElement.querySelector('div.kbnAppWrapper')).toMatchInlineSnapshot(` +
+
+
+ Hello application! +
+
+ `); }); - it('adds the `chrome-hidden` class to the AppWrapper when chrome is hidden', () => { + it('adds the `kbnAppWrapper--hiddenChrome` class to the AppWrapper when chrome is hidden', () => { const isVisible$ = new BehaviorSubject(true); chrome.getIsVisible$.mockReturnValue(isVisible$); startService(); - const appWrapper = targetDomElement.querySelector('div.app-wrapper')!; - expect(appWrapper.className).toEqual('app-wrapper'); + const appWrapper = targetDomElement.querySelector('div.kbnAppWrapper')!; + expect(appWrapper.className).toEqual('kbnAppWrapper'); act(() => isVisible$.next(false)); - expect(appWrapper.className).toEqual('app-wrapper hidden-chrome'); + expect(appWrapper.className).toEqual('kbnAppWrapper kbnAppWrapper--hiddenChrome'); act(() => isVisible$.next(true)); - expect(appWrapper.className).toEqual('app-wrapper'); + expect(appWrapper.className).toEqual('kbnAppWrapper'); }); - it('adds the application classes to the AppContainer', () => { + it('adds the application classes to the AppWrapper', () => { const applicationClasses$ = new BehaviorSubject([]); + const isVisible$ = new BehaviorSubject(true); + chrome.getIsVisible$.mockReturnValue(isVisible$); chrome.getApplicationClasses$.mockReturnValue(applicationClasses$); startService(); - const appContainer = targetDomElement.querySelector('div.application')!; - expect(appContainer.className).toEqual('application'); + const appContainer = targetDomElement.querySelector('div.kbnAppWrapper')!; + expect(appContainer.className).toEqual('kbnAppWrapper'); act(() => applicationClasses$.next(['classA', 'classB'])); - expect(appContainer.className).toEqual('application classA classB'); + expect(appContainer.className).toEqual('kbnAppWrapper classA classB'); act(() => applicationClasses$.next(['classC'])); - expect(appContainer.className).toEqual('application classC'); + expect(appContainer.className).toEqual('kbnAppWrapper classC'); act(() => applicationClasses$.next([])); - expect(appContainer.className).toEqual('application'); + expect(appContainer.className).toEqual('kbnAppWrapper'); }); it('contains wrapper divs', () => { startService(); - expect(targetDomElement.querySelector('div.app-wrapper')).toBeDefined(); - expect(targetDomElement.querySelector('div.app-wrapper-pannel')).toBeDefined(); + expect(targetDomElement.querySelector('div.kbnAppWrapper')).toBeDefined(); }); it('renders the banner UI', () => { diff --git a/src/core/public/rendering/rendering_service.tsx b/src/core/public/rendering/rendering_service.tsx index 787fa475c7d5f..1dfb4259d7d70 100644 --- a/src/core/public/rendering/rendering_service.tsx +++ b/src/core/public/rendering/rendering_service.tsx @@ -14,7 +14,7 @@ import { pairwise, startWith } from 'rxjs/operators'; import { InternalChromeStart } from '../chrome'; import { InternalApplicationStart } from '../application'; import { OverlayStart } from '../overlays'; -import { AppWrapper, AppContainer } from './app_containers'; +import { AppWrapper } from './app_containers'; interface StartDeps { application: InternalApplicationStart; @@ -48,16 +48,25 @@ export class RenderingService { ReactDOM.render( -
+ <> + {/* Fixed headers */} {chromeHeader} - -
-
-
{bannerComponent}
- {appComponent} -
+ + {/* banners$.subscribe() for things like the No data banner */} +
{bannerComponent}
+ + {/* The App Wrapper outside of the fixed headers that accepts custom class names from apps */} + + {/* Affixes a div to restrict the position of charts tooltip to the visible viewport minus the header */} +
+ + {/* The actual plugin/app */} + {appComponent} -
+ , targetDomElement ); diff --git a/src/core/public/styles/_ace_overrides.scss b/src/core/public/styles/_ace_overrides.scss index 30acdbbc80975..ca5230b46acd3 100644 --- a/src/core/public/styles/_ace_overrides.scss +++ b/src/core/public/styles/_ace_overrides.scss @@ -6,7 +6,7 @@ // In order to override the TM (Textmate) theme of Ace/Brace, everywhere, // it is being scoped by a known outer selector -.application { +.kbnBody { .ace-tm { $aceBackground: tintOrShade($euiColorLightShade, 50%, 0); diff --git a/src/core/public/styles/_base.scss b/src/core/public/styles/_base.scss index bfb07c1b51427..46f46b469783b 100644 --- a/src/core/public/styles/_base.scss +++ b/src/core/public/styles/_base.scss @@ -5,29 +5,6 @@ // Grab some nav-specific EUI vars @import '@elastic/eui/src/components/collapsible_nav/variables'; -// Application Layout - -.application, -.app-container { - > * { - position: relative; - } -} - -.application { - position: relative; - z-index: 0; - display: flex; - flex-grow: 1; - flex-shrink: 0; - flex-basis: auto; - flex-direction: column; - - > * { - flex-shrink: 0; - } -} - // We apply brute force focus states to anything not coming from Eui // which has focus states designed at the component level. // You can also use "kbn-resetFocusState" to not apply the default focus diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 1a82907849cea..7624a11a6f03f 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -138,6 +138,40 @@ test('log listening address after started when configured with BasePath and rewr `); }); +test('does not allow router registration after server is listening', async () => { + expect(server.isListening()).toBe(false); + + const { registerRouter } = await server.setup(config); + + const router1 = new Router('/foo', logger, enhanceWithContext); + expect(() => registerRouter(router1)).not.toThrowError(); + + await server.start(); + + expect(server.isListening()).toBe(true); + + const router2 = new Router('/bar', logger, enhanceWithContext); + expect(() => registerRouter(router2)).toThrowErrorMatchingInlineSnapshot( + `"Routers can be registered only when HTTP server is stopped."` + ); +}); + +test('allows router registration after server is listening via `registerRouterAfterListening`', async () => { + expect(server.isListening()).toBe(false); + + const { registerRouterAfterListening } = await server.setup(config); + + const router1 = new Router('/foo', logger, enhanceWithContext); + expect(() => registerRouterAfterListening(router1)).not.toThrowError(); + + await server.start(); + + expect(server.isListening()).toBe(true); + + const router2 = new Router('/bar', logger, enhanceWithContext); + expect(() => registerRouterAfterListening(router2)).not.toThrowError(); +}); + test('valid params', async () => { const router = new Router('/foo', logger, enhanceWithContext); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index d845ac1b639b6..8b4c3b9416152 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -33,6 +33,7 @@ import { KibanaRouteOptions, KibanaRequestState, isSafeMethod, + RouterRoute, } from './router'; import { SessionStorageCookieOptions, @@ -52,6 +53,13 @@ export interface HttpServerSetup { * @param router {@link IRouter} - a router with registered route handlers. */ registerRouter: (router: IRouter) => void; + /** + * Add all the routes registered with `router` to HTTP server request listeners. + * Unlike `registerRouter`, this function allows routes to be registered even after the server + * has started listening for requests. + * @param router {@link IRouter} - a router with registered route handlers. + */ + registerRouterAfterListening: (router: IRouter) => void; registerStaticDir: (path: string, dirPath: string) => void; basePath: HttpServiceSetup['basePath']; csp: HttpServiceSetup['csp']; @@ -114,6 +122,17 @@ export class HttpServer { this.registeredRouters.add(router); } + private registerRouterAfterListening(router: IRouter) { + if (this.isListening()) { + for (const route of router.getRoutes()) { + this.configureRoute(route); + } + } else { + // Not listening yet, add to set of registeredRouters so that it can be added after listening has started. + this.registeredRouters.add(router); + } + } + public async setup(config: HttpConfig): Promise { const serverOptions = getServerOptions(config); const listenerOptions = getListenerOptions(config); @@ -130,6 +149,7 @@ export class HttpServer { return { registerRouter: this.registerRouter.bind(this), + registerRouterAfterListening: this.registerRouterAfterListening.bind(this), registerStaticDir: this.registerStaticDir.bind(this), registerOnPreRouting: this.registerOnPreRouting.bind(this), registerOnPreAuth: this.registerOnPreAuth.bind(this), @@ -170,45 +190,7 @@ export class HttpServer { for (const router of this.registeredRouters) { for (const route of router.getRoutes()) { - this.log.debug(`registering route handler for [${route.path}]`); - // Hapi does not allow payload validation to be specified for 'head' or 'get' requests - const validate = isSafeMethod(route.method) ? undefined : { payload: true }; - const { authRequired, tags, body = {}, timeout } = route.options; - const { accepts: allow, maxBytes, output, parse } = body; - - const kibanaRouteOptions: KibanaRouteOptions = { - xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method), - }; - - this.server.route({ - handler: route.handler, - method: route.method, - path: route.path, - options: { - auth: this.getAuthOption(authRequired), - app: kibanaRouteOptions, - tags: tags ? Array.from(tags) : undefined, - // TODO: This 'validate' section can be removed once the legacy platform is completely removed. - // We are telling Hapi that NP routes can accept any payload, so that it can bypass the default - // validation applied in ./http_tools#getServerOptions - // (All NP routes are already required to specify their own validation in order to access the payload) - validate, - // @ts-expect-error Types are outdated and doesn't allow `payload.multipart` to be `true` - payload: [allow, maxBytes, output, parse, timeout?.payload].some((x) => x !== undefined) - ? { - allow, - maxBytes, - output, - parse, - timeout: timeout?.payload, - multipart: true, - } - : undefined, - timeout: { - socket: timeout?.idleSocket ?? this.config!.socketTimeout, - }, - }, - }); + this.configureRoute(route); } } @@ -486,4 +468,46 @@ export class HttpServer { options: { auth: false }, }); } + + private configureRoute(route: RouterRoute) { + this.log.debug(`registering route handler for [${route.path}]`); + // Hapi does not allow payload validation to be specified for 'head' or 'get' requests + const validate = isSafeMethod(route.method) ? undefined : { payload: true }; + const { authRequired, tags, body = {}, timeout } = route.options; + const { accepts: allow, maxBytes, output, parse } = body; + + const kibanaRouteOptions: KibanaRouteOptions = { + xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method), + }; + + this.server!.route({ + handler: route.handler, + method: route.method, + path: route.path, + options: { + auth: this.getAuthOption(authRequired), + app: kibanaRouteOptions, + tags: tags ? Array.from(tags) : undefined, + // TODO: This 'validate' section can be removed once the legacy platform is completely removed. + // We are telling Hapi that NP routes can accept any payload, so that it can bypass the default + // validation applied in ./http_tools#getServerOptions + // (All NP routes are already required to specify their own validation in order to access the payload) + validate, + // @ts-expect-error Types are outdated and doesn't allow `payload.multipart` to be `true` + payload: [allow, maxBytes, output, parse, timeout?.payload].some((x) => x !== undefined) + ? { + allow, + maxBytes, + output, + parse, + timeout: timeout?.payload, + multipart: true, + } + : undefined, + timeout: { + socket: timeout?.idleSocket ?? this.config!.socketTimeout, + }, + }, + }); + } } diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index 83279e99bc476..ebb9ad971b848 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -68,20 +68,32 @@ test('creates and sets up http server', async () => { start: jest.fn(), stop: jest.fn(), }; - mockHttpServer.mockImplementation(() => httpServer); + const notReadyHttpServer = { + isListening: () => false, + setup: jest.fn().mockReturnValue({ server: fakeHapiServer }), + start: jest.fn(), + stop: jest.fn(), + }; + mockHttpServer.mockImplementationOnce(() => httpServer); + mockHttpServer.mockImplementationOnce(() => notReadyHttpServer); const service = new HttpService({ coreId, configService, env, logger }); expect(mockHttpServer.mock.instances.length).toBe(1); expect(httpServer.setup).not.toHaveBeenCalled(); + expect(notReadyHttpServer.setup).not.toHaveBeenCalled(); await service.setup(setupDeps); expect(httpServer.setup).toHaveBeenCalled(); expect(httpServer.start).not.toHaveBeenCalled(); + expect(notReadyHttpServer.setup).toHaveBeenCalled(); + expect(notReadyHttpServer.start).toHaveBeenCalled(); + await service.start(); expect(httpServer.start).toHaveBeenCalled(); + expect(notReadyHttpServer.stop).toHaveBeenCalled(); }); test('spins up notReady server until started if configured with `autoListen:true`', async () => { @@ -102,6 +114,8 @@ test('spins up notReady server until started if configured with `autoListen:true .mockImplementationOnce(() => httpServer) .mockImplementationOnce(() => ({ setup: () => ({ server: notReadyHapiServer }), + start: jest.fn(), + stop: jest.fn().mockImplementation(() => notReadyHapiServer.stop()), })); const service = new HttpService({ @@ -163,7 +177,14 @@ test('stops http server', async () => { start: noop, stop: jest.fn(), }; - mockHttpServer.mockImplementation(() => httpServer); + const notReadyHttpServer = { + isListening: () => false, + setup: jest.fn().mockReturnValue({ server: fakeHapiServer }), + start: noop, + stop: jest.fn(), + }; + mockHttpServer.mockImplementationOnce(() => httpServer); + mockHttpServer.mockImplementationOnce(() => notReadyHttpServer); const service = new HttpService({ coreId, configService, env, logger }); @@ -171,6 +192,7 @@ test('stops http server', async () => { await service.start(); expect(httpServer.stop).toHaveBeenCalledTimes(0); + expect(notReadyHttpServer.stop).toHaveBeenCalledTimes(1); await service.stop(); @@ -188,7 +210,7 @@ test('stops not ready server if it is running', async () => { isListening: () => false, setup: jest.fn().mockReturnValue({ server: mockHapiServer }), start: noop, - stop: jest.fn(), + stop: jest.fn().mockImplementation(() => mockHapiServer.stop()), }; mockHttpServer.mockImplementation(() => httpServer); @@ -198,7 +220,7 @@ test('stops not ready server if it is running', async () => { await service.stop(); - expect(mockHapiServer.stop).toHaveBeenCalledTimes(1); + expect(mockHapiServer.stop).toHaveBeenCalledTimes(2); }); test('register route handler', async () => { @@ -231,6 +253,7 @@ test('returns http server contract on setup', async () => { mockHttpServer.mockImplementation(() => ({ isListening: () => false, setup: jest.fn().mockReturnValue(httpServer), + start: noop, stop: noop, })); diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index fdf9b738a9833..0d28506607682 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -8,7 +8,6 @@ import { Observable, Subscription, combineLatest, of } from 'rxjs'; import { first, map } from 'rxjs/operators'; -import { Server } from '@hapi/hapi'; import { pick } from '@kbn/std'; import type { RequestHandlerContext } from 'src/core/server'; @@ -20,7 +19,7 @@ import { CoreContext } from '../core_context'; import { PluginOpaqueId } from '../plugins'; import { CspConfigType, config as cspConfig } from '../csp'; -import { Router } from './router'; +import { IRouter, Router } from './router'; import { HttpConfig, HttpConfigType, config as httpConfig } from './http_config'; import { HttpServer } from './http_server'; import { HttpsRedirectServer } from './https_redirect_server'; @@ -30,6 +29,7 @@ import { RequestHandlerContextProvider, InternalHttpServiceSetup, InternalHttpServiceStart, + InternalNotReadyHttpServiceSetup, } from './types'; import { registerCoreHandlers } from './lifecycle_handlers'; @@ -54,7 +54,7 @@ export class HttpService private readonly logger: LoggerFactory; private readonly log: Logger; private readonly env: Env; - private notReadyServer?: Server; + private notReadyServer?: HttpServer; private internalSetup?: InternalHttpServiceSetup; private requestHandlerContext?: RequestHandlerContextContainer; @@ -88,9 +88,7 @@ export class HttpService const config = await this.config$.pipe(first()).toPromise(); - if (this.shouldListen(config)) { - await this.runNotReadyServer(config); - } + const notReadyServer = await this.setupNotReadyService({ config, context: deps.context }); const { registerRouter, ...serverContract } = await this.httpServer.setup(config); @@ -99,6 +97,8 @@ export class HttpService this.internalSetup = { ...serverContract, + notReadyServer, + externalUrl: new ExternalUrlConfig(config.externalUrl), createRouter: ( @@ -178,14 +178,51 @@ export class HttpService await this.httpsRedirectServer.stop(); } + private async setupNotReadyService({ + config, + context, + }: { + config: HttpConfig; + context: ContextSetup; + }): Promise { + if (!this.shouldListen(config)) { + return; + } + + const notReadySetup = await this.runNotReadyServer(config); + + // We cannot use the real context container since the core services may not yet be ready + const fakeContext: RequestHandlerContextContainer = new Proxy( + context.createContextContainer(), + { + get: (target, property, receiver) => { + if (property === 'createHandler') { + return Reflect.get(target, property, receiver); + } + throw new Error(`Unexpected access from fake context: ${String(property)}`); + }, + } + ); + + return { + registerRoutes: (path: string, registerCallback: (router: IRouter) => void) => { + const router = new Router( + path, + this.log, + fakeContext.createHandler.bind(null, this.coreContext.coreId) + ); + + registerCallback(router); + notReadySetup.registerRouterAfterListening(router); + }, + }; + } + private async runNotReadyServer(config: HttpConfig) { this.log.debug('starting NotReady server'); - const httpServer = new HttpServer(this.logger, 'NotReady', of(config.shutdownTimeout)); - const { server } = await httpServer.setup(config); - this.notReadyServer = server; - // use hapi server while KibanaResponseFactory doesn't allow specifying custom headers - // https://github.com/elastic/kibana/issues/33779 - this.notReadyServer.route({ + this.notReadyServer = new HttpServer(this.logger, 'NotReady', of(config.shutdownTimeout)); + const notReadySetup = await this.notReadyServer.setup(config); + notReadySetup.server.route({ path: '/{p*}', method: '*', handler: (req, responseToolkit) => { @@ -201,5 +238,7 @@ export class HttpService }, }); await this.notReadyServer.start(); + + return notReadySetup; } } diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index 5b297ab44f8bb..354ab1c65d565 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -15,6 +15,8 @@ import { contextServiceMock } from '../../context/context_service.mock'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { createHttpServer } from '../test_utils'; import { HttpService } from '../http_service'; +import { Router } from '../router'; +import { loggerMock } from '@kbn/logging/target/mocks'; let server: HttpService; let logger: ReturnType; @@ -1836,3 +1838,57 @@ describe('ETag', () => { .expect(304, ''); }); }); + +describe('registerRouterAfterListening', () => { + it('allows a router to be registered before server has started listening', async () => { + const { server: innerServer, createRouter, registerRouterAfterListening } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => { + return res.ok({ body: 'hello' }); + }); + + const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); + + const otherRouter = new Router('/test', loggerMock.create(), enhanceWithContext); + otherRouter.get({ path: '/afterListening', validate: false }, (context, req, res) => { + return res.ok({ body: 'hello from other router' }); + }); + + registerRouterAfterListening(otherRouter); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200); + await supertest(innerServer.listener).get('/test/afterListening').expect(200); + }); + + it('allows a router to be registered after server has started listening', async () => { + const { server: innerServer, createRouter, registerRouterAfterListening } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => { + return res.ok({ body: 'hello' }); + }); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200); + await supertest(innerServer.listener).get('/test/afterListening').expect(404); + + const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); + + const otherRouter = new Router('/test', loggerMock.create(), enhanceWithContext); + otherRouter.get({ path: '/afterListening', validate: false }, (context, req, res) => { + return res.ok({ body: 'hello from other router' }); + }); + + registerRouterAfterListening(otherRouter); + + await supertest(innerServer.listener).get('/test/afterListening').expect(200); + }); +}); diff --git a/src/core/server/http/router/index.ts b/src/core/server/http/router/index.ts index a958d330bf24d..5ba8143936563 100644 --- a/src/core/server/http/router/index.ts +++ b/src/core/server/http/router/index.ts @@ -9,7 +9,13 @@ export { filterHeaders } from './headers'; export type { Headers, ResponseHeaders, KnownHeaders } from './headers'; export { Router } from './router'; -export type { RequestHandler, RequestHandlerWrapper, IRouter, RouteRegistrar } from './router'; +export type { + RequestHandler, + RequestHandlerWrapper, + IRouter, + RouteRegistrar, + RouterRoute, +} from './router'; export { isKibanaRequest, isRealRequest, ensureRawRequest, KibanaRequest } from './request'; export type { KibanaRequestEvents, diff --git a/src/core/server/http/router/response.ts b/src/core/server/http/router/response.ts index e2babf719f67e..6cea7fcf4c949 100644 --- a/src/core/server/http/router/response.ts +++ b/src/core/server/http/router/response.ts @@ -62,6 +62,8 @@ export interface HttpResponseOptions { body?: HttpResponsePayload; /** HTTP Headers with additional information about response */ headers?: ResponseHeaders; + /** Bypass the default error formatting */ + bypassErrorFormat?: boolean; } /** @@ -79,6 +81,8 @@ export interface CustomHttpResponseOptions; diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index f007a77a2a21a..bbd296d6b1831 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -277,6 +277,11 @@ export interface HttpServiceSetup { getServerInfo: () => HttpServerInfo; } +/** @internal */ +export interface InternalNotReadyHttpServiceSetup { + registerRoutes(path: string, callback: (router: IRouter) => void): void; +} + /** @internal */ export interface InternalHttpServiceSetup extends Omit { @@ -287,6 +292,7 @@ export interface InternalHttpServiceSetup path: string, plugin?: PluginOpaqueId ) => IRouter; + registerRouterAfterListening: (router: IRouter) => void; registerStaticDir: (path: string, dirPath: string) => void; getAuthHeaders: GetAuthHeaders; registerRouteHandlerContext: < @@ -297,6 +303,7 @@ export interface InternalHttpServiceSetup contextName: ContextName, provider: RequestHandlerContextProvider ) => RequestHandlerContextContainer; + notReadyServer?: InternalNotReadyHttpServiceSetup; } /** @public */ diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 9fccc4b8bc1f0..ca328f17b2ae1 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -397,7 +397,7 @@ export type { } from './deprecations'; export type { AppCategory } from '../types'; -export { DEFAULT_APP_CATEGORIES } from '../utils'; +export { DEFAULT_APP_CATEGORIES, APP_WRAPPER_CLASS } from '../utils'; export type { SavedObject, diff --git a/src/core/server/rendering/views/styles.tsx b/src/core/server/rendering/views/styles.tsx index 018ee2d48d8c7..105f94df9218f 100644 --- a/src/core/server/rendering/views/styles.tsx +++ b/src/core/server/rendering/views/styles.tsx @@ -89,8 +89,7 @@ const InlineStyles: FC<{ darkMode: boolean }> = ({ darkMode }) => { } .kbnWelcomeText { - font-family: - display: inline-block; + display: block; font-size: 14px; font-family: sans-serif; line-height: 40px !important; @@ -103,7 +102,7 @@ const InlineStyles: FC<{ darkMode: boolean }> = ({ darkMode }) => { text-align: center; line-height: 1; text-align: center; - font-faimily: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial !important; + font-family: sans-serif; letter-spacing: -.005em; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index cccd38bf5cc9e..8e538f6e12384 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -850,7 +850,8 @@ function assertNoDowngrades( * that we can later regenerate any inbound object references to match. * * @note This is only intended to be used when single-namespace object types are converted into multi-namespace object types. + * @internal */ -function deterministicallyRegenerateObjectId(namespace: string, type: string, id: string) { +export function deterministicallyRegenerateObjectId(namespace: string, type: string, id: string) { return uuidv5(`${namespace}:${type}:${id}`, uuidv5.DNS); // the uuidv5 namespace constant (uuidv5.DNS) is arbitrary } diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.ts b/src/core/server/saved_objects/migrations/core/elastic_index.ts index 460aabbc77415..44dd60097f1cd 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.ts @@ -14,7 +14,6 @@ import _ from 'lodash'; import { estypes } from '@elastic/elasticsearch'; import { MigrationEsClient } from './migration_es_client'; -import { CountResponse, SearchResponse } from '../../../elasticsearch'; import { IndexMapping } from '../../mappings'; import { SavedObjectsMigrationVersion } from '../../types'; import { AliasAction, RawDoc } from './call_cluster'; @@ -95,11 +94,11 @@ export async function fetchInfo(client: MigrationEsClient, index: string): Promi * Creates a reader function that serves up batches of documents from the index. We aren't using * an async generator, as that feature currently breaks Kibana's tooling. * - * @param {CallCluster} callCluster - The elastic search connection - * @param {string} - The index to be read from + * @param client - The elastic search connection + * @param index - The index to be read from * @param {opts} - * @prop {number} batchSize - The number of documents to read at a time - * @prop {string} scrollDuration - The scroll duration used for scrolling through the index + * @prop batchSize - The number of documents to read at a time + * @prop scrollDuration - The scroll duration used for scrolling through the index */ export function reader( client: MigrationEsClient, @@ -111,11 +110,11 @@ export function reader( const nextBatch = () => scrollId !== undefined - ? client.scroll>({ + ? client.scroll({ scroll, scroll_id: scrollId, }) - : client.search>({ + : client.search({ body: { size: batchSize, query: excludeUnusedTypesQuery, @@ -143,10 +142,6 @@ export function reader( /** * Writes the specified documents to the index, throws an exception * if any of the documents fail to save. - * - * @param {CallCluster} callCluster - * @param {string} index - * @param {RawDoc[]} docs */ export async function write(client: MigrationEsClient, index: string, docs: RawDoc[]) { const { body } = await client.bulk({ @@ -184,9 +179,9 @@ export async function write(client: MigrationEsClient, index: string, docs: RawD * it performs the check *each* time it is called, rather than memoizing itself, * as this is used to determine if migrations are complete. * - * @param {CallCluster} callCluster - * @param {string} index - * @param {SavedObjectsMigrationVersion} migrationVersion - The latest versions of the migrations + * @param client - The connection to ElasticSearch + * @param index + * @param migrationVersion - The latest versions of the migrations */ export async function migrationsUpToDate( client: MigrationEsClient, @@ -207,7 +202,7 @@ export async function migrationsUpToDate( return true; } - const { body } = await client.count({ + const { body } = await client.count({ body: { query: { bool: { @@ -271,9 +266,9 @@ export async function createIndex( * is a concrete index. This function will reindex `alias` into a new index, delete the `alias` * index, and then create an alias `alias` that points to the new index. * - * @param {CallCluster} callCluster - The connection to ElasticSearch - * @param {FullIndexInfo} info - Information about the mappings and name of the new index - * @param {string} alias - The name of the index being converted to an alias + * @param client - The ElasticSearch connection + * @param info - Information about the mappings and name of the new index + * @param alias - The name of the index being converted to an alias */ export async function convertToAlias( client: MigrationEsClient, @@ -297,7 +292,7 @@ export async function convertToAlias( * alias, meaning that it will only point to one index at a time, so we * remove any other indices from the alias. * - * @param {CallCluster} callCluster + * @param {CallCluster} client * @param {string} index * @param {string} alias * @param {AliasAction[]} aliasActions - Optional actions to be added to the updateAliases call @@ -377,7 +372,7 @@ async function reindex( ) { // We poll instead of having the request wait for completion, as for large indices, // the request times out on the Elasticsearch side of things. We have a relatively tight - // polling interval, as the request is fairly efficent, and we don't + // polling interval, as the request is fairly efficient, and we don't // want to block index migrations for too long on this. const pollInterval = 250; const { body: reindexBody } = await client.reindex({ 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 dd295efacf6b8..fcc03f363139b 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 @@ -27,6 +27,7 @@ describe('IndexMigrator', () => { index: '.kibana', kibanaVersion: '7.10.0', log: loggingSystemMock.create().get(), + setStatus: jest.fn(), mappingProperties: {}, pollInterval: 1, scrollDuration: '1m', diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.ts b/src/core/server/saved_objects/migrations/core/index_migrator.ts index 5bf5ae26f6a0a..14dba1db9b624 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.ts @@ -41,6 +41,8 @@ export class IndexMigrator { pollInterval: context.pollInterval, + setStatus: context.setStatus, + async isMigrated() { return !(await requiresMigration(context)); }, @@ -189,8 +191,7 @@ async function migrateSourceToDest(context: Context) { serializer, documentMigrator.migrateAndConvert, // @ts-expect-error @elastic/elasticsearch `Hit._id` may be a string | number in ES, but we always expect strings in the SO index. - docs, - log + docs ) ); } diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts index 66750a8abf1db..45e73f7dfae30 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts @@ -11,7 +11,6 @@ import _ from 'lodash'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectsSerializer } from '../../serialization'; import { migrateRawDocs } from './migrate_raw_docs'; -import { createSavedObjectsMigrationLoggerMock } from '../../migrations/mocks'; describe('migrateRawDocs', () => { test('converts raw docs to saved objects', async () => { @@ -24,8 +23,7 @@ describe('migrateRawDocs', () => { [ { _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }, { _id: 'c:d', _source: { type: 'c', c: { name: 'DDD' } } }, - ], - createSavedObjectsMigrationLoggerMock() + ] ); expect(result).toEqual([ @@ -59,7 +57,6 @@ describe('migrateRawDocs', () => { }); test('throws when encountering a corrupt saved object document', async () => { - const logger = createSavedObjectsMigrationLoggerMock(); const transform = jest.fn((doc: any) => [ set(_.cloneDeep(doc), 'attributes.name', 'TADA'), ]); @@ -69,8 +66,7 @@ describe('migrateRawDocs', () => { [ { _id: 'foo:b', _source: { type: 'a', a: { name: 'AAA' } } }, { _id: 'c:d', _source: { type: 'c', c: { name: 'DDD' } } }, - ], - logger + ] ); expect(result).rejects.toMatchInlineSnapshot( @@ -88,8 +84,7 @@ describe('migrateRawDocs', () => { const result = await migrateRawDocs( new SavedObjectsSerializer(new SavedObjectTypeRegistry()), transform, - [{ _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }], - createSavedObjectsMigrationLoggerMock() + [{ _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }] ); expect(result).toEqual([ @@ -119,12 +114,9 @@ describe('migrateRawDocs', () => { throw new Error('error during transform'); }); await expect( - migrateRawDocs( - new SavedObjectsSerializer(new SavedObjectTypeRegistry()), - transform, - [{ _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }], - createSavedObjectsMigrationLoggerMock() - ) + migrateRawDocs(new SavedObjectsSerializer(new SavedObjectTypeRegistry()), transform, [ + { _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }, + ]) ).rejects.toThrowErrorMatchingInlineSnapshot(`"error during transform"`); }); }); diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts index e75f29e54c876..102ec81646a92 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts @@ -16,7 +16,6 @@ import { SavedObjectUnsanitizedDoc, } from '../../serialization'; import { MigrateAndConvertFn } from './document_migrator'; -import { SavedObjectsMigrationLogger } from '.'; /** * Error thrown when saved object migrations encounter a corrupt saved object. @@ -46,8 +45,7 @@ export class CorruptSavedObjectError extends Error { export async function migrateRawDocs( serializer: SavedObjectsSerializer, migrateDoc: MigrateAndConvertFn, - rawDocs: SavedObjectsRawDoc[], - log: SavedObjectsMigrationLogger + rawDocs: SavedObjectsRawDoc[] ): Promise { const migrateDocWithoutBlocking = transformNonBlocking(migrateDoc); const processedDocs = []; diff --git a/src/core/server/saved_objects/migrations/core/migration_context.ts b/src/core/server/saved_objects/migrations/core/migration_context.ts index 441c7efed049f..d7f7aff45a470 100644 --- a/src/core/server/saved_objects/migrations/core/migration_context.ts +++ b/src/core/server/saved_objects/migrations/core/migration_context.ts @@ -25,6 +25,7 @@ import { buildActiveMappings } from './build_active_mappings'; import { VersionedTransformer } from './document_migrator'; import * as Index from './elastic_index'; import { SavedObjectsMigrationLogger, MigrationLogger } from './migration_logger'; +import { KibanaMigratorStatus } from '../kibana'; export interface MigrationOpts { batchSize: number; @@ -34,6 +35,7 @@ export interface MigrationOpts { index: string; kibanaVersion: string; log: Logger; + setStatus: (status: KibanaMigratorStatus) => void; mappingProperties: SavedObjectsTypeMappingDefinitions; documentMigrator: VersionedTransformer; serializer: SavedObjectsSerializer; @@ -57,6 +59,7 @@ export interface Context { documentMigrator: VersionedTransformer; kibanaVersion: string; log: SavedObjectsMigrationLogger; + setStatus: (status: KibanaMigratorStatus) => void; batchSize: number; pollInterval: number; scrollDuration: string; @@ -70,7 +73,7 @@ export interface Context { * and various info needed to migrate the source index. */ export async function migrationContext(opts: MigrationOpts): Promise { - const { log, client } = opts; + const { log, client, setStatus } = opts; const alias = opts.index; const source = createSourceContext(await Index.fetchInfo(client, alias), alias); const dest = createDestContext(source, alias, opts.mappingProperties); @@ -82,6 +85,7 @@ export async function migrationContext(opts: MigrationOpts): Promise { dest, kibanaVersion: opts.kibanaVersion, log: new MigrationLogger(log), + setStatus, batchSize: opts.batchSize, documentMigrator: opts.documentMigrator, pollInterval: opts.pollInterval, diff --git a/src/core/server/saved_objects/migrations/core/migration_coordinator.test.ts b/src/core/server/saved_objects/migrations/core/migration_coordinator.test.ts index 9a045d0fbf7f9..63476a15d77cd 100644 --- a/src/core/server/saved_objects/migrations/core/migration_coordinator.test.ts +++ b/src/core/server/saved_objects/migrations/core/migration_coordinator.test.ts @@ -19,6 +19,7 @@ describe('coordinateMigration', () => { throw { body: { error: { index: '.foo', type: 'resource_already_exists_exception' } } }; }); const isMigrated = jest.fn(); + const setStatus = jest.fn(); isMigrated.mockResolvedValueOnce(false).mockResolvedValueOnce(true); @@ -27,6 +28,7 @@ describe('coordinateMigration', () => { runMigration, pollInterval, isMigrated, + setStatus, }); expect(runMigration).toHaveBeenCalledTimes(1); @@ -39,12 +41,14 @@ describe('coordinateMigration', () => { const pollInterval = 1; const runMigration = jest.fn(() => Promise.resolve()); const isMigrated = jest.fn(() => Promise.resolve(true)); + const setStatus = jest.fn(); await coordinateMigration({ log, runMigration, pollInterval, isMigrated, + setStatus, }); expect(isMigrated).not.toHaveBeenCalled(); }); @@ -55,6 +59,7 @@ describe('coordinateMigration', () => { throw new Error('Doh'); }); const isMigrated = jest.fn(() => Promise.resolve(true)); + const setStatus = jest.fn(); await expect( coordinateMigration({ @@ -62,6 +67,7 @@ describe('coordinateMigration', () => { runMigration, pollInterval, isMigrated, + setStatus, }) ).rejects.toThrow(/Doh/); expect(isMigrated).not.toHaveBeenCalled(); diff --git a/src/core/server/saved_objects/migrations/core/migration_coordinator.ts b/src/core/server/saved_objects/migrations/core/migration_coordinator.ts index 3e66d37ce6964..5b99f050b0ece 100644 --- a/src/core/server/saved_objects/migrations/core/migration_coordinator.ts +++ b/src/core/server/saved_objects/migrations/core/migration_coordinator.ts @@ -24,11 +24,16 @@ */ import _ from 'lodash'; +import { KibanaMigratorStatus } from '../kibana'; import { SavedObjectsMigrationLogger } from './migration_logger'; const DEFAULT_POLL_INTERVAL = 15000; -export type MigrationStatus = 'waiting' | 'running' | 'completed'; +export type MigrationStatus = + | 'waiting_to_start' + | 'waiting_for_other_nodes' + | 'running' + | 'completed'; export type MigrationResult = | { status: 'skipped' } @@ -43,6 +48,7 @@ export type MigrationResult = interface Opts { runMigration: () => Promise; isMigrated: () => Promise; + setStatus: (status: KibanaMigratorStatus) => void; log: SavedObjectsMigrationLogger; pollInterval?: number; } @@ -64,7 +70,9 @@ export async function coordinateMigration(opts: Opts): Promise try { return await opts.runMigration(); } catch (error) { - if (handleIndexExists(error, opts.log)) { + const waitingIndex = handleIndexExists(error, opts.log); + if (waitingIndex) { + opts.setStatus({ status: 'waiting_for_other_nodes', waitingIndex }); await waitForMigration(opts.isMigrated, opts.pollInterval); return { status: 'skipped' }; } @@ -77,11 +85,11 @@ export async function coordinateMigration(opts: Opts): Promise * and is the cue for us to fall into a polling loop, waiting for some * other Kibana instance to complete the migration. */ -function handleIndexExists(error: any, log: SavedObjectsMigrationLogger) { +function handleIndexExists(error: any, log: SavedObjectsMigrationLogger): string | undefined { const isIndexExistsError = _.get(error, 'body.error.type') === 'resource_already_exists_exception'; if (!isIndexExistsError) { - return false; + return undefined; } const index = _.get(error, 'body.error.index'); @@ -93,7 +101,7 @@ function handleIndexExists(error: any, log: SavedObjectsMigrationLogger) { `restarting Kibana.` ); - return true; + return index; } /** 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 221e78e3e12e2..c6dfd2c2d1809 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 @@ -229,48 +229,6 @@ describe('KibanaMigrator', () => { jest.clearAllMocks(); }); - it('creates a V2 migrator that initializes a new index and migrates an existing index', async () => { - const options = mockV2MigrationOptions(); - const migrator = new KibanaMigrator(options); - const migratorStatus = migrator.getStatus$().pipe(take(3)).toPromise(); - migrator.prepareMigrations(); - await migrator.runMigrations(); - - // Basic assertions that we're creating and reindexing the expected indices - expect(options.client.indices.create).toHaveBeenCalledTimes(3); - expect(options.client.indices.create.mock.calls).toEqual( - expect.arrayContaining([ - // LEGACY_CREATE_REINDEX_TARGET - expect.arrayContaining([expect.objectContaining({ index: '.my-index_pre8.2.3_001' })]), - // CREATE_REINDEX_TEMP - expect.arrayContaining([ - expect.objectContaining({ index: '.my-index_8.2.3_reindex_temp' }), - ]), - // CREATE_NEW_TARGET - expect.arrayContaining([expect.objectContaining({ index: 'other-index_8.2.3_001' })]), - ]) - ); - // LEGACY_REINDEX - expect(options.client.reindex.mock.calls[0][0]).toEqual( - expect.objectContaining({ - body: expect.objectContaining({ - source: expect.objectContaining({ index: '.my-index' }), - dest: expect.objectContaining({ index: '.my-index_pre8.2.3_001' }), - }), - }) - ); - // REINDEX_SOURCE_TO_TEMP - expect(options.client.reindex.mock.calls[1][0]).toEqual( - expect.objectContaining({ - body: expect.objectContaining({ - source: expect.objectContaining({ index: '.my-index_pre8.2.3_001' }), - dest: expect.objectContaining({ index: '.my-index_8.2.3_reindex_temp' }), - }), - }) - ); - const { status } = await migratorStatus; - return expect(status).toEqual('completed'); - }); it('emits results on getMigratorResult$()', async () => { const options = mockV2MigrationOptions(); const migrator = new KibanaMigrator(options); @@ -378,6 +336,24 @@ const mockV2MigrationOptions = () => { } as estypes.GetTaskResponse) ); + options.client.search = jest + .fn() + .mockImplementation(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { hits: [] } }) + ); + + options.client.openPointInTime = jest + .fn() + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ id: 'pit_id' }) + ); + + options.client.closePointInTime = jest + .fn() + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ succeeded: true }) + ); + return options; }; diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index 29852f8ac6445..e09284b49c86e 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -36,7 +36,6 @@ import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectsType } from '../../types'; import { runResilientMigrator } from '../../migrationsv2'; import { migrateRawDocs } from '../core/migrate_raw_docs'; -import { MigrationLogger } from '../core/migration_logger'; export interface KibanaMigratorOptions { client: ElasticsearchClient; @@ -53,6 +52,7 @@ export type IKibanaMigrator = Pick; export interface KibanaMigratorStatus { status: MigrationStatus; result?: MigrationResult[]; + waitingIndex?: string; } /** @@ -68,7 +68,7 @@ export class KibanaMigrator { private readonly serializer: SavedObjectsSerializer; private migrationResult?: Promise; private readonly status$ = new BehaviorSubject({ - status: 'waiting', + status: 'waiting_to_start', }); private readonly activeMappings: IndexMapping; private migrationsRetryDelay?: number; @@ -185,12 +185,7 @@ export class KibanaMigrator { logger: this.log, preMigrationScript: indexMap[index].script, transformRawDocs: (rawDocs: SavedObjectsRawDoc[]) => - migrateRawDocs( - this.serializer, - this.documentMigrator.migrateAndConvert, - rawDocs, - new MigrationLogger(this.log) - ), + migrateRawDocs(this.serializer, this.documentMigrator.migrateAndConvert, rawDocs), migrationVersionPerType: this.documentMigrator.migrationVersion, indexPrefix: index, migrationsConfig: this.soMigrationsConfig, @@ -206,6 +201,7 @@ export class KibanaMigrator { kibanaVersion: this.kibanaVersion, log: this.log, mappingProperties: indexMap[index].typeMappings, + setStatus: (status) => this.status$.next(status), pollInterval: this.soMigrationsConfig.pollInterval, scrollDuration: this.soMigrationsConfig.scrollDuration, serializer: this.serializer, diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/index.test.ts index bee17f42d7bdb..b144905cf01ad 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/index.test.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/index.test.ts @@ -78,6 +78,54 @@ describe('actions', () => { }); }); + describe('openPit', () => { + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = Actions.openPit(client, 'my_index'); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + }); + + describe('readWithPit', () => { + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = Actions.readWithPit(client, 'pitId', Option.none, 10_000); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + }); + + describe('closePit', () => { + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = Actions.closePit(client, 'pitId'); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + }); + + describe('transformDocs', () => { + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = Actions.transformDocs(client, () => Promise.resolve([]), [], 'my_index', false); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + }); + describe('reindex', () => { it('calls catchRetryableEsClientErrors when the promise rejects', async () => { const task = Actions.reindex( @@ -205,7 +253,7 @@ describe('actions', () => { describe('bulkOverwriteTransformedDocuments', () => { it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.bulkOverwriteTransformedDocuments(client, 'new_index', []); + const task = Actions.bulkOverwriteTransformedDocuments(client, 'new_index', [], 'wait_for'); try { await task(); } catch (e) { diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.ts b/src/core/server/saved_objects/migrationsv2/actions/index.ts index 02d3f8e21a510..049cdc41b7527 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/index.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/index.ts @@ -16,7 +16,8 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { flow } from 'fp-ts/lib/function'; import { ElasticsearchClient } from '../../../elasticsearch'; import { IndexMapping } from '../../mappings'; -import { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; +import type { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; +import type { TransformRawDocs } from '../types'; import { catchRetryableEsClientErrors, RetryableEsClientError, @@ -419,6 +420,133 @@ export const pickupUpdatedMappings = ( .catch(catchRetryableEsClientErrors); }; +/** @internal */ +export interface OpenPitResponse { + pitId: string; +} + +// how long ES should keep PIT alive +const pitKeepAlive = '10m'; +/* + * Creates a lightweight view of data when the request has been initiated. + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * */ +export const openPit = ( + client: ElasticsearchClient, + index: string +): TaskEither.TaskEither => () => { + return client + .openPointInTime({ + index, + keep_alive: pitKeepAlive, + }) + .then((response) => Either.right({ pitId: response.body.id })) + .catch(catchRetryableEsClientErrors); +}; + +/** @internal */ +export interface ReadWithPit { + outdatedDocuments: SavedObjectsRawDoc[]; + readonly lastHitSortValue: number[] | undefined; +} + +/* + * Requests documents from the index using PIT mechanism. + * Filter unusedTypesToExclude documents out to exclude them from being migrated. + * */ +export const readWithPit = ( + client: ElasticsearchClient, + pitId: string, + /* When reading we use a source query to exclude saved objects types which + * are no longer used. These saved objects will still be kept in the outdated + * index for backup purposes, but won't be available in the upgraded index. + */ + unusedTypesQuery: Option.Option, + batchSize: number, + searchAfter?: number[] +): TaskEither.TaskEither => () => { + return client + .search({ + body: { + // Sort fields are required to use searchAfter + sort: { + // the most efficient option as order is not important for the migration + _shard_doc: { order: 'asc' }, + }, + pit: { id: pitId, keep_alive: pitKeepAlive }, + size: batchSize, + search_after: searchAfter, + // Improve performance by not calculating the total number of hits + // matching the query. + track_total_hits: false, + // Exclude saved object types + query: Option.isSome(unusedTypesQuery) ? unusedTypesQuery.value : undefined, + }, + }) + .then((response) => { + const hits = response.body.hits.hits; + + if (hits.length > 0) { + return Either.right({ + // @ts-expect-error @elastic/elasticsearch _source is optional + outdatedDocuments: hits as SavedObjectsRawDoc[], + lastHitSortValue: hits[hits.length - 1].sort as number[], + }); + } + + return Either.right({ + outdatedDocuments: [], + lastHitSortValue: undefined, + }); + }) + .catch(catchRetryableEsClientErrors); +}; + +/* + * Closes PIT. + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * */ +export const closePit = ( + client: ElasticsearchClient, + pitId: string +): TaskEither.TaskEither => () => { + return client + .closePointInTime({ + body: { id: pitId }, + }) + .then((response) => { + if (!response.body.succeeded) { + throw new Error(`Failed to close PointInTime with id: ${pitId}`); + } + return Either.right({}); + }) + .catch(catchRetryableEsClientErrors); +}; + +/* + * Transform outdated docs and write them to the index. + * */ +export const transformDocs = ( + client: ElasticsearchClient, + transformRawDocs: TransformRawDocs, + outdatedDocuments: SavedObjectsRawDoc[], + index: string, + refresh: estypes.Refresh +): TaskEither.TaskEither< + RetryableEsClientError | IndexNotFound | TargetIndexHadWriteBlock, + 'bulk_index_succeeded' +> => + pipe( + TaskEither.tryCatch( + () => transformRawDocs(outdatedDocuments), + (e) => { + throw e; + } + ), + TaskEither.chain((docs) => bulkOverwriteTransformedDocuments(client, index, docs, refresh)) + ); + +/** @internal */ export interface ReindexResponse { taskId: string; } @@ -489,10 +617,12 @@ interface WaitForReindexTaskFailure { readonly cause: { type: string; reason: string }; } +/** @internal */ export interface TargetIndexHadWriteBlock { type: 'target_index_had_write_block'; } +/** @internal */ export interface IncompatibleMappingException { type: 'incompatible_mapping_exception'; } @@ -605,14 +735,17 @@ export const waitForPickupUpdatedMappingsTask = flow( ) ); +/** @internal */ export interface AliasNotFound { type: 'alias_not_found_exception'; } +/** @internal */ export interface RemoveIndexNotAConcreteIndex { type: 'remove_index_not_a_concrete_index'; } +/** @internal */ export type AliasAction = | { remove_index: { index: string } } | { remove: { index: string; alias: string; must_exist: boolean } } @@ -679,11 +812,19 @@ export const updateAliases = ( .catch(catchRetryableEsClientErrors); }; +/** @internal */ export interface AcknowledgeResponse { acknowledged: boolean; shardsAcknowledged: boolean; } +function aliasArrayToRecord(aliases: string[]): Record { + const result: Record = {}; + for (const alias of aliases) { + result[alias] = {}; + } + return result; +} /** * Creates an index with the given mappings * @@ -698,16 +839,13 @@ export const createIndex = ( client: ElasticsearchClient, indexName: string, mappings: IndexMapping, - aliases?: string[] + aliases: string[] = [] ): TaskEither.TaskEither => { const createIndexTask: TaskEither.TaskEither< RetryableEsClientError, AcknowledgeResponse > = () => { - const aliasesObject = (aliases ?? []).reduce((acc, alias) => { - acc[alias] = {}; - return acc; - }, {} as Record); + const aliasesObject = aliasArrayToRecord(aliases); return client.indices .create( @@ -792,6 +930,7 @@ export const createIndex = ( ); }; +/** @internal */ export interface UpdateAndPickupMappingsResponse { taskId: string; } @@ -842,6 +981,8 @@ export const updateAndPickupMappings = ( }) ); }; + +/** @internal */ export interface SearchResponse { outdatedDocuments: SavedObjectsRawDoc[]; } @@ -906,7 +1047,8 @@ export const searchForOutdatedDocuments = ( export const bulkOverwriteTransformedDocuments = ( client: ElasticsearchClient, index: string, - transformedDocs: SavedObjectsRawDoc[] + transformedDocs: SavedObjectsRawDoc[], + refresh: estypes.Refresh ): TaskEither.TaskEither => () => { return client .bulk({ @@ -919,15 +1061,7 @@ export const bulkOverwriteTransformedDocuments = ( // system indices puts in place a hard control. require_alias: false, wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, - // Wait for a refresh to happen before returning. This ensures that when - // this Kibana instance searches for outdated documents, it won't find - // documents that were already transformed by itself or another Kibna - // instance. However, this causes each OUTDATED_DOCUMENTS_SEARCH -> - // OUTDATED_DOCUMENTS_TRANSFORM cycle to take 1s so when batches are - // small performance will become a lot worse. - // The alternative is to use a search_after with either a tie_breaker - // field or using a Point In Time as a cursor to go through all documents. - refresh: 'wait_for', + refresh, filter_path: ['items.*.error'], body: transformedDocs.flatMap((doc) => { return [ diff --git a/src/core/server/saved_objects/migrationsv2/index.ts b/src/core/server/saved_objects/migrationsv2/index.ts index 6e65a2e700fd3..25816c7fd14c6 100644 --- a/src/core/server/saved_objects/migrationsv2/index.ts +++ b/src/core/server/saved_objects/migrationsv2/index.ts @@ -9,9 +9,10 @@ import { ElasticsearchClient } from '../../elasticsearch'; import { IndexMapping } from '../mappings'; import { Logger } from '../../logging'; -import { SavedObjectsMigrationVersion } from '../types'; +import type { SavedObjectsMigrationVersion } from '../types'; +import type { TransformRawDocs } from './types'; import { MigrationResult } from '../migrations/core'; -import { next, TransformRawDocs } from './next'; +import { next } from './next'; import { createInitialState, model } from './model'; import { migrationStateActionMachine } from './migrations_state_action_machine'; import { SavedObjectsMigrationConfigType } from '../saved_objects_config'; @@ -55,5 +56,6 @@ export async function runResilientMigrator({ logger, next: next(client, transformRawDocs), model, + client, }); } diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/.gitignore b/src/core/server/saved_objects/migrationsv2/integration_tests/.gitignore index 57208badcc680..397b4a7624e35 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/.gitignore +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/.gitignore @@ -1 +1 @@ -migration_test_kibana.log +*.log diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts index 3905044f04e2f..b31f20950ae77 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts @@ -14,9 +14,14 @@ import { SavedObjectsRawDoc } from '../../serialization'; import { bulkOverwriteTransformedDocuments, cloneIndex, + closePit, createIndex, fetchIndices, + openPit, + OpenPitResponse, reindex, + readWithPit, + ReadWithPit, searchForOutdatedDocuments, SearchResponse, setWriteBlock, @@ -30,6 +35,7 @@ import { UpdateAndPickupMappingsResponse, verifyReindex, removeWriteBlock, + transformDocs, waitForIndexStatusYellow, } from '../actions'; import * as Either from 'fp-ts/lib/Either'; @@ -70,14 +76,20 @@ describe('migration actions', () => { { _source: { title: 'saved object 4', type: 'another_unused_type' } }, { _source: { title: 'f-agent-event 5', type: 'f_agent_event' } }, ] as unknown) as SavedObjectsRawDoc[]; - await bulkOverwriteTransformedDocuments(client, 'existing_index_with_docs', sourceDocs)(); + await bulkOverwriteTransformedDocuments( + client, + 'existing_index_with_docs', + sourceDocs, + 'wait_for' + )(); await createIndex(client, 'existing_index_2', { properties: {} })(); await createIndex(client, 'existing_index_with_write_block', { properties: {} })(); await bulkOverwriteTransformedDocuments( client, 'existing_index_with_write_block', - sourceDocs + sourceDocs, + 'wait_for' )(); await setWriteBlock(client, 'existing_index_with_write_block')(); await updateAliases(client, [ @@ -155,7 +167,12 @@ describe('migration actions', () => { { _source: { title: 'doc 4' } }, ] as unknown) as SavedObjectsRawDoc[]; await expect( - bulkOverwriteTransformedDocuments(client, 'new_index_without_write_block', sourceDocs)() + bulkOverwriteTransformedDocuments( + client, + 'new_index_without_write_block', + sourceDocs, + 'wait_for' + )() ).rejects.toMatchObject(expect.anything()); }); it('resolves left index_not_found_exception when the index does not exist', async () => { @@ -265,14 +282,14 @@ describe('migration actions', () => { const task = cloneIndex(client, 'existing_index_with_write_block', 'clone_target_1'); expect.assertions(1); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": Object { - "acknowledged": true, - "shardsAcknowledged": true, - }, - } - `); + Object { + "_tag": "Right", + "right": Object { + "acknowledged": true, + "shardsAcknowledged": true, + }, + } + `); }); it('resolves right after waiting for index status to be yellow if clone target already existed', async () => { expect.assertions(2); @@ -331,14 +348,14 @@ describe('migration actions', () => { expect.assertions(1); const task = cloneIndex(client, 'no_such_index', 'clone_target_3'); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "index": "no_such_index", - "type": "index_not_found_exception", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "index": "no_such_index", + "type": "index_not_found_exception", + }, + } + `); }); it('resolves left with a retryable_es_client_error if clone target already exists but takes longer than the specified timeout before turning yellow', async () => { // Create a red index @@ -406,13 +423,13 @@ describe('migration actions', () => { targetIndex: 'reindex_target', outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - expect(results.map((doc) => doc._source.title)).toMatchInlineSnapshot(` + expect(results.map((doc) => doc._source.title).sort()).toMatchInlineSnapshot(` Array [ "doc 1", "doc 2", "doc 3", - "saved object 4", "f-agent-event 5", + "saved object 4", ] `); }); @@ -433,18 +450,18 @@ describe('migration actions', () => { )()) as Either.Right; const task = waitForReindexTask(client, res.right.taskId, '10s'); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": "reindex_succeeded", - } - `); + Object { + "_tag": "Right", + "right": "reindex_succeeded", + } + `); const results = ((await searchForOutdatedDocuments(client, { batchSize: 1000, targetIndex: 'reindex_target_excluded_docs', outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - expect(results.map((doc) => doc._source.title)).toMatchInlineSnapshot(` + expect(results.map((doc) => doc._source.title).sort()).toMatchInlineSnapshot(` Array [ "doc 1", "doc 2", @@ -474,13 +491,13 @@ describe('migration actions', () => { targetIndex: 'reindex_target_2', outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - expect(results.map((doc) => doc._source.title)).toMatchInlineSnapshot(` + expect(results.map((doc) => doc._source.title).sort()).toMatchInlineSnapshot(` Array [ "doc 1_updated", "doc 2_updated", "doc 3_updated", - "saved object 4_updated", "f-agent-event 5_updated", + "saved object 4_updated", ] `); }); @@ -526,13 +543,13 @@ describe('migration actions', () => { targetIndex: 'reindex_target_3', outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - expect(results.map((doc) => doc._source.title)).toMatchInlineSnapshot(` + expect(results.map((doc) => doc._source.title).sort()).toMatchInlineSnapshot(` Array [ "doc 1_updated", "doc 2_updated", "doc 3_updated", - "saved object 4_updated", "f-agent-event 5_updated", + "saved object 4_updated", ] `); }); @@ -551,7 +568,7 @@ describe('migration actions', () => { _id, _source, })); - await bulkOverwriteTransformedDocuments(client, 'reindex_target_4', sourceDocs)(); + await bulkOverwriteTransformedDocuments(client, 'reindex_target_4', sourceDocs, 'wait_for')(); // Now do a real reindex const res = (await reindex( @@ -576,13 +593,13 @@ describe('migration actions', () => { targetIndex: 'reindex_target_4', outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - expect(results.map((doc) => doc._source.title)).toMatchInlineSnapshot(` + expect(results.map((doc) => doc._source.title).sort()).toMatchInlineSnapshot(` Array [ "doc 1", "doc 2", "doc 3_updated", - "saved object 4_updated", "f-agent-event 5_updated", + "saved object 4_updated", ] `); }); @@ -790,9 +807,169 @@ describe('migration actions', () => { ); task = verifyReindex(client, 'existing_index_2', 'no_such_index'); - await expect(task()).rejects.toMatchInlineSnapshot( - `[ResponseError: index_not_found_exception]` + await expect(task()).rejects.toThrow('index_not_found_exception'); + }); + }); + + describe('openPit', () => { + it('opens PointInTime for an index', async () => { + const openPitTask = openPit(client, 'existing_index_with_docs'); + const pitResponse = (await openPitTask()) as Either.Right; + + expect(pitResponse.right.pitId).toEqual(expect.any(String)); + + const searchResponse = await client.search({ + body: { + pit: { id: pitResponse.right.pitId }, + }, + }); + + await expect(searchResponse.body.hits.hits.length).toBeGreaterThan(0); + }); + it('rejects if index does not exist', async () => { + const openPitTask = openPit(client, 'no_such_index'); + await expect(openPitTask()).rejects.toThrow('index_not_found_exception'); + }); + }); + + describe('readWithPit', () => { + it('requests documents from an index using given PIT', async () => { + const openPitTask = openPit(client, 'existing_index_with_docs'); + const pitResponse = (await openPitTask()) as Either.Right; + + const readWithPitTask = readWithPit( + client, + pitResponse.right.pitId, + Option.none, + 1000, + undefined + ); + const docsResponse = (await readWithPitTask()) as Either.Right; + + await expect(docsResponse.right.outdatedDocuments.length).toBe(5); + }); + + it('requests the batchSize of documents from an index', async () => { + const openPitTask = openPit(client, 'existing_index_with_docs'); + const pitResponse = (await openPitTask()) as Either.Right; + + const readWithPitTask = readWithPit( + client, + pitResponse.right.pitId, + Option.none, + 3, + undefined ); + const docsResponse = (await readWithPitTask()) as Either.Right; + + await expect(docsResponse.right.outdatedDocuments.length).toBe(3); + }); + + it('it excludes documents not matching the provided "unusedTypesQuery"', async () => { + const openPitTask = openPit(client, 'existing_index_with_docs'); + const pitResponse = (await openPitTask()) as Either.Right; + + const readWithPitTask = readWithPit( + client, + pitResponse.right.pitId, + Option.some({ + bool: { + must_not: [ + { + term: { + type: 'f_agent_event', + }, + }, + { + term: { + type: 'another_unused_type', + }, + }, + ], + }, + }), + 1000, + undefined + ); + + const docsResponse = (await readWithPitTask()) as Either.Right; + + expect(docsResponse.right.outdatedDocuments.map((doc) => doc._source.title).sort()) + .toMatchInlineSnapshot(` + Array [ + "doc 1", + "doc 2", + "doc 3", + ] + `); + }); + + it('rejects if PIT does not exist', async () => { + const readWithPitTask = readWithPit(client, 'no_such_pit', Option.none, 1000, undefined); + await expect(readWithPitTask()).rejects.toThrow('illegal_argument_exception'); + }); + }); + + describe('closePit', () => { + it('closes PointInTime', async () => { + const openPitTask = openPit(client, 'existing_index_with_docs'); + const pitResponse = (await openPitTask()) as Either.Right; + + const pitId = pitResponse.right.pitId; + await closePit(client, pitId)(); + + const searchTask = client.search({ + body: { + pit: { id: pitId }, + }, + }); + + await expect(searchTask).rejects.toThrow('search_phase_execution_exception'); + }); + + it('rejects if PIT does not exist', async () => { + const closePitTask = closePit(client, 'no_such_pit'); + await expect(closePitTask()).rejects.toThrow('illegal_argument_exception'); + }); + }); + + describe('transformDocs', () => { + it('applies "transformRawDocs" and writes result into an index', async () => { + const index = 'transform_docs_index'; + const originalDocs = [ + { _id: 'foo:1', _source: { type: 'dashboard', value: 1 } }, + { _id: 'foo:2', _source: { type: 'dashboard', value: 2 } }, + ]; + + const createIndexTask = createIndex(client, index, { + dynamic: true, + properties: {}, + }); + await createIndexTask(); + + async function tranformRawDocs(docs: SavedObjectsRawDoc[]): Promise { + for (const doc of docs) { + doc._source.value += 1; + } + return docs; + } + + const transformTask = transformDocs(client, tranformRawDocs, originalDocs, index, 'wait_for'); + + const result = (await transformTask()) as Either.Right<'bulk_index_succeeded'>; + + expect(result.right).toBe('bulk_index_succeeded'); + + const { body } = await client.search<{ value: number }>({ + index, + }); + const hits = body.hits.hits; + + const foo1 = hits.find((h) => h._id === 'foo:1'); + expect(foo1?._source?.value).toBe(2); + + const foo2 = hits.find((h) => h._id === 'foo:2'); + expect(foo2?._source?.value).toBe(3); }); }); @@ -919,7 +1096,8 @@ describe('migration actions', () => { await bulkOverwriteTransformedDocuments( client, 'existing_index_without_mappings', - sourceDocs + sourceDocs, + 'wait_for' )(); // Assert that we can't search over the unmapped fields of the document @@ -1147,7 +1325,13 @@ describe('migration actions', () => { { _source: { title: 'doc 6' } }, { _source: { title: 'doc 7' } }, ] as unknown) as SavedObjectsRawDoc[]; - const task = bulkOverwriteTransformedDocuments(client, 'existing_index_with_docs', newDocs); + const task = bulkOverwriteTransformedDocuments( + client, + 'existing_index_with_docs', + newDocs, + 'wait_for' + ); + await expect(task()).resolves.toMatchInlineSnapshot(` Object { "_tag": "Right", @@ -1162,10 +1346,12 @@ describe('migration actions', () => { outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - const task = bulkOverwriteTransformedDocuments(client, 'existing_index_with_docs', [ - ...existingDocs, - ({ _source: { title: 'doc 8' } } as unknown) as SavedObjectsRawDoc, - ]); + const task = bulkOverwriteTransformedDocuments( + client, + 'existing_index_with_docs', + [...existingDocs, ({ _source: { title: 'doc 8' } } as unknown) as SavedObjectsRawDoc], + 'wait_for' + ); await expect(task()).resolves.toMatchInlineSnapshot(` Object { "_tag": "Right", @@ -1180,7 +1366,12 @@ describe('migration actions', () => { { _source: { title: 'doc 7' } }, ] as unknown) as SavedObjectsRawDoc[]; await expect( - bulkOverwriteTransformedDocuments(client, 'existing_index_with_write_block', newDocs)() + bulkOverwriteTransformedDocuments( + client, + 'existing_index_with_write_block', + newDocs, + 'wait_for' + )() ).rejects.toMatchObject(expect.anything()); }); }); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_so_with_multiple_namespaces.zip b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_so_with_multiple_namespaces.zip new file mode 100644 index 0000000000000..a92211c16c559 Binary files /dev/null and b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_so_with_multiple_namespaces.zip differ diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_with_corrupted_so.zip b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_with_corrupted_so.zip new file mode 100644 index 0000000000000..c6c89ac2879b2 Binary files /dev/null and b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_with_corrupted_so.zip differ diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts new file mode 100644 index 0000000000000..997105587da68 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fs from 'fs'; +import Util from 'util'; +import JSON5 from 'json5'; +import * as kbnTestServer from '../../../../test_helpers/kbn_server'; +import type { Root } from '../../../root'; + +const logFilePath = Path.join(__dirname, 'cleanup_test.log'); + +const asyncUnlink = Util.promisify(Fs.unlink); +const asyncReadFile = Util.promisify(Fs.readFile); +async function removeLogFile() { + // ignore errors if it doesn't exist + await asyncUnlink(logFilePath).catch(() => void 0); +} + +function createRoot() { + return kbnTestServer.createRootWithCorePlugins( + { + migrations: { + skip: false, + enableV2: true, + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFilePath, + layout: { + type: 'json', + }, + }, + }, + loggers: [ + { + name: 'root', + appenders: ['file'], + }, + ], + }, + }, + { + oss: true, + } + ); +} + +// CI FAILURE: https://github.com/elastic/kibana/issues/98352 +describe.skip('migration v2', () => { + let esServer: kbnTestServer.TestElasticsearchUtils; + let root: Root; + + beforeAll(async () => { + await removeLogFile(); + }); + + afterAll(async () => { + if (root) { + await root.shutdown(); + } + if (esServer) { + await esServer.stop(); + } + + await new Promise((resolve) => setTimeout(resolve, 10000)); + }); + + it('clean ups if migration fails', async () => { + const { startES } = kbnTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + es: { + license: 'trial', + // original SO: + // { + // _index: '.kibana_7.13.0_001', + // _type: '_doc', + // _id: 'index-pattern:test_index*', + // _version: 1, + // result: 'created', + // _shards: { total: 2, successful: 1, failed: 0 }, + // _seq_no: 0, + // _primary_term: 1 + // } + dataArchive: Path.join(__dirname, 'archives', '7.13.0_with_corrupted_so.zip'), + }, + }, + }); + + root = createRoot(); + + esServer = await startES(); + await root.setup(); + + await expect(root.start()).rejects.toThrow( + /Unable to migrate the corrupt saved object document with _id: 'index-pattern:test_index\*'/ + ); + + const logFileContent = await asyncReadFile(logFilePath, 'utf-8'); + const records = logFileContent + .split('\n') + .filter(Boolean) + .map((str) => JSON5.parse(str)); + + const logRecordWithPit = records.find( + (rec) => rec.message === '[.kibana] REINDEX_SOURCE_TO_TEMP_OPEN_PIT RESPONSE' + ); + + expect(logRecordWithPit).toBeTruthy(); + + const pitId = logRecordWithPit.right.pitId; + expect(pitId).toBeTruthy(); + + const client = esServer.es.getClient(); + await expect( + client.search({ + body: { + pit: { id: pitId }, + }, + }) + // throws an exception that cannot search with closed PIT + ).rejects.toThrow(/search_phase_execution_exception/); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts index 1f8c3a535a902..37dfe9bc717d0 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts @@ -51,6 +51,8 @@ describe('migration v2', () => { migrations: { skip: false, enableV2: true, + // There are 53 docs in fixtures. Batch size configured to enforce 3 migration steps. + batchSize: 20, }, logging: { appenders: { diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts new file mode 100644 index 0000000000000..ed6a448b115d0 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts @@ -0,0 +1,241 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fs from 'fs'; +import Util from 'util'; +import { kibanaPackageJson as pkg } from '@kbn/utils'; +import * as kbnTestServer from '../../../../test_helpers/kbn_server'; +import type { ElasticsearchClient } from '../../../elasticsearch'; +import { Root } from '../../../root'; +import { deterministicallyRegenerateObjectId } from '../../migrations/core/document_migrator'; + +const logFilePath = Path.join(__dirname, 'migration_test_kibana.log'); + +const asyncUnlink = Util.promisify(Fs.unlink); +async function removeLogFile() { + // ignore errors if it doesn't exist + await asyncUnlink(logFilePath).catch(() => void 0); +} + +function sortByTypeAndId(a: { type: string; id: string }, b: { type: string; id: string }) { + return a.type.localeCompare(b.type) || a.id.localeCompare(b.id); +} + +async function fetchDocs(esClient: ElasticsearchClient, index: string) { + const { body } = await esClient.search({ + index, + body: { + query: { + bool: { + should: [ + { + term: { type: 'foo' }, + }, + { + term: { type: 'bar' }, + }, + { + term: { type: 'legacy-url-alias' }, + }, + ], + }, + }, + }, + }); + + return body.hits.hits + .map((h) => ({ + ...h._source, + id: h._id, + })) + .sort(sortByTypeAndId); +} + +function createRoot() { + return kbnTestServer.createRootWithCorePlugins( + { + migrations: { + skip: false, + enableV2: true, + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFilePath, + layout: { + type: 'json', + }, + }, + }, + loggers: [ + { + name: 'root', + appenders: ['file'], + }, + ], + }, + }, + { + oss: true, + } + ); +} + +// CI FAILURE: https://github.com/elastic/kibana/issues/98351 +describe.skip('migration v2', () => { + let esServer: kbnTestServer.TestElasticsearchUtils; + let root: Root; + + beforeAll(async () => { + await removeLogFile(); + }); + + afterAll(async () => { + if (root) { + await root.shutdown(); + } + if (esServer) { + await esServer.stop(); + } + + await new Promise((resolve) => setTimeout(resolve, 10000)); + }); + + it('rewrites id deterministically for SO with namespaceType: "multiple" and "multiple-isolated"', async () => { + const migratedIndex = `.kibana_${pkg.version}_001`; + const { startES } = kbnTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + es: { + license: 'trial', + // original SO: + // [ + // { id: 'foo:1', type: 'foo', foo: { name: 'Foo 1 default' } }, + // { id: 'spacex:foo:1', type: 'foo', foo: { name: 'Foo 1 spacex' }, namespace: 'spacex' }, + // { + // id: 'bar:1', + // type: 'bar', + // bar: { nomnom: 1 }, + // references: [{ type: 'foo', id: '1', name: 'Foo 1 default' }], + // }, + // { + // id: 'spacex:bar:1', + // type: 'bar', + // bar: { nomnom: 2 }, + // references: [{ type: 'foo', id: '1', name: 'Foo 1 spacex' }], + // namespace: 'spacex', + // }, + // ]; + dataArchive: Path.join(__dirname, 'archives', '7.13.0_so_with_multiple_namespaces.zip'), + }, + }, + }); + + root = createRoot(); + + esServer = await startES(); + const coreSetup = await root.setup(); + + coreSetup.savedObjects.registerType({ + name: 'foo', + hidden: false, + mappings: { properties: { name: { type: 'text' } } }, + namespaceType: 'multiple', + convertToMultiNamespaceTypeVersion: '8.0.0', + }); + + coreSetup.savedObjects.registerType({ + name: 'bar', + hidden: false, + mappings: { properties: { nomnom: { type: 'integer' } } }, + namespaceType: 'multiple-isolated', + convertToMultiNamespaceTypeVersion: '8.0.0', + }); + + const coreStart = await root.start(); + const esClient = coreStart.elasticsearch.client.asInternalUser; + + const migratedDocs = await fetchDocs(esClient, migratedIndex); + + // each newly converted multi-namespace object in a non-default space has its ID deterministically regenerated, and a legacy-url-alias + // object is created which links the old ID to the new ID + const newFooId = deterministicallyRegenerateObjectId('spacex', 'foo', '1'); + const newBarId = deterministicallyRegenerateObjectId('spacex', 'bar', '1'); + + expect(migratedDocs).toEqual( + [ + { + id: 'foo:1', + type: 'foo', + foo: { name: 'Foo 1 default' }, + references: [], + namespaces: ['default'], + migrationVersion: { foo: '8.0.0' }, + coreMigrationVersion: pkg.version, + }, + { + id: `foo:${newFooId}`, + type: 'foo', + foo: { name: 'Foo 1 spacex' }, + references: [], + namespaces: ['spacex'], + originId: '1', + migrationVersion: { foo: '8.0.0' }, + coreMigrationVersion: pkg.version, + }, + { + // new object for spacex:foo:1 + id: 'legacy-url-alias:spacex:foo:1', + type: 'legacy-url-alias', + 'legacy-url-alias': { + targetId: newFooId, + targetNamespace: 'spacex', + targetType: 'foo', + }, + migrationVersion: {}, + references: [], + coreMigrationVersion: pkg.version, + }, + { + id: 'bar:1', + type: 'bar', + bar: { nomnom: 1 }, + references: [{ type: 'foo', id: '1', name: 'Foo 1 default' }], + namespaces: ['default'], + migrationVersion: { bar: '8.0.0' }, + coreMigrationVersion: pkg.version, + }, + { + id: `bar:${newBarId}`, + type: 'bar', + bar: { nomnom: 2 }, + references: [{ type: 'foo', id: newFooId, name: 'Foo 1 spacex' }], + namespaces: ['spacex'], + originId: '1', + migrationVersion: { bar: '8.0.0' }, + coreMigrationVersion: pkg.version, + }, + { + // new object for spacex:bar:1 + id: 'legacy-url-alias:spacex:bar:1', + type: 'legacy-url-alias', + 'legacy-url-alias': { + targetId: newBarId, + targetNamespace: 'spacex', + targetType: 'bar', + }, + migrationVersion: {}, + references: [], + coreMigrationVersion: pkg.version, + }, + ].sort(sortByTypeAndId) + ); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts index a6617fc2fb7f4..161d4a7219c8d 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts @@ -5,9 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { cleanupMock } from './migrations_state_machine_cleanup.mocks'; import { migrationStateActionMachine } from './migrations_state_action_machine'; -import { loggingSystemMock } from '../../mocks'; +import { loggingSystemMock, elasticsearchServiceMock } from '../../mocks'; import * as Either from 'fp-ts/lib/Either'; import * as Option from 'fp-ts/lib/Option'; import { AllControlStates, State } from './types'; @@ -15,6 +15,7 @@ import { createInitialState } from './model'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { elasticsearchClientMock } from '../../elasticsearch/client/mocks'; +const esClient = elasticsearchServiceMock.createElasticsearchClient(); describe('migrationsStateActionMachine', () => { beforeAll(() => { jest @@ -74,6 +75,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'LEGACY_DELETE', 'DONE']), next, + client: esClient, }); const logs = loggingSystemMock.collect(mockLogger); const doneLog = logs.info.splice(8, 1)[0][0]; @@ -151,6 +153,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'LEGACY_DELETE', 'DONE']), next, + client: esClient, }) ).resolves.toEqual(expect.anything()); }); @@ -161,6 +164,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'LEGACY_DELETE', 'DONE']), next, + client: esClient, }) ).resolves.toEqual(expect.objectContaining({ status: 'migrated' })); }); @@ -171,6 +175,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'LEGACY_DELETE', 'DONE']), next, + client: esClient, }) ).resolves.toEqual(expect.objectContaining({ status: 'patched' })); }); @@ -181,6 +186,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'FATAL']), next, + client: esClient, }) ).rejects.toMatchInlineSnapshot( `[Error: Unable to complete saved object migrations for the [.my-so-index] index: the fatal reason]` @@ -196,6 +202,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_DELETE', 'FATAL']), next, + client: esClient, }).catch((err) => err); // Ignore the first 4 log entries that come from our model const executionLogLogs = loggingSystemMock.collect(mockLogger).info.slice(4); @@ -418,6 +425,7 @@ describe('migrationsStateActionMachine', () => { }) ); }, + client: esClient, }) ).rejects.toMatchInlineSnapshot( `[Error: Unable to complete saved object migrations for the [.my-so-index] index. Please check the health of your Elasticsearch cluster and try again. Error: [snapshot_in_progress_exception]: Cannot delete indices that are being snapshotted]` @@ -450,6 +458,7 @@ describe('migrationsStateActionMachine', () => { next: () => { throw new Error('this action throws'); }, + client: esClient, }) ).rejects.toMatchInlineSnapshot( `[Error: Unable to complete saved object migrations for the [.my-so-index] index. Error: this action throws]` @@ -483,6 +492,7 @@ describe('migrationsStateActionMachine', () => { if (state.controlState === 'LEGACY_DELETE') throw new Error('this action throws'); return () => Promise.resolve('hello'); }, + client: esClient, }); } catch (e) { /** ignore */ @@ -680,4 +690,37 @@ describe('migrationsStateActionMachine', () => { ] `); }); + describe('cleanup', () => { + beforeEach(() => { + cleanupMock.mockClear(); + }); + it('calls cleanup function when an action throws', async () => { + await expect( + migrationStateActionMachine({ + initialState: { ...initialState, reason: 'the fatal reason' } as State, + logger: mockLogger.get(), + model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'FATAL']), + next: () => { + throw new Error('this action throws'); + }, + client: esClient, + }) + ).rejects.toThrow(); + + expect(cleanupMock).toHaveBeenCalledTimes(1); + }); + it('calls cleanup function when reaching the FATAL state', async () => { + await expect( + migrationStateActionMachine({ + initialState: { ...initialState, reason: 'the fatal reason' } as State, + logger: mockLogger.get(), + model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'FATAL']), + next, + client: esClient, + }) + ).rejects.toThrow(); + + expect(cleanupMock).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts index 20177dda63b3b..dede52f9758e9 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts @@ -9,8 +9,10 @@ import { errors as EsErrors } from '@elastic/elasticsearch'; import * as Option from 'fp-ts/lib/Option'; import { Logger, LogMeta } from '../../logging'; +import type { ElasticsearchClient } from '../../elasticsearch'; import { CorruptSavedObjectError } from '../migrations/core/migrate_raw_docs'; import { Model, Next, stateActionMachine } from './state_action_machine'; +import { cleanup } from './migrations_state_machine_cleanup'; import { State } from './types'; interface StateLogMeta extends LogMeta { @@ -19,7 +21,8 @@ interface StateLogMeta extends LogMeta { }; } -type ExecutionLog = Array< +/** @internal */ +export type ExecutionLog = Array< | { type: 'transition'; prevControlState: State['controlState']; @@ -31,6 +34,11 @@ type ExecutionLog = Array< controlState: State['controlState']; res: unknown; } + | { + type: 'cleanup'; + state: State; + message: string; + } >; const logStateTransition = ( @@ -99,11 +107,13 @@ export async function migrationStateActionMachine({ logger, next, model, + client, }: { initialState: State; logger: Logger; next: Next; model: Model; + client: ElasticsearchClient; }) { const executionLog: ExecutionLog = []; const startTime = Date.now(); @@ -112,11 +122,13 @@ export async function migrationStateActionMachine({ // indicate which messages come from which index upgrade. const logMessagePrefix = `[${initialState.indexPrefix}] `; let prevTimestamp = startTime; + let lastState: State | undefined; try { const finalState = await stateActionMachine( initialState, (state) => next(state), (state, res) => { + lastState = state; executionLog.push({ type: 'response', res, @@ -169,6 +181,7 @@ export async function migrationStateActionMachine({ }; } } else if (finalState.controlState === 'FATAL') { + await cleanup(client, executionLog, finalState); dumpExecutionLog(logger, logMessagePrefix, executionLog); return Promise.reject( new Error( @@ -180,6 +193,7 @@ export async function migrationStateActionMachine({ throw new Error('Invalid terminating control state'); } } catch (e) { + await cleanup(client, executionLog, lastState); if (e instanceof EsErrors.ResponseError) { logger.error( logMessagePrefix + `[${e.body?.error?.type}]: ${e.body?.error?.reason ?? e.message}` @@ -202,9 +216,13 @@ export async function migrationStateActionMachine({ ); } - throw new Error( + const newError = new Error( `Unable to complete saved object migrations for the [${initialState.indexPrefix}] index. ${e}` ); + + // restore error stack to point to a source of the problem. + newError.stack = `[${e.stack}]`; + throw newError; } } } diff --git a/src/plugins/vis_type_timeseries/public/application/index.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.mocks.ts similarity index 73% rename from src/plugins/vis_type_timeseries/public/application/index.ts rename to src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.mocks.ts index fcc0c592b1ef5..29967a1f75820 100644 --- a/src/plugins/vis_type_timeseries/public/application/index.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.mocks.ts @@ -6,5 +6,7 @@ * Side Public License, v 1. */ -export { EditorController, TSVB_EDITOR_NAME } from './editor_controller'; -export * from './lib'; +export const cleanupMock = jest.fn(); +jest.doMock('./migrations_state_machine_cleanup', () => ({ + cleanup: cleanupMock, +})); diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.ts new file mode 100644 index 0000000000000..1881f9a712c29 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ElasticsearchClient } from '../../elasticsearch'; +import * as Actions from './actions'; +import type { State } from './types'; +import type { ExecutionLog } from './migrations_state_action_machine'; + +export async function cleanup( + client: ElasticsearchClient, + executionLog: ExecutionLog, + state?: State +) { + if (!state) return; + if ('sourceIndexPitId' in state) { + try { + await Actions.closePit(client, state.sourceIndexPitId)(); + } catch (e) { + executionLog.push({ + type: 'cleanup', + state, + message: e.message, + }); + } + } +} diff --git a/src/core/server/saved_objects/migrationsv2/model.test.ts b/src/core/server/saved_objects/migrationsv2/model.test.ts index 0267ae33dd157..57a7a7f2ea24a 100644 --- a/src/core/server/saved_objects/migrationsv2/model.test.ts +++ b/src/core/server/saved_objects/migrationsv2/model.test.ts @@ -17,7 +17,10 @@ import type { LegacyReindexState, LegacyReindexWaitForTaskState, LegacyDeleteState, - ReindexSourceToTempState, + ReindexSourceToTempOpenPit, + ReindexSourceToTempRead, + ReindexSourceToTempClosePit, + ReindexSourceToTempIndex, UpdateTargetMappingsState, UpdateTargetMappingsWaitForTaskState, OutdatedDocumentsSearch, @@ -25,7 +28,6 @@ import type { MarkVersionIndexReady, BaseState, CreateReindexTempState, - ReindexSourceToTempWaitForTaskState, MarkVersionIndexReadyConflict, CreateNewTargetState, CloneTempToSource, @@ -299,14 +301,12 @@ describe('migrations v2 model', () => { settings: {}, }, }); - const newState = model(initState, res) as FatalState; + const newState = model(initState, res) as WaitForYellowSourceState; - expect(newState.controlState).toEqual('WAIT_FOR_YELLOW_SOURCE'); - expect(newState).toMatchObject({ - controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: '.kibana_7.invalid.0_001', - }); + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('.kibana_7.invalid.0_001'); }); + test('INIT -> WAIT_FOR_YELLOW_SOURCE when migrating from a v2 migrations index (>= 7.11.0)', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana_7.11.0_001': { @@ -330,15 +330,14 @@ describe('migrations v2 model', () => { }, }, res - ); + ) as WaitForYellowSourceState; - expect(newState).toMatchObject({ - controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: '.kibana_7.11.0_001', - }); + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('.kibana_7.11.0_001'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); + test('INIT -> WAIT_FOR_YELLOW_SOURCE when migrating from a v1 migrations index (>= 6.5 < 7.11.0)', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana_3': { @@ -349,12 +348,10 @@ describe('migrations v2 model', () => { settings: {}, }, }); - const newState = model(initState, res); + const newState = model(initState, res) as WaitForYellowSourceState; - expect(newState).toMatchObject({ - controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: '.kibana_3', - }); + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('.kibana_3'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -420,12 +417,10 @@ describe('migrations v2 model', () => { versionIndex: 'my-saved-objects_7.11.0_001', }, res - ); + ) as WaitForYellowSourceState; - expect(newState).toMatchObject({ - controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: 'my-saved-objects_3', - }); + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('my-saved-objects_3'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -449,12 +444,11 @@ describe('migrations v2 model', () => { versionIndex: 'my-saved-objects_7.12.0_001', }, res - ); + ) as WaitForYellowSourceState; + + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('my-saved-objects_7.11.0'); - expect(newState).toMatchObject({ - controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: 'my-saved-objects_7.11.0', - }); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -662,7 +656,7 @@ describe('migrations v2 model', () => { const waitForYellowSourceState: WaitForYellowSourceState = { ...baseState, controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: '.kibana_3', + sourceIndex: Option.some('.kibana_3') as Option.Some, sourceIndexMappings: mappingsWithUnknownType, }; @@ -734,7 +728,7 @@ describe('migrations v2 model', () => { }); }); describe('CREATE_REINDEX_TEMP', () => { - const createReindexTargetState: CreateReindexTempState = { + const state: CreateReindexTempState = { ...baseState, controlState: 'CREATE_REINDEX_TEMP', versionIndexReadyActions: Option.none, @@ -742,80 +736,134 @@ describe('migrations v2 model', () => { targetIndex: '.kibana_7.11.0_001', tempIndexMappings: { properties: {} }, }; - it('CREATE_REINDEX_TEMP -> REINDEX_SOURCE_TO_TEMP if action succeeds', () => { + it('CREATE_REINDEX_TEMP -> REINDEX_SOURCE_TO_TEMP_OPEN_PIT if action succeeds', () => { const res: ResponseType<'CREATE_REINDEX_TEMP'> = Either.right('create_index_succeeded'); - const newState = model(createReindexTargetState, res); - expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP'); + const newState = model(state, res); + expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_OPEN_PIT'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); }); - describe('REINDEX_SOURCE_TO_TEMP', () => { - const reindexSourceToTargetState: ReindexSourceToTempState = { + + describe('REINDEX_SOURCE_TO_TEMP_OPEN_PIT', () => { + const state: ReindexSourceToTempOpenPit = { ...baseState, - controlState: 'REINDEX_SOURCE_TO_TEMP', + controlState: 'REINDEX_SOURCE_TO_TEMP_OPEN_PIT', versionIndexReadyActions: Option.none, sourceIndex: Option.some('.kibana') as Option.Some, targetIndex: '.kibana_7.11.0_001', + tempIndexMappings: { properties: {} }, }; - test('REINDEX_SOURCE_TO_TEMP -> REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP'> = Either.right({ - taskId: 'reindex-task-id', + it('REINDEX_SOURCE_TO_TEMP_OPEN_PIT -> REINDEX_SOURCE_TO_TEMP_READ if action succeeds', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_OPEN_PIT'> = Either.right({ + pitId: 'pit_id', }); - const newState = model(reindexSourceToTargetState, res); - expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'); - expect(newState.retryCount).toEqual(0); - expect(newState.retryDelay).toEqual(0); + const newState = model(state, res) as ReindexSourceToTempRead; + expect(newState.controlState).toBe('REINDEX_SOURCE_TO_TEMP_READ'); + expect(newState.sourceIndexPitId).toBe('pit_id'); + expect(newState.lastHitSortValue).toBe(undefined); }); }); - describe('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK', () => { - const state: ReindexSourceToTempWaitForTaskState = { + + describe('REINDEX_SOURCE_TO_TEMP_READ', () => { + const state: ReindexSourceToTempRead = { ...baseState, - controlState: 'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK', + controlState: 'REINDEX_SOURCE_TO_TEMP_READ', versionIndexReadyActions: Option.none, sourceIndex: Option.some('.kibana') as Option.Some, + sourceIndexPitId: 'pit_id', targetIndex: '.kibana_7.11.0_001', - reindexSourceToTargetTaskId: 'reindex-task-id', + tempIndexMappings: { properties: {} }, + lastHitSortValue: undefined, }; - test('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK -> SET_TEMP_WRITE_BLOCK when response is right', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'> = Either.right( - 'reindex_succeeded' + + it('REINDEX_SOURCE_TO_TEMP_READ -> REINDEX_SOURCE_TO_TEMP_INDEX if the index has outdated documents to reindex', () => { + const outdatedDocuments = [{ _id: '1', _source: { type: 'vis' } }]; + const lastHitSortValue = [123456]; + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_READ'> = Either.right({ + outdatedDocuments, + lastHitSortValue, + }); + const newState = model(state, res) as ReindexSourceToTempIndex; + expect(newState.controlState).toBe('REINDEX_SOURCE_TO_TEMP_INDEX'); + expect(newState.outdatedDocuments).toBe(outdatedDocuments); + expect(newState.lastHitSortValue).toBe(lastHitSortValue); + }); + + it('REINDEX_SOURCE_TO_TEMP_READ -> REINDEX_SOURCE_TO_TEMP_CLOSE_PIT if no outdated documents to reindex', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_READ'> = Either.right({ + outdatedDocuments: [], + lastHitSortValue: undefined, + }); + const newState = model(state, res) as ReindexSourceToTempClosePit; + expect(newState.controlState).toBe('REINDEX_SOURCE_TO_TEMP_CLOSE_PIT'); + expect(newState.sourceIndexPitId).toBe('pit_id'); + }); + }); + + describe('REINDEX_SOURCE_TO_TEMP_CLOSE_PIT', () => { + const state: ReindexSourceToTempClosePit = { + ...baseState, + controlState: 'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT', + versionIndexReadyActions: Option.none, + sourceIndex: Option.some('.kibana') as Option.Some, + sourceIndexPitId: 'pit_id', + targetIndex: '.kibana_7.11.0_001', + tempIndexMappings: { properties: {} }, + }; + + it('REINDEX_SOURCE_TO_TEMP_CLOSE_PIT -> SET_TEMP_WRITE_BLOCK if action succeeded', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT'> = Either.right({}); + const newState = model(state, res) as ReindexSourceToTempIndex; + expect(newState.controlState).toBe('SET_TEMP_WRITE_BLOCK'); + expect(newState.sourceIndex).toEqual(state.sourceIndex); + }); + }); + + describe('REINDEX_SOURCE_TO_TEMP_INDEX', () => { + const state: ReindexSourceToTempIndex = { + ...baseState, + controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX', + outdatedDocuments: [], + versionIndexReadyActions: Option.none, + sourceIndex: Option.some('.kibana') as Option.Some, + sourceIndexPitId: 'pit_id', + targetIndex: '.kibana_7.11.0_001', + lastHitSortValue: undefined, + }; + + it('REINDEX_SOURCE_TO_TEMP_INDEX -> REINDEX_SOURCE_TO_TEMP_READ if action succeeded', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX'> = Either.right( + 'bulk_index_succeeded' ); const newState = model(state, res); - expect(newState.controlState).toEqual('SET_TEMP_WRITE_BLOCK'); + expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_READ'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); - test('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK -> SET_TEMP_WRITE_BLOCK when response is left target_index_had_write_block', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'> = Either.left({ + + it('REINDEX_SOURCE_TO_TEMP_INDEX -> REINDEX_SOURCE_TO_TEMP_READ when response is left target_index_had_write_block', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX'> = Either.left({ type: 'target_index_had_write_block', }); - const newState = model(state, res); - expect(newState.controlState).toEqual('SET_TEMP_WRITE_BLOCK'); + const newState = model(state, res) as ReindexSourceToTempRead; + expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_READ'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); - test('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK -> SET_TEMP_WRITE_BLOCK when response is left index_not_found_exception', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'> = Either.left({ + + it('REINDEX_SOURCE_TO_TEMP_INDEX -> REINDEX_SOURCE_TO_TEMP_READ when response is left index_not_found_exception for temp index', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX'> = Either.left({ type: 'index_not_found_exception', - index: '.kibana_7.11.0_reindex_temp', + index: state.tempIndex, }); - const newState = model(state, res); - expect(newState.controlState).toEqual('SET_TEMP_WRITE_BLOCK'); + const newState = model(state, res) as ReindexSourceToTempRead; + expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_READ'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); - test('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK -> REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK when response is left wait_for_task_completion_timeout', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'> = Either.left({ - message: '[timeout_exception] Timeout waiting for ...', - type: 'wait_for_task_completion_timeout', - }); - const newState = model(state, res); - expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'); - expect(newState.retryCount).toEqual(1); - expect(newState.retryDelay).toEqual(2000); - }); }); + describe('SET_TEMP_WRITE_BLOCK', () => { const state: SetTempWriteBlock = { ...baseState, diff --git a/src/core/server/saved_objects/migrationsv2/model.ts b/src/core/server/saved_objects/migrationsv2/model.ts index acf0f620136a2..2097b1de88aab 100644 --- a/src/core/server/saved_objects/migrationsv2/model.ts +++ b/src/core/server/saved_objects/migrationsv2/model.ts @@ -227,7 +227,7 @@ export const model = (currentState: State, resW: ResponseType): return { ...stateP, controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: source, + sourceIndex: Option.some(source) as Option.Some, sourceIndexMappings: indices[source].mappings, }; } else if (indices[stateP.legacyIndex] != null) { @@ -303,7 +303,7 @@ export const model = (currentState: State, resW: ResponseType): } } else if (stateP.controlState === 'LEGACY_SET_WRITE_BLOCK') { const res = resW as ExcludeRetryableEsError>; - // If the write block is sucessfully in place + // If the write block is successfully in place if (Either.isRight(res)) { return { ...stateP, controlState: 'LEGACY_CREATE_REINDEX_TARGET' }; } else if (Either.isLeft(res)) { @@ -431,14 +431,14 @@ export const model = (currentState: State, resW: ResponseType): return { ...stateP, controlState: 'SET_SOURCE_WRITE_BLOCK', - sourceIndex: Option.some(source) as Option.Some, + sourceIndex: source, targetIndex: target, targetIndexMappings: disableUnknownTypeMappingFields( stateP.targetIndexMappings, stateP.sourceIndexMappings ), versionIndexReadyActions: Option.some([ - { remove: { index: source, alias: stateP.currentAlias, must_exist: true } }, + { remove: { index: source.value, alias: stateP.currentAlias, must_exist: true } }, { add: { index: target, alias: stateP.currentAlias } }, { add: { index: target, alias: stateP.versionAlias } }, { remove_index: { index: stateP.tempIndex } }, @@ -466,32 +466,61 @@ export const model = (currentState: State, resW: ResponseType): } else if (stateP.controlState === 'CREATE_REINDEX_TEMP') { const res = resW as ExcludeRetryableEsError>; if (Either.isRight(res)) { - return { ...stateP, controlState: 'REINDEX_SOURCE_TO_TEMP' }; + return { ...stateP, controlState: 'REINDEX_SOURCE_TO_TEMP_OPEN_PIT' }; } else { // If the createIndex action receives an 'resource_already_exists_exception' // it will wait until the index status turns green so we don't have any // left responses to handle here. throwBadResponse(stateP, res); } - } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP') { + } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_OPEN_PIT') { const res = resW as ExcludeRetryableEsError>; if (Either.isRight(res)) { return { ...stateP, - controlState: 'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK', - reindexSourceToTargetTaskId: res.right.taskId, + controlState: 'REINDEX_SOURCE_TO_TEMP_READ', + sourceIndexPitId: res.right.pitId, + lastHitSortValue: undefined, }; } else { - // Since this is a background task, the request should always succeed, - // errors only show up in the returned task. throwBadResponse(stateP, res); } - } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK') { + } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_READ') { const res = resW as ExcludeRetryableEsError>; if (Either.isRight(res)) { + if (res.right.outdatedDocuments.length > 0) { + return { + ...stateP, + controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX', + outdatedDocuments: res.right.outdatedDocuments, + lastHitSortValue: res.right.lastHitSortValue, + }; + } return { ...stateP, + controlState: 'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT', + }; + } else { + throwBadResponse(stateP, res); + } + } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT') { + const res = resW as ExcludeRetryableEsError>; + if (Either.isRight(res)) { + const { sourceIndexPitId, ...state } = stateP; + return { + ...state, controlState: 'SET_TEMP_WRITE_BLOCK', + sourceIndex: stateP.sourceIndex as Option.Some, + }; + } else { + throwBadResponse(stateP, res); + } + } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_INDEX') { + const res = resW as ExcludeRetryableEsError>; + if (Either.isRight(res)) { + return { + ...stateP, + controlState: 'REINDEX_SOURCE_TO_TEMP_READ', }; } else { const left = res.left; @@ -510,28 +539,11 @@ export const model = (currentState: State, resW: ResponseType): // we know another instance already completed these. return { ...stateP, - controlState: 'SET_TEMP_WRITE_BLOCK', + controlState: 'REINDEX_SOURCE_TO_TEMP_READ', }; - } else if (isLeftTypeof(left, 'wait_for_task_completion_timeout')) { - // After waiting for the specificed timeout, the task has not yet - // completed. Retry this step to see if the task has completed after an - // exponential delay. We will basically keep polling forever until the - // Elasticeasrch task succeeds or fails. - return delayRetryState(stateP, left.message, Number.MAX_SAFE_INTEGER); - } else if ( - isLeftTypeof(left, 'index_not_found_exception') || - isLeftTypeof(left, 'incompatible_mapping_exception') - ) { - // Don't handle the following errors as the migration algorithm should - // never cause them to occur: - // - incompatible_mapping_exception the temp index has `dynamic: false` - // mappings - // - index_not_found_exception for the source index, we will never - // delete the source index - throwBadResponse(stateP, left as never); - } else { - throwBadResponse(stateP, left); } + // should never happen + throwBadResponse(stateP, res as never); } } else if (stateP.controlState === 'SET_TEMP_WRITE_BLOCK') { const res = resW as ExcludeRetryableEsError>; @@ -609,7 +621,7 @@ export const model = (currentState: State, resW: ResponseType): controlState: 'OUTDATED_DOCUMENTS_SEARCH', }; } else { - throwBadResponse(stateP, res); + throwBadResponse(stateP, res as never); } } else if (stateP.controlState === 'UPDATE_TARGET_MAPPINGS') { const res = resW as ExcludeRetryableEsError>; @@ -647,10 +659,10 @@ export const model = (currentState: State, resW: ResponseType): } else { const left = res.left; if (isLeftTypeof(left, 'wait_for_task_completion_timeout')) { - // After waiting for the specificed timeout, the task has not yet + // After waiting for the specified timeout, the task has not yet // completed. Retry this step to see if the task has completed after an // exponential delay. We will basically keep polling forever until the - // Elasticeasrch task succeeds or fails. + // Elasticsearch task succeeds or fails. return delayRetryState(stateP, res.left.message, Number.MAX_SAFE_INTEGER); } else { throwBadResponse(stateP, left); diff --git a/src/core/server/saved_objects/migrationsv2/next.ts b/src/core/server/saved_objects/migrationsv2/next.ts index bb506cbca66fb..6d61634a6948e 100644 --- a/src/core/server/saved_objects/migrationsv2/next.ts +++ b/src/core/server/saved_objects/migrationsv2/next.ts @@ -6,13 +6,13 @@ * Side Public License, v 1. */ -import * as TaskEither from 'fp-ts/lib/TaskEither'; -import * as Option from 'fp-ts/lib/Option'; -import { UnwrapPromise } from '@kbn/utility-types'; -import { pipe } from 'fp-ts/lib/pipeable'; +import type { UnwrapPromise } from '@kbn/utility-types'; import type { AllActionStates, - ReindexSourceToTempState, + ReindexSourceToTempOpenPit, + ReindexSourceToTempRead, + ReindexSourceToTempClosePit, + ReindexSourceToTempIndex, MarkVersionIndexReady, InitState, LegacyCreateReindexTargetState, @@ -27,18 +27,16 @@ import type { UpdateTargetMappingsState, UpdateTargetMappingsWaitForTaskState, CreateReindexTempState, - ReindexSourceToTempWaitForTaskState, MarkVersionIndexReadyConflict, CreateNewTargetState, CloneTempToSource, SetTempWriteBlock, WaitForYellowSourceState, + TransformRawDocs, } from './types'; import * as Actions from './actions'; import { ElasticsearchClient } from '../../elasticsearch'; -import { SavedObjectsRawDoc } from '..'; -export type TransformRawDocs = (rawDocs: SavedObjectsRawDoc[]) => Promise; type ActionMap = ReturnType; /** @@ -56,26 +54,43 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra INIT: (state: InitState) => Actions.fetchIndices(client, [state.currentAlias, state.versionAlias]), WAIT_FOR_YELLOW_SOURCE: (state: WaitForYellowSourceState) => - Actions.waitForIndexStatusYellow(client, state.sourceIndex), + Actions.waitForIndexStatusYellow(client, state.sourceIndex.value), SET_SOURCE_WRITE_BLOCK: (state: SetSourceWriteBlockState) => Actions.setWriteBlock(client, state.sourceIndex.value), CREATE_NEW_TARGET: (state: CreateNewTargetState) => Actions.createIndex(client, state.targetIndex, state.targetIndexMappings), CREATE_REINDEX_TEMP: (state: CreateReindexTempState) => Actions.createIndex(client, state.tempIndex, state.tempIndexMappings), - REINDEX_SOURCE_TO_TEMP: (state: ReindexSourceToTempState) => - Actions.reindex( + REINDEX_SOURCE_TO_TEMP_OPEN_PIT: (state: ReindexSourceToTempOpenPit) => + Actions.openPit(client, state.sourceIndex.value), + REINDEX_SOURCE_TO_TEMP_READ: (state: ReindexSourceToTempRead) => + Actions.readWithPit( client, - state.sourceIndex.value, + state.sourceIndexPitId, + state.unusedTypesQuery, + state.batchSize, + state.lastHitSortValue + ), + REINDEX_SOURCE_TO_TEMP_CLOSE_PIT: (state: ReindexSourceToTempClosePit) => + Actions.closePit(client, state.sourceIndexPitId), + REINDEX_SOURCE_TO_TEMP_INDEX: (state: ReindexSourceToTempIndex) => + Actions.transformDocs( + client, + transformRawDocs, + state.outdatedDocuments, state.tempIndex, - Option.none, - false, - state.unusedTypesQuery + /** + * Since we don't run a search against the target index, we disable "refresh" to speed up + * the migration process. + * Although any further step must run "refresh" for the target index + * before we reach out to the OUTDATED_DOCUMENTS_SEARCH step. + * Right now, we rely on UPDATE_TARGET_MAPPINGS + UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK + * to perform refresh. + */ + false ), SET_TEMP_WRITE_BLOCK: (state: SetTempWriteBlock) => Actions.setWriteBlock(client, state.tempIndex), - REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK: (state: ReindexSourceToTempWaitForTaskState) => - Actions.waitForReindexTask(client, state.reindexSourceToTargetTaskId, '60s'), CLONE_TEMP_TO_TARGET: (state: CloneTempToSource) => Actions.cloneIndex(client, state.tempIndex, state.targetIndex), UPDATE_TARGET_MAPPINGS: (state: UpdateTargetMappingsState) => @@ -89,16 +104,20 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra outdatedDocumentsQuery: state.outdatedDocumentsQuery, }), OUTDATED_DOCUMENTS_TRANSFORM: (state: OutdatedDocumentsTransform) => - pipe( - TaskEither.tryCatch( - () => transformRawDocs(state.outdatedDocuments), - (e) => { - throw e; - } - ), - TaskEither.chain((docs) => - Actions.bulkOverwriteTransformedDocuments(client, state.targetIndex, docs) - ) + // Wait for a refresh to happen before returning. This ensures that when + // this Kibana instance searches for outdated documents, it won't find + // documents that were already transformed by itself or another Kibana + // instance. However, this causes each OUTDATED_DOCUMENTS_SEARCH -> + // OUTDATED_DOCUMENTS_TRANSFORM cycle to take 1s so when batches are + // small performance will become a lot worse. + // The alternative is to use a search_after with either a tie_breaker + // field or using a Point In Time as a cursor to go through all documents. + Actions.transformDocs( + client, + transformRawDocs, + state.outdatedDocuments, + state.targetIndex, + 'wait_for' ), MARK_VERSION_INDEX_READY: (state: MarkVersionIndexReady) => Actions.updateAliases(client, state.versionIndexReadyActions.value), diff --git a/src/core/server/saved_objects/migrationsv2/types.ts b/src/core/server/saved_objects/migrationsv2/types.ts index 5e84bc23b1d16..b84d483cf6203 100644 --- a/src/core/server/saved_objects/migrationsv2/types.ts +++ b/src/core/server/saved_objects/migrationsv2/types.ts @@ -132,7 +132,7 @@ export type FatalState = BaseState & { export interface WaitForYellowSourceState extends BaseState { /** Wait for the source index to be yellow before requesting it. */ readonly controlState: 'WAIT_FOR_YELLOW_SOURCE'; - readonly sourceIndex: string; + readonly sourceIndex: Option.Some; readonly sourceIndexMappings: IndexMapping; } @@ -158,21 +158,29 @@ export type CreateReindexTempState = PostInitState & { readonly sourceIndex: Option.Some; }; -export type ReindexSourceToTempState = PostInitState & { - /** Reindex documents from the source index into the target index */ - readonly controlState: 'REINDEX_SOURCE_TO_TEMP'; +export interface ReindexSourceToTempOpenPit extends PostInitState { + /** Open PIT to the source index */ + readonly controlState: 'REINDEX_SOURCE_TO_TEMP_OPEN_PIT'; readonly sourceIndex: Option.Some; -}; +} -export type ReindexSourceToTempWaitForTaskState = PostInitState & { - /** - * Wait until reindexing documents from the source index into the target - * index has completed - */ - readonly controlState: 'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'; - readonly sourceIndex: Option.Some; - readonly reindexSourceToTargetTaskId: string; -}; +export interface ReindexSourceToTempRead extends PostInitState { + readonly controlState: 'REINDEX_SOURCE_TO_TEMP_READ'; + readonly sourceIndexPitId: string; + readonly lastHitSortValue: number[] | undefined; +} + +export interface ReindexSourceToTempClosePit extends PostInitState { + readonly controlState: 'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT'; + readonly sourceIndexPitId: string; +} + +export interface ReindexSourceToTempIndex extends PostInitState { + readonly controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX'; + readonly outdatedDocuments: SavedObjectsRawDoc[]; + readonly sourceIndexPitId: string; + readonly lastHitSortValue: number[] | undefined; +} export type SetTempWriteBlock = PostInitState & { /** @@ -302,8 +310,10 @@ export type State = | SetSourceWriteBlockState | CreateNewTargetState | CreateReindexTempState - | ReindexSourceToTempState - | ReindexSourceToTempWaitForTaskState + | ReindexSourceToTempOpenPit + | ReindexSourceToTempRead + | ReindexSourceToTempClosePit + | ReindexSourceToTempIndex | SetTempWriteBlock | CloneTempToSource | UpdateTargetMappingsState @@ -324,3 +334,5 @@ export type AllControlStates = State['controlState']; * 'FATAL' and 'DONE'). */ export type AllActionStates = Exclude; + +export type TransformRawDocs = (rawDocs: SavedObjectsRawDoc[]) => Promise; diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index c0e2cdc333363..8faa476b77bfa 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -1917,10 +1917,7 @@ export class SavedObjectsRepository { ...(preference ? { preference } : {}), }; - const { - body, - statusCode, - } = await this.client.openPointInTime( + const { body, statusCode } = await this.client.openPointInTime( // @ts-expect-error @elastic/elasticsearch OpenPointInTimeRequest.index expected to accept string[] esOptions, { diff --git a/src/core/server/saved_objects/status.ts b/src/core/server/saved_objects/status.ts index 24e87d2924543..95bf6ddd9ff52 100644 --- a/src/core/server/saved_objects/status.ts +++ b/src/core/server/saved_objects/status.ts @@ -18,11 +18,20 @@ export const calculateStatus$ = ( ): Observable> => { const migratorStatus$: Observable> = rawMigratorStatus$.pipe( map((migrationStatus) => { - if (migrationStatus.status === 'waiting') { + if (migrationStatus.status === 'waiting_to_start') { return { level: ServiceStatusLevels.unavailable, summary: `SavedObjects service is waiting to start migrations`, }; + } else if (migrationStatus.status === 'waiting_for_other_nodes') { + return { + level: ServiceStatusLevels.unavailable, + summary: `SavedObjects service is waiting for other nodes to complete the migration`, + detail: + `If no other Kibana instance is attempting ` + + `migrations, you can get past this message by deleting index ${migrationStatus.waitingIndex} and ` + + `restarting Kibana.`, + }; } else if (migrationStatus.status === 'running') { return { level: ServiceStatusLevels.unavailable, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index b4c6ee323cbac..56759edbd6533 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -175,6 +175,9 @@ import { URL } from 'url'; export { AddConfigDeprecation } +// @public +export const APP_WRAPPER_CLASS = "kbnAppWrapper"; + // @public export interface AppCategory { ariaLabel?: string; @@ -788,6 +791,7 @@ export class CspConfig implements ICspConfig { // @public export interface CustomHttpResponseOptions { body?: T; + bypassErrorFormat?: boolean; headers?: ResponseHeaders; // (undocumented) statusCode: number; @@ -1078,6 +1082,7 @@ export interface HttpResourcesServiceToolkit { // @public export interface HttpResponseOptions { body?: HttpResponsePayload; + bypassErrorFormat?: boolean; headers?: ResponseHeaders; } @@ -3261,7 +3266,7 @@ export const validBodyOutput: readonly ["data", "stream"]; // Warnings were encountered during analysis: // // src/core/server/elasticsearch/client/types.ts:94:7 - (ae-forgotten-export) The symbol "Explanation" needs to be exported by the entry point index.d.ts -// src/core/server/http/router/response.ts:297:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts +// src/core/server/http/router/response.ts:301:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:326:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:326:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:329:3 - (ae-forgotten-export) The symbol "SavedObjectsConfigType" needs to be exported by the entry point index.d.ts diff --git a/src/core/server/status/legacy_status.ts b/src/core/server/status/legacy_status.ts index b7d0965e31f68..1b3d139b1345e 100644 --- a/src/core/server/status/legacy_status.ts +++ b/src/core/server/status/legacy_status.ts @@ -95,7 +95,7 @@ const serviceStatusToHttpComponent = ( since: string ): StatusComponentHttp => ({ id: serviceName, - message: status.summary, + message: [status.summary, status.detail].filter(Boolean).join(' '), since, ...serviceStatusAttrs(status), }); diff --git a/src/core/server/status/routes/status.ts b/src/core/server/status/routes/status.ts index c1782570ecfa0..72f639231996f 100644 --- a/src/core/server/status/routes/status.ts +++ b/src/core/server/status/routes/status.ts @@ -12,7 +12,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { MetricsServiceSetup } from '../../metrics'; -import { ServiceStatus, CoreStatus } from '../types'; +import { ServiceStatus, CoreStatus, ServiceStatusLevels } from '../types'; import { PluginName } from '../../plugins'; import { calculateLegacyStatus, LegacyStatusInfo } from '../legacy_status'; import { PackageInfo } from '../../config'; @@ -160,7 +160,8 @@ export const registerStatusRoute = ({ router, config, metrics, status }: Deps) = }, }; - return res.ok({ body }); + const statusCode = overall.level >= ServiceStatusLevels.unavailable ? 503 : 200; + return res.custom({ body, statusCode, bypassErrorFormat: true }); } ); }; diff --git a/src/core/server/status/status_service.ts b/src/core/server/status/status_service.ts index 7724e7a5e44b4..cfd4d92d91d3f 100644 --- a/src/core/server/status/status_service.ts +++ b/src/core/server/status/status_service.ts @@ -88,9 +88,7 @@ export class StatusService implements CoreService { // Create an unused subscription to ensure all underlying lazy observables are started. this.overallSubscription = overall$.subscribe(); - const router = http.createRouter(''); - registerStatusRoute({ - router, + const commonRouteDeps = { config: { allowAnonymous: statusConfig.allowAnonymous, packageInfo: this.coreContext.env.packageInfo, @@ -103,8 +101,27 @@ export class StatusService implements CoreService { plugins$: this.pluginsStatus.getAll$(), core$, }, + }; + + const router = http.createRouter(''); + registerStatusRoute({ + router, + ...commonRouteDeps, }); + if (http.notReadyServer && commonRouteDeps.config.allowAnonymous) { + http.notReadyServer.registerRoutes('', (notReadyRouter) => { + registerStatusRoute({ + router: notReadyRouter, + ...commonRouteDeps, + config: { + ...commonRouteDeps.config, + allowAnonymous: true, + }, + }); + }); + } + return { core$, overall$, diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index 950ab5f4392e1..dbf19f84825be 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { Client } from 'elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; import { // @ts-expect-error https://github.com/elastic/kibana/issues/95679 @@ -140,7 +140,7 @@ export interface TestElasticsearchServer { start: (esArgs: string[], esEnvVars: Record) => Promise; stop: () => Promise; cleanup: () => Promise; - getClient: () => Client; + getClient: () => KibanaClient; getCallCluster: () => LegacyAPICaller; getUrl: () => string; } diff --git a/src/core/utils/app_wrapper_class.ts b/src/core/utils/app_wrapper_class.ts new file mode 100644 index 0000000000000..51230cbbb6f78 --- /dev/null +++ b/src/core/utils/app_wrapper_class.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * The class name for top level *and* nested application wrappers to ensure proper layout + * @public + */ +export const APP_WRAPPER_CLASS = 'kbnAppWrapper'; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index a76138399f0f8..73980983a12e1 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -7,3 +7,4 @@ */ export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; +export { APP_WRAPPER_CLASS } from './app_wrapper_class'; diff --git a/src/plugins/dashboard/public/application/_dashboard_app.scss b/src/plugins/dashboard/public/application/_dashboard_app.scss index f6525377cce70..435659b685280 100644 --- a/src/plugins/dashboard/public/application/_dashboard_app.scss +++ b/src/plugins/dashboard/public/application/_dashboard_app.scss @@ -1,9 +1,3 @@ -.dshAppContainer { - display: flex; - flex-direction: column; - flex: 1; -} - .dashboardViewport { flex: 1; display: flex; diff --git a/src/plugins/dashboard/public/application/actions/copy_to_dashboard_modal.tsx b/src/plugins/dashboard/public/application/actions/copy_to_dashboard_modal.tsx index cda2f76930627..77b136de9d7c1 100644 --- a/src/plugins/dashboard/public/application/actions/copy_to_dashboard_modal.tsx +++ b/src/plugins/dashboard/public/application/actions/copy_to_dashboard_modal.tsx @@ -25,8 +25,13 @@ import { import { DashboardCopyToCapabilities } from './copy_to_dashboard_action'; import { LazyDashboardPicker, withSuspense } from '../../services/presentation_util'; import { dashboardCopyToDashboardAction } from '../../dashboard_strings'; -import { EmbeddableStateTransfer, IEmbeddable } from '../../services/embeddable'; -import { createDashboardEditUrl, DashboardConstants } from '../..'; +import { + EmbeddableStateTransfer, + IEmbeddable, + PanelNotFoundError, +} from '../../services/embeddable'; +import { createDashboardEditUrl, DashboardConstants, DashboardContainer } from '../..'; +import { DashboardPanelState } from '..'; interface CopyToDashboardModalProps { capabilities: DashboardCopyToCapabilities; @@ -53,9 +58,16 @@ export function CopyToDashboardModal({ ); const onSubmit = useCallback(() => { + const dashboard = embeddable.getRoot() as DashboardContainer; + const panelToCopy = dashboard.getInput().panels[embeddable.id] as DashboardPanelState; + if (!panelToCopy) { + throw new PanelNotFoundError(); + } const state = { - input: omit(embeddable.getInput(), 'id'), type: embeddable.type, + input: { + ...omit(panelToCopy.explicitInput, 'id'), + }, }; const path = diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index e7e2ccfd46b9c..fa86fb81bd407 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -303,7 +303,7 @@ export function DashboardApp({ }, [data.search.session]); return ( -
+ <> {savedDashboard && dashboardStateManager && dashboardContainer && viewMode && ( <> )} -
+ ); } diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index e23c249cc7e7a..02403999cd75c 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -620,36 +620,38 @@ export function DashboardTopNav({ return ( <> - {viewMode !== ViewMode.VIEW ? ( - - {{ - primaryActionButton: ( - - ), - quickButtonGroup: , - addFromLibraryButton: ( - - ), - extraButtons: [ - , - ], - }} - + <> + + + {{ + primaryActionButton: ( + + ), + quickButtonGroup: , + addFromLibraryButton: ( + + ), + extraButtons: [ + , + ], + }} + + ) : null} ); diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 0fad1c51f433a..0c4ef8c58f949 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -12,6 +12,7 @@ import { filter, map } from 'rxjs/operators'; import { Start as InspectorStartContract } from 'src/plugins/inspector/public'; import { UrlForwardingSetup, UrlForwardingStart } from 'src/plugins/url_forwarding/public'; +import { APP_WRAPPER_CLASS } from '../../../core/public'; import { App, Plugin, @@ -292,7 +293,7 @@ export class DashboardPlugin category: DEFAULT_APP_CATEGORIES.kibana, mount: async (params: AppMountParameters) => { this.currentHistory = params.history; - params.element.classList.add('dshAppContainer'); + params.element.classList.add(APP_WRAPPER_CLASS); const { mountApp } = await import('./application/dashboard_router'); appMounted(); return mountApp({ diff --git a/src/plugins/discover/public/application/components/discover.tsx b/src/plugins/discover/public/application/components/discover.tsx index 0df921dc99ad7..030d7be8ea7e1 100644 --- a/src/plugins/discover/public/application/components/discover.tsx +++ b/src/plugins/discover/public/application/components/discover.tsx @@ -272,19 +272,22 @@ export function Discover({ - setIsSidebarClosed(!isSidebarClosed)} - data-test-subj="collapseSideBarButton" - aria-controls="discover-sidebar" - aria-expanded={isSidebarClosed ? 'false' : 'true'} - aria-label={i18n.translate('discover.toggleSidebarAriaLabel', { - defaultMessage: 'Toggle sidebar', - })} - buttonRef={collapseIcon} - /> +
+ + setIsSidebarClosed(!isSidebarClosed)} + data-test-subj="collapseSideBarButton" + aria-controls="discover-sidebar" + aria-expanded={isSidebarClosed ? 'false' : 'true'} + aria-label={i18n.translate('discover.toggleSidebarAriaLabel', { + defaultMessage: 'Toggle sidebar', + })} + buttonRef={collapseIcon} + /> +
diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid.scss b/src/plugins/discover/public/application/components/discover_grid/discover_grid.scss index 5bb6c01da5ad6..cb1b9a8ea191e 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid.scss +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid.scss @@ -11,6 +11,7 @@ .euiDataGridRowCell.euiDataGridRowCell--firstColumn { border-left: none; + padding: 0; } .euiDataGridRowCell.euiDataGridRowCell--lastColumn { diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx index da91ec1c842a8..df7e2285a0754 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx @@ -20,7 +20,7 @@ export function getLeadControlColumns() { return [ { id: 'openDetails', - width: 32, + width: 24, headerCellRender: () => ( @@ -34,7 +34,7 @@ export function getLeadControlColumns() { }, { id: 'select', - width: 32, + width: 24, rowCellRender: SelectButton, headerCellRender: () => ( diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_expand_button.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_expand_button.tsx index 73778d7453af4..115acb84b95d8 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_expand_button.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_expand_button.tsx @@ -38,7 +38,7 @@ export const ExpandButton = ({ rowIndex, setCellProps }: EuiDataGridCellValueEle return ( { return ( - + - + {i18n.translate('discover.fieldChooser.filter.filterByTypeLabel', { defaultMessage: 'Filter by type', })} diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss index 4540a945d4884..139230fbdb66a 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss @@ -1,5 +1,5 @@ .dscSidebar { - margin: 0; + margin: 0 !important; flex-grow: 1; padding-left: $euiSize; width: $euiSize * 19; diff --git a/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss b/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss index f7ee1f3c741c4..9072c26576097 100644 --- a/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss +++ b/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss @@ -120,9 +120,10 @@ // EDITING MODE .embPanel--editing { - border-style: dashed; - border-color: $euiColorMediumShade; + border-style: dashed !important; + border-color: $euiColorMediumShade !important; transition: all $euiAnimSpeedFast $euiAnimSlightResistance; + border-width: $euiBorderWidthThin; &:hover, &:focus { diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index dfe31b1da3643..c5a2550723814 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -432,7 +432,11 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'text', _meta: { description: 'Non-default value of setting.' }, }, - 'labs:presentation:unifiedToolbar': { + 'labs:presentation:timeToPresent': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, + 'labs:canvas:enable_ui': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index b8bc06d8a6a29..4dc1773ecfbe2 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -118,5 +118,6 @@ export interface UsageStats { 'banners:placement': string; 'banners:textColor': string; 'banners:backgroundColor': string; - 'labs:presentation:unifiedToolbar': boolean; + 'labs:canvas:enable_ui': boolean; + 'labs:presentation:timeToPresent': boolean; } diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index bc27cf061eb68..9af1bb5434bb1 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,5 +1,13 @@ .kbnTopNavMenu { - margin-right: $euiSizeXS; + @include kbnThemeStyle('v7') { + margin-right: $euiSizeXS; + } + + @include kbnThemeStyle('v8') { + button:last-child { + margin-right: 0; + } + } } .kbnTopNavMenu__badgeWrapper { diff --git a/src/plugins/presentation_util/common/labs.ts b/src/plugins/presentation_util/common/labs.ts index 65e42996ae910..ce7855c516c8b 100644 --- a/src/plugins/presentation_util/common/labs.ts +++ b/src/plugins/presentation_util/common/labs.ts @@ -8,9 +8,9 @@ import { i18n } from '@kbn/i18n'; -export const UNIFIED_TOOLBAR = 'labs:presentation:unifiedToolbar'; +export const TIME_TO_PRESENT = 'labs:presentation:timeToPresent'; -export const projectIDs = [UNIFIED_TOOLBAR] as const; +export const projectIDs = [TIME_TO_PRESENT] as const; export const environmentNames = ['kibana', 'browser', 'session'] as const; export const solutionNames = ['canvas', 'dashboard', 'presentation'] as const; @@ -19,17 +19,18 @@ export const solutionNames = ['canvas', 'dashboard', 'presentation'] as const; * provided to users of our solutions in Kibana. */ export const projects: { [ID in ProjectID]: ProjectConfig & { id: ID } } = { - [UNIFIED_TOOLBAR]: { - id: UNIFIED_TOOLBAR, + [TIME_TO_PRESENT]: { + id: TIME_TO_PRESENT, isActive: false, + isDisplayed: false, environments: ['kibana', 'browser', 'session'], - name: i18n.translate('presentationUtil.labs.enableUnifiedToolbarProjectName', { - defaultMessage: 'Unified Toolbar', + name: i18n.translate('presentationUtil.labs.enableTimeToPresentProjectName', { + defaultMessage: 'Canvas Presentation UI', }), description: i18n.translate('presentationUtil.labs.enableUnifiedToolbarProjectDescription', { - defaultMessage: 'Enable the new unified toolbar design for Presentation solutions', + defaultMessage: 'Enable the new presentation-oriented UI for Canvas.', }), - solutions: ['dashboard', 'canvas'], + solutions: ['canvas'], }, }; @@ -51,6 +52,7 @@ export interface ProjectConfig { id: ProjectID; name: string; isActive: boolean; + isDisplayed: boolean; environments: EnvironmentName[]; description: string; solutions: SolutionName[]; diff --git a/src/plugins/presentation_util/public/components/index.tsx b/src/plugins/presentation_util/public/components/index.tsx index af806e1c22f1a..508a1f4983031 100644 --- a/src/plugins/presentation_util/public/components/index.tsx +++ b/src/plugins/presentation_util/public/components/index.tsx @@ -25,11 +25,9 @@ export const withSuspense =

( ); -export const LazyLabsBeakerButton = withSuspense( - React.lazy(() => import('./labs/labs_beaker_button')) -); +export const LazyLabsBeakerButton = React.lazy(() => import('./labs/labs_beaker_button')); -export const LazyLabsFlyout = withSuspense(React.lazy(() => import('./labs/labs_flyout'))); +export const LazyLabsFlyout = React.lazy(() => import('./labs/labs_flyout')); export const LazyDashboardPicker = React.lazy(() => import('./dashboard_picker')); diff --git a/src/plugins/presentation_util/public/components/labs/environment_switch.tsx b/src/plugins/presentation_util/public/components/labs/environment_switch.tsx index 0acdd433cbac8..9b48bacf3780a 100644 --- a/src/plugins/presentation_util/public/components/labs/environment_switch.tsx +++ b/src/plugins/presentation_util/public/components/labs/environment_switch.tsx @@ -16,6 +16,7 @@ import { EuiScreenReaderOnly, } from '@elastic/eui'; +import { pluginServices } from '../../services'; import { EnvironmentName } from '../../../common/labs'; import { LabsStrings } from '../../i18n'; @@ -34,29 +35,36 @@ export interface Props { name: string; } -export const EnvironmentSwitch = ({ env, isChecked, onChange, name }: Props) => ( - - - - - - {name} - - - {switchText[env].name} - - } - onChange={(e) => onChange(e.target.checked)} - compressed - /> - - - - - - - -); +export const EnvironmentSwitch = ({ env, isChecked, onChange, name }: Props) => { + const { capabilities } = pluginServices.getHooks(); + + const canSet = env === 'kibana' ? capabilities.useService().canSetAdvancedSettings() : true; + + return ( + + + + + + {name} - + + {switchText[env].name} + + } + onChange={(e) => onChange(e.target.checked)} + compressed + /> + + + + + + + + ); +}; diff --git a/src/plugins/presentation_util/public/components/labs/labs.stories.tsx b/src/plugins/presentation_util/public/components/labs/labs.stories.tsx index a9a1a0753d24b..e8dd2abb0c5b8 100644 --- a/src/plugins/presentation_util/public/components/labs/labs.stories.tsx +++ b/src/plugins/presentation_util/public/components/labs/labs.stories.tsx @@ -16,7 +16,12 @@ export default { title: 'Labs/Flyout', description: 'A set of components used for providing Labs controls and projects in another solution.', - argTypes: {}, + argTypes: { + canSetAdvancedSettings: { + control: 'boolean', + defaultValue: true, + }, + }, }; export function BeakerButton() { diff --git a/src/plugins/presentation_util/public/components/labs/labs_flyout.tsx b/src/plugins/presentation_util/public/components/labs/labs_flyout.tsx index 562d3b291a4b3..5b424c7e95f18 100644 --- a/src/plugins/presentation_util/public/components/labs/labs_flyout.tsx +++ b/src/plugins/presentation_util/public/components/labs/labs_flyout.tsx @@ -10,6 +10,8 @@ import React, { ReactNode, useRef, useState, useEffect } from 'react'; import { EuiFlyout, EuiTitle, + EuiSpacer, + EuiText, EuiFlyoutBody, EuiFlyoutFooter, EuiFlyoutHeader, @@ -18,6 +20,7 @@ import { EuiFlexItem, EuiFlexGroup, EuiIcon, + EuiOverlayMask, } from '@elastic/eui'; import { SolutionName, ProjectStatus, ProjectID, Project, EnvironmentName } from '../../../common'; @@ -104,32 +107,47 @@ export const LabsFlyout = (props: Props) => { footer = ( - - {resetButton} - {refreshButton} + + + onClose()} flush="left"> + {strings.getCloseButtonLabel()} + + + + + {resetButton} + {refreshButton} + + ); return ( - - - -

- - - - - {strings.getTitleLabel()} - -

- - - - - - {footer} - + onClose()} headerZindexLocation="below"> + + + +

+ + + + + {strings.getTitleLabel()} + +

+
+ + +

{strings.getDescriptionMessage()}

+
+
+ + + + {footer} +
+
); }; diff --git a/src/plugins/presentation_util/public/components/labs/project_list.tsx b/src/plugins/presentation_util/public/components/labs/project_list.tsx index 4ecf45409b02c..301fd1aa6414f 100644 --- a/src/plugins/presentation_util/public/components/labs/project_list.tsx +++ b/src/plugins/presentation_util/public/components/labs/project_list.tsx @@ -29,6 +29,10 @@ export const ProjectList = (props: Props) => { const items = Object.values(projects) .map((project) => { + if (!project.isDisplayed) { + return null; + } + // Filter out any panels that don't match the solutions filter, (if provided). if (solutions && !solutions.some((solution) => project.solutions.includes(solution))) { return null; diff --git a/src/plugins/presentation_util/public/components/labs/project_list_item.scss b/src/plugins/presentation_util/public/components/labs/project_list_item.scss index c91a07576b314..898770f7811a1 100644 --- a/src/plugins/presentation_util/public/components/labs/project_list_item.scss +++ b/src/plugins/presentation_util/public/components/labs/project_list_item.scss @@ -10,7 +10,7 @@ left: 4px; bottom: $euiSizeL; width: 4px; - background: $euiColorPrimary; + background: $euiColorSecondary; content: ''; } @@ -37,10 +37,20 @@ } &--isOverridden:before { - left: -12px; + left: -$euiSizeS; } &--isOverridden:first-child:before { top: 0; } } + +.projectListItem__titlePendingChangesIndicator { + margin-left: $euiSizeS; + position: relative; + top: -1px; +} + +.projectListItem__solutions { + text-transform: capitalize; +} diff --git a/src/plugins/presentation_util/public/components/labs/project_list_item.stories.tsx b/src/plugins/presentation_util/public/components/labs/project_list_item.stories.tsx index ce93abded521e..bc6c123c21f34 100644 --- a/src/plugins/presentation_util/public/components/labs/project_list_item.stories.tsx +++ b/src/plugins/presentation_util/public/components/labs/project_list_item.stories.tsx @@ -37,7 +37,7 @@ export function EmptyList() { export const ListItem = ( props: Pick< Props['project'], - 'description' | 'isActive' | 'name' | 'solutions' | 'environments' + 'description' | 'isActive' | 'name' | 'solutions' | 'environments' | 'isDisplayed' > & Omit ) => { diff --git a/src/plugins/presentation_util/public/components/labs/project_list_item.tsx b/src/plugins/presentation_util/public/components/labs/project_list_item.tsx index e4aa1abd3693c..994059c9789ec 100644 --- a/src/plugins/presentation_util/public/components/labs/project_list_item.tsx +++ b/src/plugins/presentation_util/public/components/labs/project_list_item.tsx @@ -15,6 +15,8 @@ import { EuiText, EuiFormFieldset, EuiScreenReaderOnly, + EuiSpacer, + EuiIconTip, } from '@elastic/eui'; import classnames from 'classnames'; @@ -47,8 +49,20 @@ export const ProjectListItem = ({ project, onStatusChange }: Props) => { - -

{name}

+ +

+ {name} + {isOverride ? ( + + + + ) : null} +

@@ -59,10 +73,14 @@ export const ProjectListItem = ({ project, onStatusChange }: Props) => {
- {description} + + + {description} + - + + {isActive ? strings.getEnabledStatusMessage() : strings.getDisabledStatusMessage()} diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss index b8022201acf59..4fc3651ee9f73 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss @@ -4,4 +4,9 @@ // Lighten the border color for all states border-color: $euiBorderColor !important; // sass-lint:disable-line no-important + + @include kbnThemeStyle('v8') { + border-width: $euiBorderWidthThin; + border-style: solid; + } } diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss index 870a9a945ed5d..876ee659b71d7 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss @@ -1,6 +1,12 @@ .quickButtonGroup { .quickButtonGroup__button { background-color: $euiColorEmptyShade; + @include kbnThemeStyle('v8') { + // sass-lint:disable-block no-important + border-width: $euiBorderWidthThin !important; + border-style: solid !important; + border-color: $euiBorderColor !important; + } } // Temporary fix for two tone icons to make them monochrome diff --git a/src/plugins/presentation_util/public/i18n/labs.tsx b/src/plugins/presentation_util/public/i18n/labs.tsx index ddf6346bd68ca..d9e34fa43ebb7 100644 --- a/src/plugins/presentation_util/public/i18n/labs.tsx +++ b/src/plugins/presentation_util/public/i18n/labs.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCode } from '@elastic/eui'; export const LabsStrings = { Components: { @@ -18,7 +19,8 @@ export const LabsStrings = { defaultMessage: 'Kibana', }), help: i18n.translate('presentationUtil.labs.components.kibanaSwitchHelp', { - defaultMessage: 'Sets the corresponding Advanced Setting for this lab project in Kibana', + defaultMessage: + 'Sets the corresponding Advanced Setting for this lab project; affects all Kibana users', }), }), getBrowserSwitchText: () => ({ @@ -51,24 +53,28 @@ export const LabsStrings = { i18n.translate('presentationUtil.labs.components.overrideFlagsLabel', { defaultMessage: 'Override flags', }), + getOverriddenIconTipLabel: () => + i18n.translate('presentationUtil.labs.components.overridenIconTipLabel', { + defaultMessage: 'Default overridden', + }), getEnabledStatusMessage: () => ( Enabled, + status: Enabled, }} - description="Displays the current status of a lab project" + description="Displays the enabled status of a lab project" /> ), getDisabledStatusMessage: () => ( Disabled, + status: Disabled, }} - description="Displays the current status of a lab project" + description="Displays the disabled status of a lab project" /> ), }, @@ -77,6 +83,11 @@ export const LabsStrings = { i18n.translate('presentationUtil.labs.components.titleLabel', { defaultMessage: 'Lab projects', }), + getDescriptionMessage: () => + i18n.translate('presentationUtil.labs.components.descriptionMessage', { + defaultMessage: + 'Lab projects are features and functionality that are in-progress or experimental in nature. They can be enabled and disabled locally for your browser or tab, or in Kibana.', + }), getResetToDefaultLabel: () => i18n.translate('presentationUtil.labs.components.resetToDefaultLabel', { defaultMessage: 'Reset to defaults', @@ -89,6 +100,10 @@ export const LabsStrings = { i18n.translate('presentationUtil.labs.components.calloutHelp', { defaultMessage: 'Refresh to apply changes', }), + getCloseButtonLabel: () => + i18n.translate('presentationUtil.labs.components.closeButtonLabel', { + defaultMessage: 'Close', + }), }, }, }; diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index fd3ae89419297..aee3cff92438b 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -8,6 +8,12 @@ import { PresentationUtilPlugin } from './plugin'; +export { + PresentationCapabilitiesService, + PresentationDashboardsService, + PresentationLabsService, +} from './services'; + export { PresentationUtilPluginSetup, PresentationUtilPluginStart } from './types'; export { SaveModalDashboardProps } from './components/types'; export { projectIDs, ProjectID, Project } from '../common/labs'; diff --git a/src/plugins/presentation_util/public/services/capabilities.ts b/src/plugins/presentation_util/public/services/capabilities.ts index 58d56d1a4d81d..421e3e672b328 100644 --- a/src/plugins/presentation_util/public/services/capabilities.ts +++ b/src/plugins/presentation_util/public/services/capabilities.ts @@ -10,4 +10,5 @@ export interface PresentationCapabilitiesService { canAccessDashboards: () => boolean; canCreateNewDashboards: () => boolean; canSaveVisualizations: () => boolean; + canSetAdvancedSettings: () => boolean; } diff --git a/src/plugins/presentation_util/public/services/index.ts b/src/plugins/presentation_util/public/services/index.ts index c01a95f64619c..30bab78aeb27b 100644 --- a/src/plugins/presentation_util/public/services/index.ts +++ b/src/plugins/presentation_util/public/services/index.ts @@ -10,6 +10,10 @@ import { PluginServices } from './create'; import { PresentationCapabilitiesService } from './capabilities'; import { PresentationDashboardsService } from './dashboards'; import { PresentationLabsService } from './labs'; + +export { PresentationCapabilitiesService } from './capabilities'; +export { PresentationDashboardsService } from './dashboards'; +export { PresentationLabsService } from './labs'; export interface PresentationUtilServices { dashboards: PresentationDashboardsService; capabilities: PresentationCapabilitiesService; diff --git a/src/plugins/presentation_util/public/services/kibana/capabilities.ts b/src/plugins/presentation_util/public/services/kibana/capabilities.ts index d46af31b30667..7b12a9a3cc618 100644 --- a/src/plugins/presentation_util/public/services/kibana/capabilities.ts +++ b/src/plugins/presentation_util/public/services/kibana/capabilities.ts @@ -16,11 +16,12 @@ export type CapabilitiesServiceFactory = KibanaPluginServiceFactory< >; export const capabilitiesServiceFactory: CapabilitiesServiceFactory = ({ coreStart }) => { - const { dashboard, visualize } = coreStart.application.capabilities; + const { dashboard, visualize, advancedSettings } = coreStart.application.capabilities; return { canAccessDashboards: () => Boolean(dashboard.show), canCreateNewDashboards: () => Boolean(dashboard.createNew), canSaveVisualizations: () => Boolean(visualize.save), + canSetAdvancedSettings: () => Boolean(advancedSettings.save), }; }; diff --git a/src/plugins/presentation_util/public/services/kibana/labs.ts b/src/plugins/presentation_util/public/services/kibana/labs.ts index d2c0735c76eeb..db78103469880 100644 --- a/src/plugins/presentation_util/public/services/kibana/labs.ts +++ b/src/plugins/presentation_util/public/services/kibana/labs.ts @@ -14,6 +14,7 @@ import { ProjectID, Project, getProjectIDs, + SolutionName, } from '../../../common'; import { PresentationUtilPluginStartDeps } from '../../types'; import { KibanaPluginServiceFactory } from '../create'; @@ -35,9 +36,15 @@ export const labsServiceFactory: LabsServiceFactory = ({ coreStart }) => { const localStorage = window.localStorage; const sessionStorage = window.sessionStorage; - const getProjects = () => + const getProjects = (solutions: SolutionName[] = []) => projectIDs.reduce((acc, id) => { - acc[id] = getProject(id); + const project = getProject(id); + if ( + solutions.length === 0 || + solutions.some((solution) => project.solutions.includes(solution)) + ) { + acc[id] = project; + } return acc; }, {} as { [id in ProjectID]: Project }); diff --git a/src/plugins/presentation_util/public/services/labs.ts b/src/plugins/presentation_util/public/services/labs.ts index 72e9a232ea976..ef583bd4189a9 100644 --- a/src/plugins/presentation_util/public/services/labs.ts +++ b/src/plugins/presentation_util/public/services/labs.ts @@ -16,12 +16,13 @@ import { EnvironmentStatus, environmentNames, isProjectEnabledByStatus, + SolutionName, } from '../../common'; export interface PresentationLabsService { getProjectIDs: () => typeof projectIDs; getProject: (id: ProjectID) => Project; - getProjects: () => Record; + getProjects: (solutions?: SolutionName[]) => Record; setProjectStatus: (id: ProjectID, env: EnvironmentName, status: boolean) => void; reset: () => void; } diff --git a/src/plugins/presentation_util/public/services/storybook/capabilities.ts b/src/plugins/presentation_util/public/services/storybook/capabilities.ts index 60285f00993ab..1dd8cfd571e5c 100644 --- a/src/plugins/presentation_util/public/services/storybook/capabilities.ts +++ b/src/plugins/presentation_util/public/services/storybook/capabilities.ts @@ -18,14 +18,14 @@ type CapabilitiesServiceFactory = PluginServiceFactory< export const capabilitiesServiceFactory: CapabilitiesServiceFactory = ({ canAccessDashboards, canCreateNewDashboards, - canEditDashboards, canSaveVisualizations, + canSetAdvancedSettings, }) => { const check = (value: boolean = true) => value; return { canAccessDashboards: () => check(canAccessDashboards), canCreateNewDashboards: () => check(canCreateNewDashboards), - canEditDashboards: () => check(canEditDashboards), canSaveVisualizations: () => check(canSaveVisualizations), + canSetAdvancedSettings: () => check(canSetAdvancedSettings), }; }; diff --git a/src/plugins/presentation_util/public/services/storybook/index.ts b/src/plugins/presentation_util/public/services/storybook/index.ts index 37669d52c0096..40fdc40a4632e 100644 --- a/src/plugins/presentation_util/public/services/storybook/index.ts +++ b/src/plugins/presentation_util/public/services/storybook/index.ts @@ -18,8 +18,8 @@ export { PresentationUtilServices } from '..'; export interface StorybookParams { canAccessDashboards?: boolean; canCreateNewDashboards?: boolean; - canEditDashboards?: boolean; canSaveVisualizations?: boolean; + canSetAdvancedSettings?: boolean; } export const providers: PluginServiceProviders = { diff --git a/src/plugins/presentation_util/public/services/storybook/labs.ts b/src/plugins/presentation_util/public/services/storybook/labs.ts index 8878e218f19e8..396db52460053 100644 --- a/src/plugins/presentation_util/public/services/storybook/labs.ts +++ b/src/plugins/presentation_util/public/services/storybook/labs.ts @@ -8,7 +8,7 @@ import { EnvironmentName, projectIDs, Project } from '../../../common'; import { PluginServiceFactory } from '../create'; -import { projects, ProjectID, getProjectIDs } from '../../../common'; +import { projects, ProjectID, getProjectIDs, SolutionName } from '../../../common'; import { PresentationLabsService, isEnabledByStorageValue, applyProjectStatus } from '../labs'; export type LabsServiceFactory = PluginServiceFactory; @@ -16,9 +16,15 @@ export type LabsServiceFactory = PluginServiceFactory; export const labsServiceFactory: LabsServiceFactory = () => { const storage = window.sessionStorage; - const getProjects = () => + const getProjects = (solutions: SolutionName[] = []) => projectIDs.reduce((acc, id) => { - acc[id] = getProject(id); + const project = getProject(id); + if ( + solutions.length === 0 || + solutions.some((solution) => project.solutions.includes(solution)) + ) { + acc[id] = project; + } return acc; }, {} as { [id in ProjectID]: Project }); diff --git a/src/plugins/presentation_util/public/services/stub/capabilities.ts b/src/plugins/presentation_util/public/services/stub/capabilities.ts index 80b913c4f0856..be1be966285f7 100644 --- a/src/plugins/presentation_util/public/services/stub/capabilities.ts +++ b/src/plugins/presentation_util/public/services/stub/capabilities.ts @@ -14,6 +14,6 @@ type CapabilitiesServiceFactory = PluginServiceFactory ({ canAccessDashboards: () => true, canCreateNewDashboards: () => true, - canEditDashboards: () => true, canSaveVisualizations: () => true, + canSetAdvancedSettings: () => true, }); diff --git a/src/plugins/presentation_util/public/services/stub/labs.ts b/src/plugins/presentation_util/public/services/stub/labs.ts index c83bb68b5d072..c511ed26ef32e 100644 --- a/src/plugins/presentation_util/public/services/stub/labs.ts +++ b/src/plugins/presentation_util/public/services/stub/labs.ts @@ -13,6 +13,7 @@ import { EnvironmentName, getProjectIDs, Project, + SolutionName, } from '../../../common'; import { PluginServiceFactory } from '../create'; import { PresentationLabsService, isEnabledByStorageValue, applyProjectStatus } from '../labs'; @@ -36,9 +37,15 @@ export const labsServiceFactory: LabsServiceFactory = () => { let statuses = reset(); - const getProjects = () => + const getProjects = (solutions: SolutionName[] = []) => projectIDs.reduce((acc, id) => { - acc[id] = getProject(id); + const project = getProject(id); + if ( + solutions.length === 0 || + solutions.some((solution) => project.solutions.includes(solution)) + ) { + acc[id] = project; + } return acc; }, {} as { [id in ProjectID]: Project }); diff --git a/src/plugins/presentation_util/server/index.ts b/src/plugins/presentation_util/server/index.ts index de7e8de405442..d1f9ef6da760a 100644 --- a/src/plugins/presentation_util/server/index.ts +++ b/src/plugins/presentation_util/server/index.ts @@ -8,4 +8,5 @@ import { PresentationUtilPlugin } from './plugin'; +export { SETTING_CATEGORY } from './ui_settings'; export const plugin = () => new PresentationUtilPlugin(); diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 842496815c15c..76460a57ee442 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -8331,7 +8331,13 @@ "description": "Non-default value of setting." } }, - "labs:presentation:unifiedToolbar": { + "labs:presentation:timeToPresent": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, + "labs:canvas:enable_ui": { "type": "boolean", "_meta": { "description": "Non-default value of setting." diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts index 4e45ddf434771..797e40df22710 100644 --- a/src/plugins/vis_type_timeseries/public/metrics_type.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; -import { TSVB_EDITOR_NAME } from './application'; +import { TSVB_EDITOR_NAME } from './application/editor_controller'; import { PANEL_TYPES } from '../common/panel_types'; import { isStringTypeIndexPattern } from '../common/index_patterns_utils'; import { toExpressionAst } from './to_ast'; diff --git a/src/plugins/vis_type_timeseries/public/plugin.ts b/src/plugins/vis_type_timeseries/public/plugin.ts index 6900630ffa971..1c1212add3d8c 100644 --- a/src/plugins/vis_type_timeseries/public/plugin.ts +++ b/src/plugins/vis_type_timeseries/public/plugin.ts @@ -6,13 +6,11 @@ * Side Public License, v 1. */ -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 { VisualizePluginSetup } from '../../visualize/public'; -import { EditorController, TSVB_EDITOR_NAME } from './application'; +import { EditorController, TSVB_EDITOR_NAME } from './application/editor_controller'; import { createMetricsFn } from './metrics_fn'; import { metricsVisDefinition } from './metrics_type'; diff --git a/src/plugins/vis_type_timeseries/public/request_handler.ts b/src/plugins/vis_type_timeseries/public/request_handler.ts index bf58287870c82..9c350305820cd 100644 --- a/src/plugins/vis_type_timeseries/public/request_handler.ts +++ b/src/plugins/vis_type_timeseries/public/request_handler.ts @@ -8,7 +8,8 @@ import { KibanaContext } from '../../data/public'; -import { getTimezone, validateInterval } from './application'; +import { getTimezone } from './application/lib/get_timezone'; +import { validateInterval } from './application/lib/validate_interval'; import { getUISettings, getDataStart, getCoreStart } from './services'; import { MAX_BUCKETS_SETTING, ROUTES } from '../common/constants'; import { TimeseriesVisParams } from './types'; diff --git a/src/plugins/vis_type_timeseries/public/timeseries_vis_renderer.tsx b/src/plugins/vis_type_timeseries/public/timeseries_vis_renderer.tsx index 7faf314cd4046..52a357bd0cc90 100644 --- a/src/plugins/vis_type_timeseries/public/timeseries_vis_renderer.tsx +++ b/src/plugins/vis_type_timeseries/public/timeseries_vis_renderer.tsx @@ -12,14 +12,16 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; import { IUiSettingsClient } from 'kibana/public'; -import type { PersistedState } from '../../visualizations/public'; -import { VisualizationContainer } from '../../visualizations/public'; -import { ExpressionRenderDefinition } from '../../expressions/common/expression_renderers'; -import { TimeseriesRenderValue } from './metrics_fn'; + +import { VisualizationContainer, PersistedState } from '../../visualizations/public'; + import { isVisTableData, TimeseriesVisData } from '../common/types'; -import { TimeseriesVisParams } from './types'; import { getChartsSetup } from './services'; +import type { TimeseriesVisParams } from './types'; +import type { ExpressionRenderDefinition } from '../../expressions/common'; +import type { TimeseriesRenderValue } from './metrics_fn'; + const TimeseriesVisualization = lazy( () => import('./application/components/timeseries_visualization') ); @@ -39,6 +41,10 @@ export const getTimeseriesVisRenderer: (deps: { name: 'timeseries_vis', reuseDomNode: true, render: async (domNode, config, handlers) => { + // Build optimization. Move app styles from main bundle + // @ts-expect-error TS error, cannot find type declaration for scss + await import('./application/index.scss'); + handlers.onDestroy(() => { unmountComponentAtNode(domNode); }); diff --git a/src/plugins/vis_type_timeseries/public/to_ast.ts b/src/plugins/vis_type_timeseries/public/to_ast.ts index 90d57218da28c..c0c0a5b1546a9 100644 --- a/src/plugins/vis_type_timeseries/public/to_ast.ts +++ b/src/plugins/vis_type_timeseries/public/to_ast.ts @@ -7,9 +7,9 @@ */ import { buildExpression, buildExpressionFunction } from '../../expressions/public'; -import { Vis } from '../../visualizations/public'; -import { TimeseriesExpressionFunctionDefinition } from './metrics_fn'; -import { TimeseriesVisParams } from './types'; +import type { Vis } from '../../visualizations/public'; +import type { TimeseriesExpressionFunctionDefinition } from './metrics_fn'; +import type { TimeseriesVisParams } from './types'; export const toExpressionAst = (vis: Vis) => { const timeseries = buildExpressionFunction('tsvb', { diff --git a/src/plugins/vis_type_xy/public/utils/domain.ts b/src/plugins/vis_type_xy/public/utils/domain.ts index 322ffc087766c..9cd74cd0433cc 100644 --- a/src/plugins/vis_type_xy/public/utils/domain.ts +++ b/src/plugins/vis_type_xy/public/utils/domain.ts @@ -57,8 +57,8 @@ export const getAdjustedDomain = ( const lastXValue = xValues[xValues.length - 1]; const domainMin = Math.min(firstXValue, domain.min); - const domainMaxValue = hasBars ? domain.max - interval : lastXValue + interval; - const domainMax = Math.max(domainMaxValue, lastXValue); + const domainMaxValue = Math.max(domain.max - interval, lastXValue); + const domainMax = hasBars ? domainMaxValue : domainMaxValue + interval; const minInterval = getAdjustedInterval( xValues, intervalESValue, diff --git a/test/api_integration/apis/saved_objects/migrations.ts b/test/api_integration/apis/saved_objects/migrations.ts index 87997ab4231a2..dcd34c604dc31 100644 --- a/test/api_integration/apis/saved_objects/migrations.ts +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -735,6 +735,7 @@ async function migrateIndex({ mappingProperties, batchSize: 10, log: getLogMock(), + setStatus: () => {}, pollInterval: 50, scrollDuration: '5m', serializer: new SavedObjectsSerializer(typeRegistry), diff --git a/test/functional/apps/context/_discover_navigation.js b/test/functional/apps/context/_discover_navigation.js index dc5d56271c7fd..1c3862e07e9d7 100644 --- a/test/functional/apps/context/_discover_navigation.js +++ b/test/functional/apps/context/_discover_navigation.js @@ -35,7 +35,10 @@ export default function ({ getService, getPageObjects }) { describe('context link in discover', () => { before(async () => { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); - await kibanaServer.uiSettings.update({ 'doc_table:legacy': true }); + await kibanaServer.uiSettings.update({ + 'doc_table:legacy': true, + defaultIndex: 'logstash-*', + }); await PageObjects.common.navigateToApp('discover'); for (const columnName of TEST_COLUMN_NAMES) { diff --git a/test/functional/apps/dashboard/copy_panel_to.ts b/test/functional/apps/dashboard/copy_panel_to.ts index 9abdc2ceffc01..641d520801c4d 100644 --- a/test/functional/apps/dashboard/copy_panel_to.ts +++ b/test/functional/apps/dashboard/copy_panel_to.ts @@ -91,6 +91,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.expectOnDashboard(`Editing ${fewPanelsTitle}`); const newPanelCount = await PageObjects.dashboard.getPanelCount(); expect(newPanelCount).to.be(fewPanelsPanelCount + 1); + + // Save & ensure that view mode is applied properly. + await PageObjects.dashboard.clickQuickSave(); + await testSubjects.existOrFail('saveDashboardSuccess'); + + await PageObjects.dashboard.clickCancelOutOfEditMode(); + const panelOptions = await dashboardPanelActions.getPanelHeading(markdownTitle); + await dashboardPanelActions.openContextMenu(panelOptions); + await dashboardPanelActions.expectMissingEditPanelAction(); }); it('does not show the current dashboard in the dashboard picker', async () => { diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index 7bdc3490a959f..8ed54f88afea3 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -131,7 +131,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return actualCount === expectedCount; }); const newDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); - expect(Math.round(newDurationHours)).to.be(27); + expect(Math.round(newDurationHours)).to.be(26); await retry.waitFor('doc table to contain the right search result', async () => { const rowData = await PageObjects.discover.getDocTableField(1); diff --git a/test/functional/apps/discover/_runtime_fields_editor.ts b/test/functional/apps/discover/_runtime_fields_editor.ts index ea95e0adff617..f780f4ecad97c 100644 --- a/test/functional/apps/discover/_runtime_fields_editor.ts +++ b/test/functional/apps/discover/_runtime_fields_editor.ts @@ -32,8 +32,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await fieldEditor.save(); }; - // FLAKY: https://github.com/elastic/kibana/issues/97864 - describe.skip('discover integration with runtime fields editor', function describeIndexTests() { + describe('discover integration with runtime fields editor', function describeIndexTests() { before(async function () { await esArchiver.load('discover'); await esArchiver.loadIfNeeded('logstash_functional'); @@ -43,19 +42,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); }); - after(async () => { - await kibanaServer.uiSettings.replace({ 'discover:searchFieldsFromSource': true }); - }); - it('allows adding custom label to existing fields', async function () { - await PageObjects.discover.clickFieldListItemAdd('bytes'); + const customLabel = 'megabytes'; await PageObjects.discover.editField('bytes'); await fieldEditor.enableCustomLabel(); - await fieldEditor.setCustomLabel('megabytes'); + await fieldEditor.setCustomLabel(customLabel); await fieldEditor.save(); await PageObjects.header.waitUntilLoadingHasFinished(); - expect(await PageObjects.discover.getDocHeader()).to.have.string('megabytes'); - expect((await PageObjects.discover.getAllFieldNames()).includes('megabytes')).to.be(true); + expect((await PageObjects.discover.getAllFieldNames()).includes(customLabel)).to.be(true); + await PageObjects.discover.clickFieldListItemAdd('bytes'); + expect(await PageObjects.discover.getDocHeader()).to.have.string(customLabel); }); it('allows creation of a new field', async function () { diff --git a/test/functional/apps/visualize/_area_chart.ts b/test/functional/apps/visualize/_area_chart.ts index 1ae476b0868fb..f6eaa2c685f5d 100644 --- a/test/functional/apps/visualize/_area_chart.ts +++ b/test/functional/apps/visualize/_area_chart.ts @@ -96,7 +96,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show correct chart', async function () { const xAxisLabels = await PageObjects.visChart.getExpectedValue( ['2015-09-20 00:00', '2015-09-21 00:00', '2015-09-22 00:00', '2015-09-23 00:00'], - ['2015-09-19 12:00', '2015-09-20 12:00', '2015-09-21 12:00', '2015-09-22 12:00'] + [ + '2015-09-19 12:00', + '2015-09-20 12:00', + '2015-09-21 12:00', + '2015-09-22 12:00', + '2015-09-23 12:00', + ] ); const yAxisLabels = await PageObjects.visChart.getExpectedValue( ['0', '200', '400', '600', '800', '1,000', '1,200', '1,400', '1,600'], diff --git a/test/functional/apps/visualize/_point_series_options.ts b/test/functional/apps/visualize/_point_series_options.ts index ac641fb554b0b..d4bcc19a7c87c 100644 --- a/test/functional/apps/visualize/_point_series_options.ts +++ b/test/functional/apps/visualize/_point_series_options.ts @@ -60,7 +60,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.clickGo(); } - describe('point series', function describeIndexTests() { + describe('vlad point series', function describeIndexTests() { before(initChart); describe('secondary value axis', function () { @@ -281,10 +281,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ['2015-09-20 00:00', '2015-09-21 00:00', '2015-09-22 00:00'], [ '2015-09-19 12:00', - '2015-09-20 06:00', - '2015-09-21 00:00', - '2015-09-21 18:00', + '2015-09-20 12:00', + '2015-09-21 12:00', '2015-09-22 12:00', + '2015-09-23 12:00', ] ); @@ -328,6 +328,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { '14:30', '15:00', '15:30', + '16:00', ] ); return labels.toString() === xLabels.toString(); @@ -396,6 +397,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { '21:30', '22:00', '22:30', + '23:00', ] ); return labels2.toString() === xLabels2.toString(); diff --git a/test/functional/fixtures/es_archiver/visualize/data.json b/test/functional/fixtures/es_archiver/visualize/data.json index 66941e201e9ba..f337bffe80f2c 100644 --- a/test/functional/fixtures/es_archiver/visualize/data.json +++ b/test/functional/fixtures/es_archiver/visualize/data.json @@ -207,7 +207,7 @@ "fields": "[{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"esTypes\":[\"_type\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"message\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"message.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"message\"}}},{\"name\":\"user\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"user.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"user\"}}}]", "title": "test_index*" }, - "type": "test_index*" + "type": "index-pattern" } } } diff --git a/test/plugin_functional/test_suites/core_plugins/applications.ts b/test/plugin_functional/test_suites/core_plugins/applications.ts index 0e52b536410e4..0145a84423b3c 100644 --- a/test/plugin_functional/test_suites/core_plugins/applications.ts +++ b/test/plugin_functional/test_suites/core_plugins/applications.ts @@ -25,7 +25,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide expect(await testSubjects.exists('kbnLoadingMessage')).to.be(false); const getAppWrapperHeight = async () => { - const wrapper = await find.byClassName('app-wrapper'); + const wrapper = await find.byClassName('kbnAppWrapper'); return (await wrapper.getSize()).height; }; diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts index 210bdf954ada4..1db990edef2a9 100644 --- a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts @@ -260,9 +260,14 @@ export class AlertsClient { ); const username = await this.getUserName(); - const createdAPIKey = data.enabled - ? await this.createAPIKey(this.generateAPIKeyName(alertType.id, data.name)) - : null; + let createdAPIKey = null; + try { + createdAPIKey = data.enabled + ? await this.createAPIKey(this.generateAPIKeyName(alertType.id, data.name)) + : null; + } catch (error) { + throw Boom.badRequest(`Error creating rule: could not create API key - ${error.message}`); + } this.validateActions(alertType, data.actions); @@ -727,9 +732,16 @@ export class AlertsClient { const { actions, references } = await this.denormalizeActions(data.actions); const username = await this.getUserName(); - const createdAPIKey = attributes.enabled - ? await this.createAPIKey(this.generateAPIKeyName(alertType.id, data.name)) - : null; + + let createdAPIKey = null; + try { + createdAPIKey = attributes.enabled + ? await this.createAPIKey(this.generateAPIKeyName(alertType.id, data.name)) + : null; + } catch (error) { + throw Boom.badRequest(`Error updating rule: could not create API key - ${error.message}`); + } + const apiKeyAttributes = this.apiKeyAsAlertAttributes(createdAPIKey, username); const notifyWhen = getAlertNotifyWhenType(data.notifyWhen, data.throttle); @@ -837,12 +849,21 @@ export class AlertsClient { } const username = await this.getUserName(); + + let createdAPIKey = null; + try { + createdAPIKey = await this.createAPIKey( + this.generateAPIKeyName(attributes.alertTypeId, attributes.name) + ); + } catch (error) { + throw Boom.badRequest( + `Error updating API key for rule: could not create API key - ${error.message}` + ); + } + const updateAttributes = this.updateMeta({ ...attributes, - ...this.apiKeyAsAlertAttributes( - await this.createAPIKey(this.generateAPIKeyName(attributes.alertTypeId, attributes.name)), - username - ), + ...this.apiKeyAsAlertAttributes(createdAPIKey, username), updatedAt: new Date().toISOString(), updatedBy: username, }); @@ -944,13 +965,20 @@ export class AlertsClient { if (attributes.enabled === false) { const username = await this.getUserName(); + + let createdAPIKey = null; + try { + createdAPIKey = await this.createAPIKey( + this.generateAPIKeyName(attributes.alertTypeId, attributes.name) + ); + } catch (error) { + throw Boom.badRequest(`Error enabling rule: could not create API key - ${error.message}`); + } + const updateAttributes = this.updateMeta({ ...attributes, enabled: true, - ...this.apiKeyAsAlertAttributes( - await this.createAPIKey(this.generateAPIKeyName(attributes.alertTypeId, attributes.name)), - username - ), + ...this.apiKeyAsAlertAttributes(createdAPIKey, username), updatedBy: username, updatedAt: new Date().toISOString(), }); diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts index 158c9478e6be1..6f493ced47371 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts @@ -1701,6 +1701,18 @@ describe('create()', () => { ); }); + test('throws an error if API key creation throws', async () => { + const data = getMockData(); + alertsClientParams.createAPIKey.mockImplementation(() => { + throw new Error('no'); + }); + expect( + async () => await alertsClient.create({ data }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error creating rule: could not create API key - no"` + ); + }); + test('throws error when ensureActionTypeEnabled throws', async () => { const data = getMockData(); alertTypeRegistry.ensureAlertTypeEnabled.mockImplementation(() => { diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/enable.test.ts index db24d192c7755..7b0d6d7b1f10b 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/enable.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/enable.test.ts @@ -359,6 +359,17 @@ describe('enable()', () => { ); }); + test('throws an error if API key creation throws', async () => { + alertsClientParams.createAPIKey.mockImplementation(() => { + throw new Error('no'); + }); + expect( + async () => await alertsClient.enable({ id: '1' }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error enabling rule: could not create API key - no"` + ); + }); + test('falls back when failing to getDecryptedAsInternalUser', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValue(new Error('Fail')); diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts index 24cef4677a9a2..cdbfbbac9f9a1 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts @@ -692,6 +692,53 @@ describe('update()', () => { `); }); + it('throws an error if API key creation throws', async () => { + alertsClientParams.createAPIKey.mockImplementation(() => { + throw new Error('no'); + }); + expect( + async () => + await alertsClient.update({ + id: '1', + data: { + schedule: { interval: '10s' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + { + group: 'default', + id: '2', + params: { + foo: true, + }, + }, + ], + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error updating rule: could not create API key - no"` + ); + }); + it('should validate params', async () => { alertTypeRegistry.get.mockReturnValueOnce({ id: '123', diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/update_api_key.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/update_api_key.test.ts index e0be54054e593..18bae8d34a8da 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/update_api_key.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/update_api_key.test.ts @@ -99,13 +99,13 @@ describe('updateApiKey()', () => { references: [], }); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingEncryptedAlert); + }); + + test('updates the API key for the alert', async () => { alertsClientParams.createAPIKey.mockResolvedValueOnce({ apiKeysEnabled: true, result: { id: '234', name: '123', api_key: 'abc' }, }); - }); - - test('updates the API key for the alert', async () => { await alertsClient.updateApiKey({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { @@ -145,7 +145,22 @@ describe('updateApiKey()', () => { ); }); + test('throws an error if API key creation throws', async () => { + alertsClientParams.createAPIKey.mockImplementation(() => { + throw new Error('no'); + }); + expect( + async () => await alertsClient.updateApiKey({ id: '1' }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error updating API key for rule: could not create API key - no"` + ); + }); + test('falls back to SOC when getDecryptedAsInternalUser throws an error', async () => { + alertsClientParams.createAPIKey.mockResolvedValueOnce({ + apiKeysEnabled: true, + result: { id: '234', name: '123', api_key: 'abc' }, + }); encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx index 965449b78f3e0..b8c232f968523 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx @@ -26,6 +26,8 @@ import { EuiText, EuiIcon, EuiBadge, + EuiButtonIcon, + EuiOutsideClickDetector, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -87,7 +89,6 @@ export function SelectableUrlList({ }: Props) { const [darkMode] = useUiSetting$('theme:darkMode'); - const [popoverRef, setPopoverRef] = useState(null); const [searchRef, setSearchRef] = useState(null); const titleRef = useRef(null); @@ -105,7 +106,7 @@ export function SelectableUrlList({ // @ts-ignore - not sure, why it's not working useEvent('keydown', onEnterKey, searchRef); - const searchOnFocus = (e: React.FocusEvent) => { + const onInputClick = (e: React.MouseEvent) => { setPopoverIsOpen(true); }; @@ -114,15 +115,6 @@ export function SelectableUrlList({ setPopoverIsOpen(true); }; - const searchOnBlur = (e: React.FocusEvent) => { - if ( - !popoverRef?.contains(e.relatedTarget as HTMLElement) && - !popoverRef?.contains(titleRef.current as HTMLDivElement) - ) { - setPopoverIsOpen(false); - } - }; - const formattedOptions = formatOptions(data.items ?? []); const closePopover = () => { @@ -163,11 +155,21 @@ export function SelectableUrlList({ function PopOverTitle() { return ( - + {loading ? : titleText} + + closePopover()} + aria-label={i18n.translate('xpack.apm.csm.search.url.close', { + defaultMessage: 'Close', + })} + iconType={'cross'} + /> + ); @@ -183,8 +185,7 @@ export function SelectableUrlList({ singleSelection={false} searchProps={{ isClearable: true, - onFocus: searchOnFocus, - onBlur: searchOnBlur, + onClick: onInputClick, onInput: onSearchInput, inputRef: setSearchRef, placeholder: I18LABELS.searchByUrl, @@ -199,56 +200,57 @@ export function SelectableUrlList({ noMatchesMessage={emptyMessage} > {(list, search) => ( - -
- - {searchValue && ( - - - {searchValue}, - icon: ( - - Enter - - ), - }} - /> - - - )} - {list} - - - - { - onTermChange(); - closePopover(); - }} - > - {i18n.translate('xpack.apm.apply.label', { - defaultMessage: 'Apply', - })} - - - - -
-
+ closePopover()}> + +
+ + {searchValue && ( + + + {searchValue}, + icon: ( + + Enter + + ), + }} + /> + + + )} + {list} + + + + { + onTermChange(); + closePopover(); + }} + > + {i18n.translate('xpack.apm.apply.label', { + defaultMessage: 'Apply', + })} + + + + +
+
+
)} ); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_details.test.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_details.test.tsx new file mode 100644 index 0000000000000..10919cf4a32aa --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_details.test.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { + expectTextsInDocument, + expectTextsNotInDocument, + renderWithTheme, +} from '../../../../utils/testHelpers'; +import { InstanceDetails } from './intance_details'; +import * as useInstanceDetailsFetcher from './use_instance_details_fetcher'; + +type ServiceInstanceDetails = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>; + +describe('InstanceDetails', () => { + it('renders loading spinner when data is being fetched', () => { + jest + .spyOn(useInstanceDetailsFetcher, 'useInstanceDetailsFetcher') + .mockReturnValue({ data: undefined, status: FETCH_STATUS.LOADING }); + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId('loadingSpinner')).toBeInTheDocument(); + }); + + it('renders all sections', () => { + jest + .spyOn(useInstanceDetailsFetcher, 'useInstanceDetailsFetcher') + .mockReturnValue({ + data: { + service: { node: { name: 'foo' } }, + container: { id: 'baz' }, + cloud: { provider: 'bar' }, + } as ServiceInstanceDetails, + status: FETCH_STATUS.SUCCESS, + }); + const component = renderWithTheme( + + ); + expectTextsInDocument(component, ['Service', 'Container', 'Cloud']); + }); + + it('hides service section', () => { + jest + .spyOn(useInstanceDetailsFetcher, 'useInstanceDetailsFetcher') + .mockReturnValue({ + data: { + container: { id: 'baz' }, + cloud: { provider: 'bar' }, + } as ServiceInstanceDetails, + status: FETCH_STATUS.SUCCESS, + }); + const component = renderWithTheme( + + ); + expectTextsInDocument(component, ['Container', 'Cloud']); + expectTextsNotInDocument(component, ['Service']); + }); + + it('hides container section', () => { + jest + .spyOn(useInstanceDetailsFetcher, 'useInstanceDetailsFetcher') + .mockReturnValue({ + data: { + service: { node: { name: 'foo' } }, + cloud: { provider: 'bar' }, + } as ServiceInstanceDetails, + status: FETCH_STATUS.SUCCESS, + }); + const component = renderWithTheme( + + ); + expectTextsInDocument(component, ['Service', 'Cloud']); + expectTextsNotInDocument(component, ['Container']); + }); + + it('hides cloud section', () => { + jest + .spyOn(useInstanceDetailsFetcher, 'useInstanceDetailsFetcher') + .mockReturnValue({ + data: { + service: { node: { name: 'foo' } }, + container: { id: 'baz' }, + } as ServiceInstanceDetails, + status: FETCH_STATUS.SUCCESS, + }); + const component = renderWithTheme( + + ); + expectTextsInDocument(component, ['Service', 'Container']); + expectTextsNotInDocument(component, ['Cloud']); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx index f50d02bb15454..ba1da7e6dd6eb 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx @@ -82,7 +82,7 @@ export function InstanceDetails({ serviceName, serviceNodeName }: Props) { ) { return (
- +
); } diff --git a/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx b/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx index ff34359d83c76..1b503e9b05286 100644 --- a/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx @@ -9,25 +9,20 @@ import { i18n } from '@kbn/i18n'; import { startsWith, uniqueId } from 'lodash'; import React, { useState } from 'react'; import { useHistory, useLocation, useParams } from 'react-router-dom'; -import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { esKuery, IIndexPattern, QuerySuggestion, } from '../../../../../../../src/plugins/data/public'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; -import { useDynamicIndexPatternFetcher } from '../../../hooks/use_dynamic_index_pattern'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useDynamicIndexPatternFetcher } from '../../../hooks/use_dynamic_index_pattern'; import { fromQuery, toQuery } from '../Links/url_helpers'; import { getBoolFilter } from './get_bool_filter'; // @ts-expect-error import { Typeahead } from './Typeahead'; import { useProcessorEvent } from './use_processor_event'; -const Container = euiStyled.div` - margin-bottom: 10px; -`; - interface State { suggestions: QuerySuggestion[]; isLoadingSuggestions: boolean; @@ -145,16 +140,14 @@ export function KueryBar(props: { prepend?: React.ReactNode | string }) { } return ( - - - + ); } diff --git a/x-pack/plugins/apm/public/components/shared/key_value_filter_list/index.tsx b/x-pack/plugins/apm/public/components/shared/key_value_filter_list/index.tsx index c836919a8a6ab..54d8790c32d33 100644 --- a/x-pack/plugins/apm/public/components/shared/key_value_filter_list/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/key_value_filter_list/index.tsx @@ -65,7 +65,8 @@ export function KeyValueFilterList({ icon?: string; onClickFilter: (filter: { key: string; value: any }) => void; }) { - if (!keyValueList.length) { + const nonEmptyKeyValueList = removeEmptyValues(keyValueList); + if (!nonEmptyKeyValueList.length) { return null; } @@ -77,7 +78,7 @@ export function KeyValueFilterList({ buttonClassName="buttonContentContainer" > - {removeEmptyValues(keyValueList).map(({ key, value }) => { + {nonEmptyKeyValueList.map(({ key, value }) => { return ( - - {showTransactionTypeSelector && ( - - - - )} + - + + {showTransactionTypeSelector && ( + + + + )} + + + + - + {showTimeComparison && ( - + )} - + + ); } diff --git a/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx b/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx index 772b42ed13577..dc071fe93bbbd 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx +++ b/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx @@ -6,7 +6,6 @@ */ import { EuiSelect } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import React, { FormEvent, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import styled from 'styled-components'; @@ -18,7 +17,7 @@ import * as urlHelpers from './Links/url_helpers'; // min-width on here to the width when "request" is loaded so it doesn't start // out collapsed and change its width when the list of transaction types is loaded. const EuiSelectWithWidth = styled(EuiSelect)` - min-width: 157px; + min-width: 200px; `; export function TransactionTypeSelect() { @@ -45,9 +44,6 @@ export function TransactionTypeSelect() { diff --git a/x-pack/plugins/apm/public/hooks/use_break_points.ts b/x-pack/plugins/apm/public/hooks/use_break_points.ts index 53e46cfe898ac..fb8dc8f6a55b8 100644 --- a/x-pack/plugins/apm/public/hooks/use_break_points.ts +++ b/x-pack/plugins/apm/public/hooks/use_break_points.ts @@ -10,26 +10,28 @@ import useWindowSize from 'react-use/lib/useWindowSize'; import useDebounce from 'react-use/lib/useDebounce'; import { isWithinMaxBreakpoint } from '@elastic/eui'; -export function useBreakPoints() { - const [screenSizes, setScreenSizes] = useState({ - isSmall: false, - isMedium: false, - isLarge: false, - isXl: false, - }); +function isMinXXL(windowWidth: number) { + return windowWidth >= 1600; +} + +function getScreenSizes(windowWidth: number) { + const isXXL = isMinXXL(windowWidth); + return { + isSmall: isWithinMaxBreakpoint(windowWidth, 's'), + isMedium: isWithinMaxBreakpoint(windowWidth, 'm'), + isLarge: isWithinMaxBreakpoint(windowWidth, 'l'), + isXl: isWithinMaxBreakpoint(windowWidth, 'xl') && !isXXL, + isXXL, + }; +} +export function useBreakPoints() { const { width } = useWindowSize(); + const [screenSizes, setScreenSizes] = useState(getScreenSizes(width)); useDebounce( () => { - const windowWidth = window.innerWidth; - - setScreenSizes({ - isSmall: isWithinMaxBreakpoint(windowWidth, 's'), - isMedium: isWithinMaxBreakpoint(windowWidth, 'm'), - isLarge: isWithinMaxBreakpoint(windowWidth, 'l'), - isXl: isWithinMaxBreakpoint(windowWidth, 'xl'), - }); + setScreenSizes(getScreenSizes(width)); }, 50, [width] diff --git a/x-pack/plugins/banners/public/components/banner.tsx b/x-pack/plugins/banners/public/components/banner.tsx index ae28986297659..5a1e20621f3d4 100644 --- a/x-pack/plugins/banners/public/components/banner.tsx +++ b/x-pack/plugins/banners/public/components/banner.tsx @@ -25,7 +25,7 @@ export const Banner: FC = ({ bannerConfig }) => { color: textColor, }} > -
+
diff --git a/x-pack/plugins/canvas/common/index.ts b/x-pack/plugins/canvas/common/index.ts new file mode 100644 index 0000000000000..51a53586dee3c --- /dev/null +++ b/x-pack/plugins/canvas/common/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const UI_SETTINGS = { + ENABLE_LABS_UI: 'labs:canvas:enable_ui', +}; diff --git a/x-pack/plugins/canvas/i18n/components.ts b/x-pack/plugins/canvas/i18n/components.ts index afd3d1408e1f1..b60f8db5b25b4 100644 --- a/x-pack/plugins/canvas/i18n/components.ts +++ b/x-pack/plugins/canvas/i18n/components.ts @@ -514,6 +514,20 @@ export const ComponentStrings = { defaultMessage: 'Keyboard shortcuts', }), }, + LabsControl: { + getLabsButtonLabel: () => + i18n.translate('xpack.canvas.workpadHeaderLabsControlSettings.labsButtonLabel', { + defaultMessage: 'Labs', + }), + getAriaLabel: () => + i18n.translate('xpack.canvas.workpadHeaderLabsControlSettings.labsAriaLabel', { + defaultMessage: 'View labs projects', + }), + getTooltip: () => + i18n.translate('xpack.canvas.workpadHeaderLabsControlSettings.labsTooltip', { + defaultMessage: 'View labs projects', + }), + }, Link: { getErrorMessage: (message: string) => i18n.translate('xpack.canvas.link.errorMessage', { diff --git a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss index 8f5bef8668fbe..15d6b13e3fbf8 100644 --- a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss +++ b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss @@ -5,10 +5,6 @@ body.canvas-isFullscreen { padding-top: 0; } - .headerWrapper ~ .app-wrapper { - min-height: 100vh; - } - // following rule is for docked navigation &.euiBody--collapsibleNavIsDocked { padding-left: 0 !important; // sass-lint:disable-line no-important diff --git a/x-pack/plugins/canvas/public/components/popover/popover.tsx b/x-pack/plugins/canvas/public/components/popover/popover.tsx index 193673932f5fc..275d800fe2ca1 100644 --- a/x-pack/plugins/canvas/public/components/popover/popover.tsx +++ b/x-pack/plugins/canvas/public/components/popover/popover.tsx @@ -86,7 +86,7 @@ export class Popover extends Component { return button(handleClick); }; - const appWrapper = document.querySelector('.app-wrapper'); + const appWrapper = document.querySelector('.kbnAppWrapper'); const EuiPopoverAny = (EuiPopover as any) as React.FC; return ( diff --git a/x-pack/plugins/canvas/public/components/workpad_header/labs_control/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/labs_control/index.ts new file mode 100644 index 0000000000000..fde077e88f86f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/labs_control/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { LabsControl } from './labs_control'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/labs_control/labs_control.tsx b/x-pack/plugins/canvas/public/components/workpad_header/labs_control/labs_control.tsx new file mode 100644 index 0000000000000..eea59e6aa49f3 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/labs_control/labs_control.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { EuiButtonEmpty, EuiNotificationBadge } from '@elastic/eui'; + +import { + LazyLabsFlyout, + withSuspense, +} from '../../../../../../../src/plugins/presentation_util/public'; + +import { ComponentStrings } from '../../../../i18n'; +import { useLabsService } from '../../../services'; +const { LabsControl: strings } = ComponentStrings; + +const Flyout = withSuspense(LazyLabsFlyout, null); + +export const LabsControl = () => { + const { isLabsEnabled, getProjects } = useLabsService(); + const [isShown, setIsShown] = useState(false); + + if (!isLabsEnabled()) { + return null; + } + + const projects = getProjects(['canvas']); + const overrideCount = Object.values(projects).filter((project) => project.status.isOverride) + .length; + + return ( + <> + setIsShown(!isShown)} size="xs"> + {strings.getLabsButtonLabel()} + {overrideCount > 0 ? ( + + {overrideCount} + + ) : null} + + {isShown ? setIsShown(false)} /> : null} + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx index dc9b7a670846b..415d3ddf46709 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx @@ -19,6 +19,7 @@ import { EditMenu } from './edit_menu'; import { ElementMenu } from './element_menu'; import { ShareMenu } from './share_menu'; import { ViewMenu } from './view_menu'; +import { LabsControl } from './labs_control'; import { CommitFn } from '../../../types'; const { WorkpadHeader: strings } = ComponentStrings; @@ -111,6 +112,9 @@ export const WorkpadHeader: FunctionComponent = ({ + + + diff --git a/x-pack/plugins/canvas/public/services/labs.ts b/x-pack/plugins/canvas/public/services/labs.ts index 9bc4bea3e35c3..7f5de8d1e6570 100644 --- a/x-pack/plugins/canvas/public/services/labs.ts +++ b/x-pack/plugins/canvas/public/services/labs.ts @@ -7,23 +7,23 @@ import { projectIDs, - Project, - ProjectID, + PresentationLabsService, } from '../../../../../src/plugins/presentation_util/public'; import { CanvasServiceFactory } from '.'; - -export interface CanvasLabsService { - getProject: (id: ProjectID) => Project; - getProjects: () => Record; +import { UI_SETTINGS } from '../../common'; +export interface CanvasLabsService extends PresentationLabsService { + projectIDs: typeof projectIDs; + isLabsEnabled: () => boolean; } export const labsServiceFactory: CanvasServiceFactory = async ( _coreSetup, - _coreStart, + coreStart, _setupPlugins, startPlugins ) => ({ projectIDs, + isLabsEnabled: () => coreStart.uiSettings.get(UI_SETTINGS.ENABLE_LABS_UI), ...startPlugins.presentationUtil.labsService, }); diff --git a/x-pack/plugins/canvas/public/services/stubs/labs.ts b/x-pack/plugins/canvas/public/services/stubs/labs.ts index 52168ebeb6f80..7caa1d0139a70 100644 --- a/x-pack/plugins/canvas/public/services/stubs/labs.ts +++ b/x-pack/plugins/canvas/public/services/stubs/labs.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { projectIDs } from '../../../../../../src/plugins/presentation_util/public'; import { CanvasLabsService } from '../labs'; const noop = (..._args: any[]): any => {}; @@ -12,4 +13,9 @@ const noop = (..._args: any[]): any => {}; export const labsService: CanvasLabsService = { getProject: noop, getProjects: noop, + getProjectIDs: () => projectIDs, + isLabsEnabled: () => true, + projectIDs, + reset: noop, + setProjectStatus: noop, }; diff --git a/x-pack/plugins/canvas/server/plugin.ts b/x-pack/plugins/canvas/server/plugin.ts index c95d825fb9b0b..9360825830e56 100644 --- a/x-pack/plugins/canvas/server/plugin.ts +++ b/x-pack/plugins/canvas/server/plugin.ts @@ -19,6 +19,7 @@ import { loadSampleData } from './sample_data'; import { setupInterpreter } from './setup_interpreter'; import { customElementType, workpadType, workpadTemplateType } from './saved_objects'; import { initializeTemplates } from './templates'; +import { getUISettings } from './ui_settings'; interface PluginsSetup { expressions: ExpressionsServerSetup; @@ -36,6 +37,7 @@ export class CanvasPlugin implements Plugin { } public setup(coreSetup: CoreSetup, plugins: PluginsSetup) { + coreSetup.uiSettings.register(getUISettings()); coreSetup.savedObjects.registerType(customElementType); coreSetup.savedObjects.registerType(workpadType); coreSetup.savedObjects.registerType(workpadTemplateType); diff --git a/x-pack/plugins/canvas/server/ui_settings.ts b/x-pack/plugins/canvas/server/ui_settings.ts new file mode 100644 index 0000000000000..75c4cc082c557 --- /dev/null +++ b/x-pack/plugins/canvas/server/ui_settings.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; +import { SETTING_CATEGORY } from '../../../../src/plugins/presentation_util/server'; +import { UiSettingsParams } from '../../../../src/core/types'; +import { UI_SETTINGS } from '../common'; + +/** + * uiSettings definitions for Presentation Util. + */ +export const getUISettings = (): Record> => ({ + [UI_SETTINGS.ENABLE_LABS_UI]: { + name: i18n.translate('xpack.canvas.labs.enableUI', { + defaultMessage: 'Enable labs button in Canvas', + }), + description: i18n.translate('xpack.canvas.labs.enableUnifiedToolbarProjectDescription', { + defaultMessage: + 'This flag determines if the viewer has access to the Labs button, a quick way to enable and disable experimental features in Canvas.', + }), + value: false, + type: 'boolean', + schema: schema.boolean(), + category: [SETTING_CATEGORY], + requiresPageReload: true, + }, +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts index b6c68151c9974..8f0c63d46c8e7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts @@ -32,6 +32,7 @@ jest.mock('react-router-dom', () => { useHistory: jest.fn(() => mockHistory), useLocation: jest.fn(() => mockLocation), useParams: jest.fn(() => ({})), + useRouteMatch: jest.fn(() => null), // Note: RR's generatePath() opinionatedly encodeURI()s paths (although this doesn't actually // show up/affect the final browser URL). Since we already have a generateEncodedPath helper & // RR is removing this behavior in history 5.0+, I'm mocking tests to remove the extra encoding diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx index dfca497807718..7d8c1b420378f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx @@ -168,8 +168,7 @@ export const EngineNav: React.FC = () => { )} {canViewMetaEngineSourceEngines && isMetaEngine && ( {ENGINES_TITLE} @@ -236,8 +235,7 @@ export const EngineNav: React.FC = () => { )} {canManageEngineSearchUi && ( {SEARCH_UI_TITLE} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx index d01958942e0a1..9565408f7f47c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx @@ -22,6 +22,8 @@ import { CurationsRouter } from '../curations'; import { EngineOverview } from '../engine_overview'; import { RelevanceTuning } from '../relevance_tuning'; import { ResultSettings } from '../result_settings'; +import { SearchUI } from '../search_ui'; +import { SourceEngines } from '../source_engines'; import { Synonyms } from '../synonyms'; import { EngineRouter } from './engine_router'; @@ -135,4 +137,18 @@ describe('EngineRouter', () => { expect(wrapper.find(ApiLogs)).toHaveLength(1); }); + + it('renders a search ui view', () => { + setMockValues({ ...values, myRole: { canManageEngineSearchUi: true } }); + const wrapper = shallow(); + + expect(wrapper.find(SearchUI)).toHaveLength(1); + }); + + it('renders a source engines view', () => { + setMockValues({ ...values, myRole: { canViewMetaEngineSourceEngines: true } }); + const wrapper = shallow(); + + expect(wrapper.find(SourceEngines)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index c246af3611563..80d1096237345 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -25,12 +25,12 @@ import { ENGINE_DOCUMENT_DETAIL_PATH, // ENGINE_SCHEMA_PATH, // ENGINE_CRAWLER_PATH, - // META_ENGINE_SOURCE_ENGINES_PATH, + META_ENGINE_SOURCE_ENGINES_PATH, ENGINE_RELEVANCE_TUNING_PATH, ENGINE_SYNONYMS_PATH, ENGINE_CURATIONS_PATH, ENGINE_RESULT_SETTINGS_PATH, - // ENGINE_SEARCH_UI_PATH, + ENGINE_SEARCH_UI_PATH, ENGINE_API_LOGS_PATH, } from '../../routes'; import { AnalyticsRouter } from '../analytics'; @@ -40,6 +40,8 @@ import { DocumentDetail, Documents } from '../documents'; import { EngineOverview } from '../engine_overview'; import { RelevanceTuning } from '../relevance_tuning'; import { ResultSettings } from '../result_settings'; +import { SearchUI } from '../search_ui'; +import { SourceEngines } from '../source_engines'; import { Synonyms } from '../synonyms'; import { EngineLogic, getEngineBreadcrumbs } from './'; @@ -51,12 +53,12 @@ export const EngineRouter: React.FC = () => { // canViewEngineDocuments, // canViewEngineSchema, // canViewEngineCrawler, - // canViewMetaEngineSourceEngines, + canViewMetaEngineSourceEngines, canManageEngineRelevanceTuning, canManageEngineSynonyms, canManageEngineCurations, canManageEngineResultSettings, - // canManageEngineSearchUi, + canManageEngineSearchUi, canViewEngineApiLogs, }, } = useValues(AppLogic); @@ -122,6 +124,16 @@ export const EngineRouter: React.FC = () => { )} + {canManageEngineSearchUi && ( + + + + )} + {canViewMetaEngineSourceEngines && ( + + + + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.ts index 04e1ee5c1b61a..3a4c7d51c50a9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.ts @@ -7,12 +7,10 @@ import { kea, MakeLogicType } from 'kea'; -import { Meta } from '../../../../../../../common/types'; import { flashAPIErrors } from '../../../../../shared/flash_messages'; - import { HttpLogic } from '../../../../../shared/http'; - import { EngineDetails } from '../../../engine/types'; +import { EnginesAPIResponse } from '../../types'; interface MetaEnginesTableValues { expandedRows: { [id: string]: boolean }; @@ -30,11 +28,6 @@ interface MetaEnginesTableActions { hideRow(itemId: string): { itemId: string }; } -interface EnginesAPIResponse { - results: EngineDetails[]; - meta: Meta; -} - export const MetaEnginesTableLogic = kea< MakeLogicType >({ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.ts index 282731fda3bd2..36c31f9891f6e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.ts @@ -16,6 +16,7 @@ import { updateMetaPageIndex } from '../../../shared/table_pagination'; import { EngineDetails, EngineTypes } from '../engine/types'; import { DELETE_ENGINE_MESSAGE } from './constants'; +import { EnginesAPIResponse } from './types'; interface EnginesValues { dataLoading: boolean; @@ -27,10 +28,6 @@ interface EnginesValues { metaEnginesLoading: boolean; } -interface EnginesAPIResponse { - results: EngineDetails[]; - meta: Meta; -} interface EnginesActions { deleteEngine(engine: EngineDetails): { engine: EngineDetails }; onDeleteEngineSuccess(engine: EngineDetails): { engine: EngineDetails }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/types.ts new file mode 100644 index 0000000000000..95b507954b8d4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Meta } from '../../../../../common/types'; +import { EngineDetails } from '../engine/types'; + +export interface EnginesAPIResponse { + results: EngineDetails[]; + meta: Meta; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx index b193e00c1d48d..a53e8a099177c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx @@ -7,7 +7,7 @@ import React from 'react'; -import { EuiPageContent } from '@elastic/eui'; +import { EuiPage, EuiPageContent } from '@elastic/eui'; import { ErrorStatePrompt } from '../../../shared/error_state'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; @@ -19,9 +19,11 @@ export const ErrorConnecting: React.FC = () => { - - - + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/index.ts index 054e3cf14a777..f161f891eb4a3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/index.ts @@ -6,3 +6,4 @@ */ export { SEARCH_UI_TITLE } from './constants'; +export { SearchUI } from './search_ui'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx new file mode 100644 index 0000000000000..352ef257dc8a2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../__mocks__/engine_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { SearchUI } from './'; + +describe('SearchUI', () => { + it('renders', () => { + shallow(); + // TODO: Check for form + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx new file mode 100644 index 0000000000000..086769f1556e9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiPageHeader, EuiPageContentBody } from '@elastic/eui'; + +import { FlashMessages } from '../../../shared/flash_messages'; +import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; + +import { getEngineBreadcrumbs } from '../engine'; + +import { SEARCH_UI_TITLE } from './constants'; + +export const SearchUI: React.FC = () => { + return ( + <> + + + + TODO + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/index.ts new file mode 100644 index 0000000000000..5f85fba54d8e7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SourceEngines } from './source_engines'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.test.tsx new file mode 100644 index 0000000000000..4bf62de408a2b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.test.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../__mocks__'; +import '../../__mocks__/engine_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiCodeBlock } from '@elastic/eui'; + +import { Loading } from '../../../shared/loading'; + +import { SourceEngines } from '.'; + +const MOCK_ACTIONS = { + // SourceEnginesLogic + fetchSourceEngines: jest.fn(), +}; + +const MOCK_VALUES = { + dataLoading: false, + sourceEngines: [], +}; + +describe('SourceEngines', () => { + beforeEach(() => { + jest.clearAllMocks(); + setMockActions(MOCK_ACTIONS); + }); + + describe('non-happy-path states', () => { + it('renders a loading component before data has loaded', () => { + setMockValues({ ...MOCK_VALUES, dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + }); + + describe('happy-path states', () => { + it('renders and calls a function to initialize data', () => { + setMockValues(MOCK_VALUES); + const wrapper = shallow(); + + expect(wrapper.find(EuiCodeBlock)).toHaveLength(1); + expect(MOCK_ACTIONS.fetchSourceEngines).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.tsx new file mode 100644 index 0000000000000..0b68eb5fd2c2e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiCodeBlock, EuiPageHeader } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { FlashMessages } from '../../../shared/flash_messages'; +import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { Loading } from '../../../shared/loading'; +import { getEngineBreadcrumbs } from '../engine'; + +import { SourceEnginesLogic } from './source_engines_logic'; + +const SOURCE_ENGINES_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.souceEngines.title', + { + defaultMessage: 'Manage engines', + } +); + +export const SourceEngines: React.FC = () => { + const { fetchSourceEngines } = useActions(SourceEnginesLogic); + const { dataLoading, sourceEngines } = useValues(SourceEnginesLogic); + + useEffect(() => { + fetchSourceEngines(); + }, []); + + if (dataLoading) return ; + + return ( + <> + + + + {JSON.stringify(sourceEngines, null, 2)} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.test.ts new file mode 100644 index 0000000000000..df1165620adc3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__'; +import '../../__mocks__/engine_logic.mock'; + +import { nextTick } from '@kbn/test/jest'; + +import { EngineDetails } from '../engine/types'; + +import { SourceEnginesLogic } from './source_engines_logic'; + +const DEFAULT_VALUES = { + dataLoading: true, + sourceEngines: [], +}; + +describe('SourceEnginesLogic', () => { + const { http } = mockHttpValues; + const { mount } = new LogicMounter(SourceEnginesLogic); + const { flashAPIErrors } = mockFlashMessageHelpers; + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('initializes with default values', () => { + expect(SourceEnginesLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('setSourceEngines', () => { + beforeEach(() => { + SourceEnginesLogic.actions.onSourceEnginesFetch([ + { name: 'source-engine-1' }, + { name: 'source-engine-2' }, + ] as EngineDetails[]); + }); + + it('sets the source engines', () => { + expect(SourceEnginesLogic.values.sourceEngines).toEqual([ + { name: 'source-engine-1' }, + { name: 'source-engine-2' }, + ]); + }); + + it('sets dataLoading to false', () => { + expect(SourceEnginesLogic.values.dataLoading).toEqual(false); + }); + }); + + describe('fetchSourceEngines', () => { + it('calls addSourceEngines and displayRow when it has retrieved all pages', async () => { + http.get.mockReturnValueOnce( + Promise.resolve({ + meta: { + page: { + total_pages: 1, + }, + }, + results: [{ name: 'source-engine-1' }, { name: 'source-engine-2' }], + }) + ); + jest.spyOn(SourceEnginesLogic.actions, 'onSourceEnginesFetch'); + + SourceEnginesLogic.actions.fetchSourceEngines(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/source_engines', { + query: { + 'page[current]': 1, + 'page[size]': 25, + }, + }); + expect(SourceEnginesLogic.actions.onSourceEnginesFetch).toHaveBeenCalledWith([ + { name: 'source-engine-1' }, + { name: 'source-engine-2' }, + ]); + }); + + it('display a flash message on error', async () => { + http.get.mockReturnValueOnce(Promise.reject()); + mount(); + + SourceEnginesLogic.actions.fetchSourceEngines(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledTimes(1); + }); + + it('recursively fetches a number of pages', async () => { + mount(); + jest.spyOn(SourceEnginesLogic.actions, 'onSourceEnginesFetch'); + + // First page + http.get.mockReturnValueOnce( + Promise.resolve({ + meta: { + page: { + total_pages: 2, + }, + }, + results: [{ name: 'source-engine-1' }], + }) + ); + + // Second and final page + http.get.mockReturnValueOnce( + Promise.resolve({ + meta: { + page: { + total_pages: 2, + }, + }, + results: [{ name: 'source-engine-2' }], + }) + ); + + SourceEnginesLogic.actions.fetchSourceEngines(); + await nextTick(); + + expect(SourceEnginesLogic.actions.onSourceEnginesFetch).toHaveBeenCalledWith([ + // First page + { name: 'source-engine-1' }, + // Second and final page + { name: 'source-engine-2' }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.ts new file mode 100644 index 0000000000000..b8a5c7c359518 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines_logic.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors } from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; +import { EngineLogic } from '../engine'; +import { EngineDetails } from '../engine/types'; +import { EnginesAPIResponse } from '../engines/types'; + +interface SourceEnginesLogicValues { + dataLoading: boolean; + sourceEngines: EngineDetails[]; +} + +interface SourceEnginesLogicActions { + fetchSourceEngines: () => void; + onSourceEnginesFetch: ( + sourceEngines: SourceEnginesLogicValues['sourceEngines'] + ) => { sourceEngines: SourceEnginesLogicValues['sourceEngines'] }; +} + +export const SourceEnginesLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'app_search', 'source_engines_logic'], + actions: () => ({ + fetchSourceEngines: true, + onSourceEnginesFetch: (sourceEngines) => ({ sourceEngines }), + }), + reducers: () => ({ + dataLoading: [ + true, + { + onSourceEnginesFetch: () => false, + }, + ], + sourceEngines: [ + [], + { + onSourceEnginesFetch: (_, { sourceEngines }) => sourceEngines, + }, + ], + }), + listeners: ({ actions }) => ({ + fetchSourceEngines: () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + let enginesAccumulator: EngineDetails[] = []; + + // We need to recursively fetch all source engines because we put the data + // into an EuiInMemoryTable to enable searching + const recursiveFetchSourceEngines = async (page = 1) => { + try { + const { meta, results }: EnginesAPIResponse = await http.get( + `/api/app_search/engines/${engineName}/source_engines`, + { + query: { + 'page[current]': page, + 'page[size]': 25, + }, + } + ); + + enginesAccumulator = [...enginesAccumulator, ...results]; + + if (page >= meta.page.total_pages) { + actions.onSourceEnginesFetch(enginesAccumulator); + } else { + recursiveFetchSourceEngines(page + 1); + } + } catch (e) { + flashAPIErrors(e); + } + }; + + recursiveFetchSourceEngines(); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 62a0ccc01f29a..d26838335d8f6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -6,12 +6,13 @@ */ import { DEFAULT_INITIAL_APP_DATA } from '../../../common/__mocks__'; -import '../__mocks__/enterprise_search_url.mock'; import { setMockValues, rerender } from '../__mocks__'; +import '../__mocks__/enterprise_search_url.mock'; +import '../__mocks__/react_router_history.mock'; import React from 'react'; -import { Redirect } from 'react-router-dom'; +import { Redirect, useRouteMatch } from 'react-router-dom'; import { shallow, ShallowWrapper } from 'enzyme'; @@ -20,7 +21,7 @@ import { Layout, SideNav, SideNavLink } from '../shared/layout'; jest.mock('./app_logic', () => ({ AppLogic: jest.fn() })); import { AppLogic } from './app_logic'; -import { EngineRouter } from './components/engine'; +import { EngineRouter, EngineNav } from './components/engine'; import { EngineCreation } from './components/engine_creation'; import { EnginesOverview } from './components/engines'; import { ErrorConnecting } from './components/error_connecting'; @@ -31,6 +32,12 @@ import { SetupGuide } from './components/setup_guide'; import { AppSearch, AppSearchUnconfigured, AppSearchConfigured, AppSearchNav } from './'; describe('AppSearch', () => { + it('always renders the Setup Guide', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuide)).toHaveLength(1); + }); + it('renders AppSearchUnconfigured when config.host is not set', () => { setMockValues({ config: { host: '' } }); const wrapper = shallow(); @@ -38,8 +45,15 @@ describe('AppSearch', () => { expect(wrapper.find(AppSearchUnconfigured)).toHaveLength(1); }); - it('renders AppSearchConfigured when config.host set', () => { - setMockValues({ config: { host: 'some.url' } }); + it('renders ErrorConnecting when Enterprise Search is unavailable', () => { + setMockValues({ errorConnecting: true }); + const wrapper = shallow(); + + expect(wrapper.find(ErrorConnecting)).toHaveLength(1); + }); + + it('renders AppSearchConfigured when config.host is set & available', () => { + setMockValues({ errorConnecting: false, config: { host: 'some.url' } }); const wrapper = shallow(); expect(wrapper.find(AppSearchConfigured)).toHaveLength(1); @@ -47,10 +61,9 @@ describe('AppSearch', () => { }); describe('AppSearchUnconfigured', () => { - it('renders the Setup Guide and redirects to the Setup Guide', () => { + it('redirects to the Setup Guide', () => { const wrapper = shallow(); - expect(wrapper.find(SetupGuide)).toHaveLength(1); expect(wrapper.find(Redirect)).toHaveLength(1); }); }); @@ -64,8 +77,8 @@ describe('AppSearchConfigured', () => { }); it('renders with layout', () => { - expect(wrapper.find(Layout)).toHaveLength(2); - expect(wrapper.find(Layout).last().prop('readOnlyMode')).toBeFalsy(); + expect(wrapper.find(Layout)).toHaveLength(1); + expect(wrapper.find(Layout).prop('readOnlyMode')).toBeFalsy(); expect(wrapper.find(EnginesOverview)).toHaveLength(1); expect(wrapper.find(EngineRouter)).toHaveLength(1); }); @@ -74,13 +87,6 @@ describe('AppSearchConfigured', () => { expect(AppLogic).toHaveBeenCalledWith(DEFAULT_INITIAL_APP_DATA); }); - it('renders ErrorConnecting', () => { - setMockValues({ myRole: {}, errorConnecting: true }); - rerender(wrapper); - - expect(wrapper.find(ErrorConnecting)).toHaveLength(1); - }); - it('passes readOnlyMode state', () => { setMockValues({ myRole: {}, readOnlyMode: true }); rerender(wrapper); @@ -145,11 +151,22 @@ describe('AppSearchNav', () => { expect(wrapper.find(SideNavLink).prop('to')).toEqual('/engines'); }); - it('renders an Engine subnav if passed', () => { - const wrapper = shallow(Testing
} />); - const link = wrapper.find(SideNavLink).dive(); + describe('engine subnavigation', () => { + const getEnginesLink = (wrapper: ShallowWrapper) => wrapper.find(SideNavLink).dive(); - expect(link.find('[data-test-subj="subnav"]')).toHaveLength(1); + it('does not render the engine subnav on top-level routes', () => { + (useRouteMatch as jest.Mock).mockReturnValueOnce(false); + const wrapper = shallow(); + + expect(getEnginesLink(wrapper).find(EngineNav)).toHaveLength(0); + }); + + it('renders the engine subnav if currently on an engine route', () => { + (useRouteMatch as jest.Mock).mockReturnValueOnce(true); + const wrapper = shallow(); + + expect(getEnginesLink(wrapper).find(EngineNav)).toHaveLength(1); + }); }); it('renders the Settings link', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 3a46a90d20d66..0b87321d87535 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { Route, Redirect, Switch } from 'react-router-dom'; +import { Route, Redirect, Switch, useRouteMatch } from 'react-router-dom'; import { useValues } from 'kea'; @@ -45,18 +45,28 @@ import { export const AppSearch: React.FC = (props) => { const { config } = useValues(KibanaLogic); - return !config.host ? ( - - ) : ( - )} /> + const { errorConnecting } = useValues(HttpLogic); + + return ( + + + + + + {!config.host ? ( + + ) : errorConnecting ? ( + + ) : ( + )} /> + )} + + ); }; export const AppSearchUnconfigured: React.FC = () => ( - - - @@ -67,79 +77,68 @@ export const AppSearchConfigured: React.FC> = (props) = const { myRole: { canManageEngines, canManageMetaEngines, canViewRoleMappings }, } = useValues(AppLogic(props)); - const { errorConnecting, readOnlyMode } = useValues(HttpLogic); + const { readOnlyMode } = useValues(HttpLogic); return ( - - - {process.env.NODE_ENV === 'development' && ( )} - - } />} readOnlyMode={readOnlyMode}> - - - } readOnlyMode={readOnlyMode}> - {errorConnecting ? ( - - ) : ( - - - + + + + + + + + + + + + + + + + + {canViewRoleMappings && ( + + - - + )} + {canManageEngines && ( + + - - + )} + {canManageMetaEngines && ( + + - - - - {canViewRoleMappings && ( - - - - )} - {canManageEngines && ( - - - - )} - {canManageMetaEngines && ( - - - - )} - - - - - )} + )} + + + + ); }; -interface AppSearchNavProps { - subNav?: React.ReactNode; -} - -export const AppSearchNav: React.FC = ({ subNav }) => { +export const AppSearchNav: React.FC = () => { const { myRole: { canViewSettings, canViewAccountCredentials, canViewRoleMappings }, } = useValues(AppLogic); + const isEngineRoute = !!useRouteMatch(ENGINE_PATH); + return ( - + : null} isRoot> {ENGINES_TITLE} {canViewSettings && {SETTINGS_TITLE}} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/index.ts new file mode 100644 index 0000000000000..e6caa5c3a7642 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { StatusItem } from './status_item'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.test.tsx new file mode 100644 index 0000000000000..c1c18b51f9fd3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiPopover, EuiCopy, EuiButton, EuiButtonIcon } from '@elastic/eui'; + +import { StatusItem } from './'; + +describe('SourceRow', () => { + const details = ['foo', 'bar']; + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiPopover)).toHaveLength(1); + }); + + it('renders the copy button', () => { + const copyMock = jest.fn(); + const wrapper = shallow(); + const copyEl = shallow(
{wrapper.find(EuiCopy).props().children(copyMock)}
); + + expect(copyEl.find(EuiButton).props().onClick).toEqual(copyMock); + }); + + it('handles popover visibility toggle click', () => { + const wrapper = shallow(); + const button = wrapper.find(EuiPopover).dive().find(EuiButtonIcon); + button.simulate('click'); + + expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(true); + + wrapper.find(EuiPopover).prop('closePopover')(); + + expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.tsx new file mode 100644 index 0000000000000..79455ccc1d90d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; + +import { + EuiCopy, + EuiButton, + EuiButtonIcon, + EuiToolTip, + EuiSpacer, + EuiCodeBlock, + EuiPopover, +} from '@elastic/eui'; + +import { COPY_TEXT, STATUS_POPOVER_TOOLTIP } from '../../../constants'; + +interface StatusItemProps { + details: string[]; +} + +export const StatusItem: React.FC = ({ details }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const onButtonClick = () => setIsPopoverOpen((isOpen) => !isOpen); + const closePopover = () => setIsPopoverOpen(false); + const formattedDetails = details.join('\n'); + + const tooltipPopoverTrigger = ( + + + + ); + + const infoPopover = ( + + + {formattedDetails} + + + + {(copy) => ( + + {COPY_TEXT} + + )} + + + ); + + return infoPopover; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index 9f758cacdfce3..dcebc35d45f71 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -751,3 +751,14 @@ export const REMOVE_BUTTON = i18n.translate( defaultMessage: 'Remove', } ); + +export const COPY_TEXT = i18n.translate('xpack.enterpriseSearch.workplaceSearch.copyText', { + defaultMessage: 'Copy', +}); + +export const STATUS_POPOVER_TOOLTIP = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.statusPopoverTooltip', + { + defaultMessage: 'Click to view info', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx index 8186c43efef49..ee4bcfb9afd34 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx @@ -6,17 +6,17 @@ */ import React, { useEffect } from 'react'; -import { useLocation } from 'react-router-dom'; - -import { Location } from 'history'; import { useActions, useValues } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { setSuccessMessage } from '../../../../../shared/flash_messages'; import { KibanaLogic } from '../../../../../shared/kibana'; import { Loading } from '../../../../../shared/loading'; import { AppLogic } from '../../../../app_logic'; import { CUSTOM_SERVICE_TYPE } from '../../../../constants'; -import { SOURCE_ADDED_PATH, getSourcesPath } from '../../../../routes'; +import { SOURCES_PATH, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; import { staticSourceData } from '../../source_data'; @@ -34,7 +34,6 @@ import { SaveCustom } from './save_custom'; import './add_source.scss'; export const AddSource: React.FC = (props) => { - const { search } = useLocation() as Location; const { initializeAddSource, setAddSourceStep, @@ -78,6 +77,13 @@ export const AddSource: React.FC = (props) => { const goToSaveConfig = () => setAddSourceStep(AddSourceSteps.SaveConfigStep); const setConfigCompletedStep = () => setAddSourceStep(AddSourceSteps.ConfigCompletedStep); const goToConfigCompleted = () => saveSourceConfig(false, setConfigCompletedStep); + const FORM_SOURCE_ADDED_SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.formSourceAddedSuccessMessage', + { + defaultMessage: '{name} connected', + values: { name }, + } + ); const goToConnectInstance = () => { setAddSourceStep(AddSourceSteps.ConnectInstanceStep); @@ -88,9 +94,8 @@ export const AddSource: React.FC = (props) => { const goToSaveCustom = () => createContentSource(CUSTOM_SERVICE_TYPE, saveCustomSuccess); const goToFormSourceCreated = () => { - KibanaLogic.values.navigateToUrl( - `${getSourcesPath(SOURCE_ADDED_PATH, isOrganization)}${search}` - ); + KibanaLogic.values.navigateToUrl(`${getSourcesPath(SOURCES_PATH, isOrganization)}`); + setSuccessMessage(FORM_SOURCE_ADDED_SUCCESS_MESSAGE); }; const header = ; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index 86c911e7e0b00..153df1bc00496 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -14,7 +14,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, - EuiIconTip, EuiLink, EuiPanel, EuiSpacer, @@ -37,6 +36,7 @@ import aclImage from '../../../assets/supports_acl.svg'; import { ComponentLoader } from '../../../components/shared/component_loader'; import { CredentialItem } from '../../../components/shared/credential_item'; import { LicenseBadge } from '../../../components/shared/license_badge'; +import { StatusItem } from '../../../components/shared/status_item'; import { ViewContentHeader } from '../../../components/shared/view_content_header'; import { RECENT_ACTIVITY_TITLE, @@ -199,15 +199,7 @@ export const Overview: React.FC = () => { {!custom && ( - {status}{' '} - {activityDetails && ( - ( -
{detail}
- ))} - /> - )} + {status} {activityDetails && }
)} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index 4bc623ac9fdf8..aa6cbf3cf6574 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -36,6 +36,7 @@ import { import { SourceDataItem } from '../../../types'; import { AddSourceLogic } from '../components/add_source/add_source_logic'; import { + SOURCE_SETTINGS_HEADING, SOURCE_SETTINGS_TITLE, SOURCE_SETTINGS_DESCRIPTION, SOURCE_NAME_LABEL, @@ -128,7 +129,7 @@ export const SourceSettings: React.FC = () => { return ( <> - +
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts index 32df63d0faba9..78722bf766961 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts @@ -257,6 +257,13 @@ export const READY_TEXT = i18n.translate( } ); +export const SOURCE_SETTINGS_HEADING = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.settings.heading', + { + defaultMessage: 'Settings', + } +); + export const SOURCE_SETTINGS_TITLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.sources.settings.title', { @@ -295,7 +302,7 @@ export const SOURCE_CONFIG_LINK = i18n.translate( export const SOURCE_REMOVE_TITLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.sources.remove.title', { - defaultMessage: 'Remove this source', + defaultMessage: 'Remove this content source', } ); diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index dd1a62d243d03..e402d233da58d 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -116,7 +116,7 @@ export class EnterpriseSearchPlugin implements Plugin { // The Workplace Search Personal dashboard needs the chrome hidden. We hide it globally // here first to prevent a flash of chrome on the Personal dashboard and unhide it for admin routes. - chrome.setIsVisible(false); + if (this.config.host) chrome.setIsVisible(false); await this.getInitialData(http); const pluginData = this.getPluginData(); diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/_index.scss b/x-pack/plugins/file_data_visualizer/public/application/components/_index.scss index a7c3926407ea0..a47f3712cbb64 100644 --- a/x-pack/plugins/file_data_visualizer/public/application/components/_index.scss +++ b/x-pack/plugins/file_data_visualizer/public/application/components/_index.scss @@ -1,6 +1,11 @@ -@import 'file_datavisualizer_view/index'; -@import 'results_view/index'; -@import 'analysis_summary/index'; @import 'about_panel/index'; -@import 'import_summary/index'; +@import 'analysis_summary/index'; +@import 'edit_flyout/index'; +@import 'embedded_map/index'; @import 'experimental_badge/index'; +@import 'file_contents/index'; +@import 'file_datavisualizer_view/index'; +@import 'import_summary/index'; +@import 'results_view/index'; +@import 'stats_table/index'; +@import 'top_values/top_values'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config_flyout.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config_flyout.tsx index a5d05bb06f78e..c2b7e18059769 100644 --- a/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config_flyout.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config_flyout.tsx @@ -104,7 +104,7 @@ const Contents: FC<{ username: string | null; }> = ({ value, index, username }) => { return ( - +
= ({ } + data-test-subj="fileDataVisFilebeatConfigLink" title={ ; + getIndexNameFormComponent(): Promise>; importerFactory: typeof importerFactory; getMaxBytes: typeof getMaxBytes; getMaxBytesFormatted: typeof getMaxBytesFormatted; @@ -35,6 +37,13 @@ export async function getFileUploadComponent(): Promise< return fileUploadModules.JsonUploadAndParse; } +export async function getIndexNameFormComponent(): Promise< + React.ComponentType +> { + const fileUploadModules = await lazyLoadModules(); + return fileUploadModules.IndexNameForm; +} + export async function importerFactory( format: string, options: ImportFactoryOptions diff --git a/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx index 7ac0685e57700..65866243a3e47 100644 --- a/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx @@ -6,16 +6,12 @@ */ import React, { ChangeEvent, Component } from 'react'; -import { EuiForm, EuiFormRow, EuiFieldText, EuiSelect, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { EuiForm, EuiFormRow, EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { GeoJsonFilePicker, OnFileSelectParameters } from './geojson_file_picker'; import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/public'; -import { - getExistingIndexNames, - getExistingIndexPatternNames, - checkIndexPatternValid, - // @ts-expect-error -} from '../../util/indexing_service'; +import { IndexNameForm } from './index_name_form'; +import { validateIndexName } from '../../util/indexing_service'; const GEO_FIELD_TYPE_OPTIONS = [ { @@ -41,38 +37,15 @@ interface Props { interface State { hasFile: boolean; isPointsOnly: boolean; - indexNames: string[]; } export class GeoJsonUploadForm extends Component { - private _isMounted = false; - state: State = { hasFile: false, isPointsOnly: false, - indexNames: [], }; - async componentDidMount() { - this._isMounted = true; - this._loadIndexNames(); - } - - componentWillUnmount() { - this._isMounted = false; - } - - _loadIndexNames = async () => { - const indexNameList = await getExistingIndexNames(); - const indexPatternList = await getExistingIndexPatternNames(); - if (this._isMounted) { - this.setState({ - indexNames: [...indexNameList, ...indexPatternList], - }); - } - }; - - _onFileSelect = (onFileSelectParameters: OnFileSelectParameters) => { + _onFileSelect = async (onFileSelectParameters: OnFileSelectParameters) => { this.setState({ hasFile: true, isPointsOnly: onFileSelectParameters.hasPoints && !onFileSelectParameters.hasShapes, @@ -80,7 +53,8 @@ export class GeoJsonUploadForm extends Component { this.props.onFileSelect(onFileSelectParameters); - this._onIndexNameChange(onFileSelectParameters.indexName); + const indexNameError = await validateIndexName(onFileSelectParameters.indexName); + this.props.onIndexNameChange(onFileSelectParameters.indexName, indexNameError); const geoFieldType = onFileSelectParameters.hasPoints && !onFileSelectParameters.hasShapes @@ -97,7 +71,7 @@ export class GeoJsonUploadForm extends Component { this.props.onFileClear(); - this._onIndexNameChange(''); + this.props.onIndexNameChange(''); }; _onGeoFieldTypeSelect = (event: ChangeEvent) => { @@ -106,28 +80,6 @@ export class GeoJsonUploadForm extends Component { ); }; - _onIndexNameChange = (name: string) => { - let error: string | undefined; - if (this.state.indexNames.includes(name)) { - error = i18n.translate('xpack.fileUpload.indexSettings.indexNameAlreadyExistsErrorMessage', { - defaultMessage: 'Index name already exists.', - }); - } else if (!checkIndexPatternValid(name)) { - error = i18n.translate( - 'xpack.fileUpload.indexSettings.indexNameContainsIllegalCharactersErrorMessage', - { - defaultMessage: 'Index name contains illegal characters.', - } - ); - } - - this.props.onIndexNameChange(name, error); - }; - - _onIndexNameChangeEvent = (event: ChangeEvent) => { - this._onIndexNameChange(event.target.value); - }; - _renderGeoFieldTypeSelect() { return this.state.hasFile && this.state.isPointsOnly ? ( { ) : null; } - _renderIndexNameInput() { - const isInvalid = this.props.indexNameError !== undefined; - return this.state.hasFile ? ( - <> - - - - - -
    -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.mustBeNewIndex', { - defaultMessage: 'Must be a new index', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.lowercaseOnly', { - defaultMessage: 'Lowercase only', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotInclude', { - defaultMessage: - 'Cannot include \\\\, /, *, ?, ", <, >, |, \ - " " (space character), , (comma), #', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotStartWith', { - defaultMessage: 'Cannot start with -, _, +', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotBe', { - defaultMessage: 'Cannot be . or ..', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.length', { - defaultMessage: - 'Cannot be longer than 255 bytes (note it is bytes, \ - so multi-byte characters will count towards the 255 \ - limit faster)', - })} -
  • -
-
- - ) : null; - } - render() { return ( {this._renderGeoFieldTypeSelect()} - {this._renderIndexNameInput()} + {this.state.hasFile ? ( + + ) : null} ); } diff --git a/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx b/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx new file mode 100644 index 0000000000000..a6e83cfa6f3ab --- /dev/null +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ChangeEvent, Component } from 'react'; +import { EuiFormRow, EuiFieldText, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { validateIndexName } from '../../util/indexing_service'; + +export interface Props { + indexName: string; + indexNameError?: string; + onIndexNameChange: (name: string, error?: string) => void; +} + +export class IndexNameForm extends Component { + _onIndexNameChange = async (event: ChangeEvent) => { + const indexName = event.target.value; + const indexNameError = await validateIndexName(indexName); + this.props.onIndexNameChange(indexName, indexNameError); + }; + + render() { + const errors = [...(this.props.indexNameError ? [this.props.indexNameError] : [])]; + + return ( + <> + + + + + +
    +
  • + {i18n.translate('xpack.fileUpload.indexNameForm.guidelines.mustBeNewIndex', { + defaultMessage: 'Must be a new index', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexNameForm.guidelines.lowercaseOnly', { + defaultMessage: 'Lowercase only', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexNameForm.guidelines.cannotInclude', { + defaultMessage: + 'Cannot include \\\\, /, *, ?, ", <, >, |, \ + " " (space character), , (comma), #', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexNameForm.guidelines.cannotStartWith', { + defaultMessage: 'Cannot start with -, _, +', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexNameForm.guidelines.cannotBe', { + defaultMessage: 'Cannot be . or ..', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexNameForm.guidelines.length', { + defaultMessage: + 'Cannot be longer than 255 bytes (note it is bytes, \ + so multi-byte characters will count towards the 255 \ + limit faster)', + })} +
  • +
+
+ + ); + } +} diff --git a/x-pack/plugins/file_upload/public/index.ts b/x-pack/plugins/file_upload/public/index.ts index 792568e9c11ad..262e399242291 100644 --- a/x-pack/plugins/file_upload/public/index.ts +++ b/x-pack/plugins/file_upload/public/index.ts @@ -13,5 +13,7 @@ export function plugin() { export * from './importer/types'; +export { Props as IndexNameFormProps } from './components/geojson_upload_form/index_name_form'; + export { FileUploadPluginStart } from './plugin'; export { FileUploadComponentProps, FileUploadGeoResults } from './lazy_load_bundle'; diff --git a/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts b/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts index 9d89b6b761e25..c2bc36e3cc450 100644 --- a/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts +++ b/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts @@ -11,6 +11,7 @@ import { HttpStart } from 'src/core/public'; import { IImporter, ImportFactoryOptions } from '../importer'; import { getHttp } from '../kibana_services'; import { ES_FIELD_TYPES } from '../../../../../src/plugins/data/public'; +import { IndexNameFormProps } from '../'; export interface FileUploadGeoResults { indexPatternId: string; @@ -32,6 +33,7 @@ let loadModulesPromise: Promise; interface LazyLoadedFileUploadModules { JsonUploadAndParse: React.ComponentType; + IndexNameForm: React.ComponentType; importerFactory: (format: string, options: ImportFactoryOptions) => IImporter | undefined; getHttp: () => HttpStart; } @@ -42,12 +44,13 @@ export async function lazyLoadModules(): Promise { } loadModulesPromise = new Promise(async (resolve) => { - const { JsonUploadAndParse, importerFactory } = await import('./lazy'); + const { JsonUploadAndParse, importerFactory, IndexNameForm } = await import('./lazy'); resolve({ JsonUploadAndParse, importerFactory, getHttp, + IndexNameForm, }); }); return loadModulesPromise; diff --git a/x-pack/plugins/file_upload/public/lazy_load_bundle/lazy/index.ts b/x-pack/plugins/file_upload/public/lazy_load_bundle/lazy/index.ts index 0a28e9e4dfc93..85333227a36d8 100644 --- a/x-pack/plugins/file_upload/public/lazy_load_bundle/lazy/index.ts +++ b/x-pack/plugins/file_upload/public/lazy_load_bundle/lazy/index.ts @@ -6,4 +6,5 @@ */ export { JsonUploadAndParse } from '../../components/json_upload_and_parse'; +export { IndexNameForm } from '../../components/geojson_upload_form/index_name_form'; export { importerFactory } from '../../importer'; diff --git a/x-pack/plugins/file_upload/public/plugin.ts b/x-pack/plugins/file_upload/public/plugin.ts index 19306fadfd61c..6240dbe39a85e 100644 --- a/x-pack/plugins/file_upload/public/plugin.ts +++ b/x-pack/plugins/file_upload/public/plugin.ts @@ -11,6 +11,7 @@ import { getFileUploadComponent, importerFactory, hasImportPermission, + getIndexNameFormComponent, checkIndexExists, getTimeFieldRange, analyzeFile, @@ -42,6 +43,7 @@ export class FileUploadPlugin setStartServices(core, plugins); return { getFileUploadComponent, + getIndexNameFormComponent, importerFactory, getMaxBytes, getMaxBytesFormatted, diff --git a/x-pack/plugins/file_upload/public/util/http_service.js b/x-pack/plugins/file_upload/public/util/http_service.js deleted file mode 100644 index 33afebc514c36..0000000000000 --- a/x-pack/plugins/file_upload/public/util/http_service.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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { getHttp } from '../kibana_services'; - -export async function http(options) { - if (!(options && options.url)) { - throw i18n.translate('xpack.fileUpload.httpService.noUrl', { - defaultMessage: 'No URL provided', - }); - } - const url = options.url || ''; - const headers = { - 'Content-Type': 'application/json', - ...options.headers, - }; - - const allHeaders = options.headers === undefined ? headers : { ...options.headers, ...headers }; - const body = options.data === undefined ? null : JSON.stringify(options.data); - - const payload = { - method: options.method || 'GET', - headers: allHeaders, - credentials: 'same-origin', - query: options.query, - }; - - if (body !== null) { - payload.body = body; - } - return await doFetch(url, payload); -} - -async function doFetch(url, payload) { - try { - return await getHttp().fetch(url, payload); - } catch (err) { - return { - failures: [ - i18n.translate('xpack.fileUpload.httpService.fetchError', { - defaultMessage: 'Error performing fetch: {error}', - values: { error: err.message }, - }), - ], - }; - } -} diff --git a/x-pack/plugins/file_upload/public/util/indexing_service.js b/x-pack/plugins/file_upload/public/util/indexing_service.js deleted file mode 100644 index cb9bc9a2e1ce6..0000000000000 --- a/x-pack/plugins/file_upload/public/util/indexing_service.js +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { http as httpService } from './http_service'; -import { getSavedObjectsClient } from '../kibana_services'; - -export const getExistingIndexNames = async () => { - const indexes = await httpService({ - url: `/api/index_management/indices`, - method: 'GET', - }); - return indexes ? indexes.map(({ name }) => name) : []; -}; - -export const getExistingIndexPatternNames = async () => { - const indexPatterns = await getSavedObjectsClient() - .find({ - type: 'index-pattern', - fields: ['id', 'title', 'type', 'fields'], - perPage: 10000, - }) - .then(({ savedObjects }) => savedObjects.map((savedObject) => savedObject.get('title'))); - return indexPatterns ? indexPatterns.map(({ name }) => name) : []; -}; - -export function checkIndexPatternValid(name) { - const byteLength = encodeURI(name).split(/%(?:u[0-9A-F]{2})?[0-9A-F]{2}|./).length - 1; - const reg = new RegExp('[\\\\/*?"<>|\\s,#]+'); - const indexPatternInvalid = - byteLength > 255 || // name can't be greater than 255 bytes - name !== name.toLowerCase() || // name should be lowercase - name === '.' || - name === '..' || // name can't be . or .. - name.match(/^[-_+]/) !== null || // name can't start with these chars - name.match(reg) !== null; // name can't contain these chars - return !indexPatternInvalid; -} diff --git a/x-pack/plugins/file_upload/public/util/indexing_service.test.js b/x-pack/plugins/file_upload/public/util/indexing_service.test.ts similarity index 100% rename from x-pack/plugins/file_upload/public/util/indexing_service.test.js rename to x-pack/plugins/file_upload/public/util/indexing_service.test.ts diff --git a/x-pack/plugins/file_upload/public/util/indexing_service.ts b/x-pack/plugins/file_upload/public/util/indexing_service.ts new file mode 100644 index 0000000000000..4dcff3dbe7f0e --- /dev/null +++ b/x-pack/plugins/file_upload/public/util/indexing_service.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import _ from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { getIndexPatternService, getHttp } from '../kibana_services'; + +export const getExistingIndexNames = _.debounce( + async () => { + let indexes; + try { + indexes = await getHttp().fetch({ + path: `/api/index_management/indices`, + method: 'GET', + }); + } catch (e) { + // Log to console. Further diagnostics can be made in network request + // eslint-disable-next-line no-console + console.error(e); + } + return indexes ? indexes.map(({ name }: { name: string }) => name) : []; + }, + 10000, + { leading: true } +); + +export function checkIndexPatternValid(name: string) { + const byteLength = encodeURI(name).split(/%(?:u[0-9A-F]{2})?[0-9A-F]{2}|./).length - 1; + const reg = new RegExp('[\\\\/*?"<>|\\s,#]+'); + const indexPatternInvalid = + byteLength > 255 || // name can't be greater than 255 bytes + name !== name.toLowerCase() || // name should be lowercase + name === '.' || + name === '..' || // name can't be . or .. + name.match(/^[-_+]/) !== null || // name can't start with these chars + name.match(reg) !== null; // name can't contain these chars + return !indexPatternInvalid; +} + +export const validateIndexName = async (indexName: string) => { + if (!checkIndexPatternValid(indexName)) { + return i18n.translate( + 'xpack.fileUpload.util.indexingService.indexNameContainsIllegalCharactersErrorMessage', + { + defaultMessage: 'Index name contains illegal characters.', + } + ); + } + + const indexNames = await getExistingIndexNames(); + const indexPatternNames = await getIndexPatternService().getTitles(); + let indexNameError; + if (indexNames.includes(indexName)) { + indexNameError = i18n.translate( + 'xpack.fileUpload.util.indexingService.indexNameAlreadyExistsErrorMessage', + { + defaultMessage: 'Index name already exists.', + } + ); + } else if (indexPatternNames.includes(indexName)) { + indexNameError = i18n.translate( + 'xpack.fileUpload.util.indexingService.indexPatternAlreadyExistsErrorMessage', + { + defaultMessage: 'Index pattern already exists.', + } + ); + } + return indexNameError; +}; diff --git a/x-pack/plugins/fleet/common/types/models/agent_policy.ts b/x-pack/plugins/fleet/common/types/models/agent_policy.ts index 439d00695a737..a8e1f6ce584d4 100644 --- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts @@ -21,7 +21,7 @@ export interface NewAgentPolicy { is_default_fleet_server?: boolean; // Optional when creating a policy is_managed?: boolean; // Optional when creating a policy monitoring_enabled?: Array>; - preconfiguration_id?: string; // Uniqifies preconfigured policies by something other than `name` + is_preconfigured?: boolean; } export interface AgentPolicy extends NewAgentPolicy { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.test.tsx new file mode 100644 index 0000000000000..e4c5840fd5f62 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.test.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getInstallCommandForPlatform } from './fleet_server_requirement_page'; + +describe('getInstallCommandForPlatform', () => { + describe('without policy id', () => { + it('should return the correct command if the the policyId is not set for linux-mac', () => { + const res = getInstallCommandForPlatform( + 'linux-mac', + 'http://elasticsearch:9200', + 'service-token-1' + ); + + expect(res).toMatchInlineSnapshot( + `"sudo ./elastic-agent install -f --fleet-server-es=http://elasticsearch:9200 --fleet-server-service-token=service-token-1"` + ); + }); + + it('should return the correct command if the the policyId is not set for windows', () => { + const res = getInstallCommandForPlatform( + 'windows', + 'http://elasticsearch:9200', + 'service-token-1' + ); + + expect(res).toMatchInlineSnapshot( + `".\\\\elastic-agent.exe install -f --fleet-server-es=http://elasticsearch:9200 --fleet-server-service-token=service-token-1"` + ); + }); + + it('should return the correct command if the the policyId is not set for rpm-deb', () => { + const res = getInstallCommandForPlatform( + 'rpm-deb', + 'http://elasticsearch:9200', + 'service-token-1' + ); + + expect(res).toMatchInlineSnapshot( + `"sudo elastic-agent enroll -f --fleet-server-es=http://elasticsearch:9200 --fleet-server-service-token=service-token-1"` + ); + }); + }); + + describe('with policy id', () => { + it('should return the correct command if the the policyId is set for linux-mac', () => { + const res = getInstallCommandForPlatform( + 'linux-mac', + 'http://elasticsearch:9200', + 'service-token-1', + 'policy-1' + ); + + expect(res).toMatchInlineSnapshot( + `"sudo ./elastic-agent install -f --fleet-server-es=http://elasticsearch:9200 --fleet-server-service-token=service-token-1 --fleet-server-policy=policy-1"` + ); + }); + + it('should return the correct command if the the policyId is set for windows', () => { + const res = getInstallCommandForPlatform( + 'windows', + 'http://elasticsearch:9200', + 'service-token-1', + 'policy-1' + ); + + expect(res).toMatchInlineSnapshot( + `".\\\\elastic-agent.exe install -f --fleet-server-es=http://elasticsearch:9200 --fleet-server-service-token=service-token-1 --fleet-server-policy=policy-1"` + ); + }); + + it('should return the correct command if the the policyId is set for rpm-deb', () => { + const res = getInstallCommandForPlatform( + 'rpm-deb', + 'http://elasticsearch:9200', + 'service-token-1', + 'policy-1' + ); + + expect(res).toMatchInlineSnapshot( + `"sudo elastic-agent enroll -f --fleet-server-es=http://elasticsearch:9200 --fleet-server-service-token=service-token-1 --fleet-server-policy=policy-1"` + ); + }); + }); + + it('should return nothing for an invalid platform', () => { + const res = getInstallCommandForPlatform( + 'rpm-deb', + 'http://elasticsearch:9200', + 'service-token-1' + ); + + expect(res).toMatchInlineSnapshot( + `"sudo elastic-agent enroll -f --fleet-server-es=http://elasticsearch:9200 --fleet-server-service-token=service-token-1"` + ); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.tsx index 2e37d9efc7857..a068426ecd23a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/fleet_server_requirement_page.tsx @@ -189,7 +189,29 @@ export const FleetServerCommandStep = ({ }; }; -export const useFleetServerInstructions = () => { +export function getInstallCommandForPlatform( + platform: PLATFORM_TYPE, + esHost: string, + serviceToken: string, + policyId?: string +) { + const commandArguments = `-f --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}${ + policyId ? ` --fleet-server-policy=${policyId}` : '' + }`; + + switch (platform) { + case 'linux-mac': + return `sudo ./elastic-agent install ${commandArguments}`; + case 'windows': + return `.\\elastic-agent.exe install ${commandArguments}`; + case 'rpm-deb': + return `sudo elastic-agent enroll ${commandArguments}`; + default: + return ''; + } +} + +export const useFleetServerInstructions = (policyId?: string) => { const outputsRequest = useGetOutputs(); const { notifications } = useStartServices(); const [serviceToken, setServiceToken] = useState(); @@ -203,17 +225,9 @@ export const useFleetServerInstructions = () => { if (!serviceToken || !esHost) { return ''; } - switch (platform) { - case 'linux-mac': - return `sudo ./elastic-agent install -f --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}`; - case 'windows': - return `.\\elastic-agent.exe install --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}`; - case 'rpm-deb': - return `sudo elastic-agent enroll -f --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}`; - default: - return ''; - } - }, [serviceToken, esHost, platform]); + + return getInstallCommandForPlatform(platform, esHost, serviceToken, policyId); + }, [serviceToken, esHost, platform, policyId]); const getServiceToken = useCallback(async () => { setIsLoadingServiceToken(true); @@ -334,7 +348,7 @@ const CloudInstructions: React.FC<{ deploymentUrl: string }> = ({ deploymentUrl fill isLoading={false} type="submit" - href={deploymentUrl} + href={`${deploymentUrl}/edit`} target="_blank" > (({ agentPolicies }) => { const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId); const settings = useGetSettings(); - const fleetServerInstructions = useFleetServerInstructions(); + const fleetServerInstructions = useFleetServerInstructions(apiKey?.data?.item?.policy_id); const steps = useMemo(() => { const { diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index f55de4b691999..f3cfc76ca5a76 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -177,7 +177,7 @@ const getSavedObjectTypes = ( updated_by: { type: 'keyword' }, revision: { type: 'integer' }, monitoring_enabled: { type: 'keyword', index: false }, - preconfiguration_id: { type: 'keyword' }, + is_preconfigured: { type: 'keyword' }, }, }, migrations: { @@ -366,7 +366,7 @@ const getSavedObjectTypes = ( }, mappings: { properties: { - preconfiguration_id: { type: 'keyword' }, + id: { type: 'keyword' }, }, }, }, diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 2b9cc4e072304..b575c1de1616d 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -14,6 +14,8 @@ import type { SavedObjectsBulkUpdateResponse, } from 'src/core/server'; +import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; + import type { AuthenticatedUser } from '../../../security/server'; import { AGENT_POLICY_SAVED_OBJECT_TYPE, @@ -113,25 +115,22 @@ class AgentPolicyService { policy?: AgentPolicy; }> { const { id, ...preconfiguredAgentPolicy } = omit(config, 'package_policies'); - const newAgentPolicyDefaults: Partial = { + const newAgentPolicyDefaults: Pick = { namespace: 'default', monitoring_enabled: ['logs', 'metrics'], }; + const newAgentPolicy: NewAgentPolicy = { + ...newAgentPolicyDefaults, + ...preconfiguredAgentPolicy, + is_preconfigured: true, + }; + let searchParams; - let newAgentPolicy; if (id) { - const preconfigurationId = String(id); searchParams = { - searchFields: ['preconfiguration_id'], - search: escapeSearchQueryPhrase(preconfigurationId), + id: String(id), }; - - newAgentPolicy = { - ...newAgentPolicyDefaults, - ...preconfiguredAgentPolicy, - preconfiguration_id: preconfigurationId, - } as NewAgentPolicy; } else if ( preconfiguredAgentPolicy.is_default || preconfiguredAgentPolicy.is_default_fleet_server @@ -144,13 +143,8 @@ class AgentPolicyService { ], search: 'true', }; - - newAgentPolicy = { - ...newAgentPolicyDefaults, - ...preconfiguredAgentPolicy, - } as NewAgentPolicy; } - if (!newAgentPolicy || !searchParams) throw new Error('Missing ID'); + if (!searchParams) throw new Error('Missing ID'); return await this.ensureAgentPolicy(soClient, esClient, newAgentPolicy, searchParams); } @@ -159,14 +153,41 @@ class AgentPolicyService { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, newAgentPolicy: NewAgentPolicy, - searchParams: { - searchFields: string[]; - search: string; - } + searchParams: + | { id: string } + | { + searchFields: string[]; + search: string; + } ): Promise<{ created: boolean; policy: AgentPolicy; }> { + // For preconfigured policies with a specified ID + if ('id' in searchParams) { + try { + const agentPolicy = await soClient.get( + AGENT_POLICY_SAVED_OBJECT_TYPE, + searchParams.id + ); + return { + created: false, + policy: { + id: agentPolicy.id, + ...agentPolicy.attributes, + }, + }; + } catch (e) { + if (SavedObjectsErrorHelpers.isNotFoundError(e)) { + return { + created: true, + policy: await this.create(soClient, esClient, newAgentPolicy, { id: searchParams.id }), + }; + } else throw e; + } + } + + // For default policies without a specified ID const agentPolicies = await soClient.find({ type: AGENT_POLICY_SAVED_OBJECT_TYPE, ...searchParams, @@ -571,9 +592,9 @@ class AgentPolicyService { ); } - if (agentPolicy.preconfiguration_id) { + if (agentPolicy.is_preconfigured) { await soClient.create(PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, { - preconfiguration_id: String(agentPolicy.preconfiguration_id), + id: String(id), }); } diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index f7a4c6d9e670f..9b3e9b7a57369 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -7,6 +7,8 @@ import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks'; +import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; + import type { PreconfiguredAgentPolicy } from '../../common/types'; import type { AgentPolicy, NewPackagePolicy, Output } from '../types'; @@ -32,12 +34,13 @@ function getPutPreconfiguredPackagesMock() { const soClient = savedObjectsClientMock.create(); soClient.find.mockImplementation(async ({ type, search }) => { if (type === AGENT_POLICY_SAVED_OBJECT_TYPE) { - const attributes = mockConfiguredPolicies.get(search!.replace(/"/g, '')); + const id = search!.replace(/"/g, ''); + const attributes = mockConfiguredPolicies.get(id); if (attributes) { return { saved_objects: [ { - id: `mocked-${attributes.preconfiguration_id}`, + id: `mocked-${id}`, attributes, type: type as string, score: 1, @@ -57,11 +60,22 @@ function getPutPreconfiguredPackagesMock() { per_page: 0, }; }); - soClient.create.mockImplementation(async (type, policy) => { + soClient.get.mockImplementation(async (type, id) => { + const attributes = mockConfiguredPolicies.get(id); + if (!attributes) throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + return { + id: `mocked-${id}`, + attributes, + type: type as string, + references: [], + }; + }); + soClient.create.mockImplementation(async (type, policy, options) => { const attributes = policy as AgentPolicy; - mockConfiguredPolicies.set(attributes.preconfiguration_id, attributes); + const { id } = options!; + mockConfiguredPolicies.set(id, attributes); return { - id: `mocked-${attributes.preconfiguration_id}`, + id: `mocked-${id}`, attributes, type, references: [], diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index 77230c01cdcb8..308abece9f4f5 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -107,10 +107,10 @@ export async function ensurePreconfiguredPackagesAndPolicies( const preconfiguredPolicies = await Promise.allSettled( policies.map(async (preconfiguredAgentPolicy) => { if (preconfiguredAgentPolicy.id) { - // Check to see if a preconfigured policy with the same preconfigurationId was already deleted by the user + // Check to see if a preconfigured policy with the same preconfiguration id was already deleted by the user const preconfigurationId = String(preconfiguredAgentPolicy.id); const searchParams = { - searchFields: ['preconfiguration_id'], + searchFields: ['id'], search: escapeSearchQueryPhrase(preconfigurationId), }; const deletionRecords = await soClient.find({ diff --git a/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx b/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx index 44f565f98cdb0..4bd9a01380c0e 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx @@ -7,7 +7,7 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiFilterButton, EuiPopover, EuiFilterSelectItem } from '@elastic/eui'; +import { EuiFilterButton, EuiFilterGroup, EuiPopover, EuiFilterSelectItem } from '@elastic/eui'; interface Filter { name: string; @@ -65,26 +65,28 @@ export function FilterListButton({ onChange, filters }: Props< ); return ( - -
- {Object.entries(filters).map(([filter, item], index) => ( - toggleFilter(filter as T)} - data-test-subj="filterItem" - > - {(item as Filter).name} - - ))} -
-
+ + +
+ {Object.entries(filters).map(([filter, item], index) => ( + toggleFilter(filter as T)} + data-test-subj="filterItem" + > + {(item as Filter).name} + + ))} +
+
+
); } diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx index 4915f4cc6422a..04772860c9fe7 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx @@ -166,7 +166,7 @@ export const NoAnomaliesFound = withTheme(({ theme }) => (

-

+

{ label: i18n.translate('xpack.infra.ml.anomalyFlyout.hostBtn', { defaultMessage: 'Hosts', }), + 'data-test-subj': 'anomaliesHostComboBoxItem', }, { id: `k8s` as JobType, label: i18n.translate('xpack.infra.ml.anomalyFlyout.podsBtn', { defaultMessage: 'Kubernetes Pods', }), + 'data-test-subj': 'anomaliesK8sComboBoxItem', }, ]; const [jobType, setJobType] = useState('hosts'); @@ -364,6 +366,7 @@ export const AnomaliesTable = (props: Props) => { }), width: '25%', render: (jobId: string) => jobId, + 'data-test-subj': 'anomalyRow', }, { field: 'anomalyScore', @@ -471,6 +474,7 @@ export const AnomaliesTable = (props: Props) => { selectedOptions={selectedJobType} onChange={changeJobType} isClearable={false} + data-test-subj="anomaliesComboBoxType" /> diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout.tsx index 387e739fab43f..5438209ae9c6b 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout.tsx @@ -50,7 +50,12 @@ export const AnomalyDetectionFlyout = () => { return ( <> - + { setTab('jobs')}> Jobs - setTab('anomalies')}> + setTab('anomalies')} + data-test-subj="anomalyFlyoutAnomaliesTab" + > Anomalies diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 51021a3e50b3f..5f116d29648c9 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -485,14 +485,18 @@ const DropsInner = memo(function DropsInner(props: DropsInnerProps) { }, [order, registerDropTarget, dropTypes, keyboardMode]); useEffect(() => { + let isMounted = true; if (activeDropTarget && activeDropTarget.id !== value.id) { setIsInZone(false); } setTimeout(() => { - if (!activeDropTarget) { + if (!activeDropTarget && isMounted) { setIsInZone(false); } }, 1000); + return () => { + isMounted = false; + }; }, [activeDropTarget, setIsInZone, value.id]); const dragEnter = () => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index c3bd6fde27ba3..a31146e500434 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -43,7 +43,6 @@ import { import { DragDrop, DragContext, DragDropIdentifier } from '../../../drag_drop'; import { Suggestion, switchToSuggestion } from '../suggestion_helpers'; import { buildExpression } from '../expression_helpers'; -import { debouncedComponent } from '../../../debounced_component'; import { trackUiEvent } from '../../../lens_ui_telemetry'; import { UiActionsStart, @@ -368,7 +367,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ ); }); -export const InnerVisualizationWrapper = ({ +export const VisualizationWrapper = ({ expression, framePublicAPI, timefilter, @@ -619,5 +618,3 @@ export const InnerVisualizationWrapper = ({

); }; - -export const VisualizationWrapper = debouncedComponent(InnerVisualizationWrapper); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts index 051feb331aec4..023e6ce979b94 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts @@ -1150,6 +1150,83 @@ describe('IndexPatternDimensionEditorPanel', () => { }); }); + it('respects groups on moving operations if some columns are not listed in groups', () => { + // config: + // a: col1, + // b: col2, col3 + // c: col4 + // col5, col6 not in visualization groups + // dragging col3 onto col1 in group a + onDrop({ + ...defaultProps, + columnId: 'col1', + droppedItem: draggingCol3, + state: { + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col3', 'col4', 'col5', 'col6'], + columns: { + ...testState.layers.first.columns, + col5: { + dataType: 'number', + operationType: 'count', + label: '', + isBucketed: false, + sourceField: 'Records', + }, + col6: { + dataType: 'number', + operationType: 'count', + label: '', + isBucketed: false, + sourceField: 'Records', + }, + }, + }, + }, + }, + groupId: 'a', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + dropType: 'move_compatible', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col4', 'col5', 'col6'], + columns: { + col1: testState.layers.first.columns.col3, + col2: testState.layers.first.columns.col2, + col4: testState.layers.first.columns.col4, + col5: { + dataType: 'number', + operationType: 'count', + label: '', + isBucketed: false, + sourceField: 'Records', + }, + col6: { + dataType: 'number', + operationType: 'count', + label: '', + isBucketed: false, + sourceField: 'Records', + }, + }, + }, + }, + }); + }); + it('respects groups on duplicating operations between compatible groups with overwrite', () => { // config: // a: col1, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts index f0ad797a81b9f..08632171ee4f7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts @@ -147,9 +147,9 @@ function onMoveCompatible( columns: newColumns, }; - const updatedColumnOrder = getColumnOrder(newLayer); + let updatedColumnOrder = getColumnOrder(newLayer); - reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); + updatedColumnOrder = reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); // Time to replace setState( @@ -342,8 +342,8 @@ function onSwapCompatible({ newColumns[targetId] = sourceColumn; newColumns[sourceId] = targetColumn; - const updatedColumnOrder = swapColumnOrder(layer.columnOrder, sourceId, targetId); - reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); + let updatedColumnOrder = swapColumnOrder(layer.columnOrder, sourceId, targetId); + updatedColumnOrder = reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); // Time to replace setState( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index 0ea533e22e4d9..c291c7ab3eac0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -860,6 +860,44 @@ describe('IndexPattern Data Source', () => { expect(operationDefinitionMap.testReference.toExpression).toHaveBeenCalled(); expect(ast.chain[2]).toEqual('mock'); }); + + it('should keep correct column mapping keys with reference columns present', async () => { + const queryBaseState: IndexPatternBaseState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col2', 'col1'], + columns: { + col1: { + label: 'Count of records', + dataType: 'date', + isBucketed: false, + sourceField: 'timefield', + operationType: 'unique_count', + }, + col2: { + label: 'Reference', + dataType: 'number', + isBucketed: false, + // @ts-expect-error not a valid type + operationType: 'testReference', + references: ['col1'], + }, + }, + }, + }, + }; + + const state = enrichBaseState(queryBaseState); + + const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; + expect(JSON.parse(ast.chain[1].arguments.idMap[0] as string)).toEqual({ + 'col-0-col1': expect.objectContaining({ + id: 'col1', + }), + }); + }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index ccae659934ba7..864a3a6f089db 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -1106,11 +1106,11 @@ describe('IndexPattern Data Source suggestions', () => { operation: expect.objectContaining({ dataType: 'date', isBucketed: true }), }, { - columnId: 'newid', + columnId: 'ref', operation: expect.objectContaining({ dataType: 'number', isBucketed: false }), }, { - columnId: 'ref', + columnId: 'newid', operation: expect.objectContaining({ dataType: 'number', isBucketed: false }), }, ], @@ -1159,21 +1159,21 @@ describe('IndexPattern Data Source suggestions', () => { changeType: 'extended', columns: [ { - columnId: 'newid', + columnId: 'ref', operation: { dataType: 'number', isBucketed: false, - label: 'Count of records', - scale: 'ratio', + label: '', + scale: undefined, }, }, { - columnId: 'ref', + columnId: 'newid', operation: { dataType: 'number', isBucketed: false, - label: '', - scale: undefined, + label: 'Count of records', + scale: 'ratio', }, }, ], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 35f334d5bd743..297fa4af2bc3f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -712,7 +712,12 @@ function addBucket( // they already had, with an extra level of detail. updatedColumnOrder = [...buckets, addedColumnId, ...metrics, ...references]; } - reorderByGroups(visualizationGroups, targetGroup, updatedColumnOrder, addedColumnId); + updatedColumnOrder = reorderByGroups( + visualizationGroups, + targetGroup, + updatedColumnOrder, + addedColumnId + ); const tempLayer = { ...resetIncomplete(layer, addedColumnId), columns: { ...layer.columns, [addedColumnId]: column }, @@ -749,16 +754,24 @@ export function reorderByGroups( }); const columnGroupIndex: Record = {}; updatedColumnOrder.forEach((columnId) => { - columnGroupIndex[columnId] = orderedVisualizationGroups.findIndex( + const groupIndex = orderedVisualizationGroups.findIndex( (group) => (columnId === addedColumnId && group.groupId === targetGroup) || group.accessors.some((acc) => acc.columnId === columnId) ); + if (groupIndex !== -1) { + columnGroupIndex[columnId] = groupIndex; + } else { + // referenced columns won't show up in visualization groups - put them in the back of the list. This will work as they are always metrics + columnGroupIndex[columnId] = updatedColumnOrder.length; + } }); - updatedColumnOrder.sort((a, b) => { + return [...updatedColumnOrder].sort((a, b) => { return columnGroupIndex[a] - columnGroupIndex[b]; }); + } else { + return updatedColumnOrder; } } @@ -899,12 +912,8 @@ export function getColumnOrder(layer: IndexPatternLayer): string[] { } }); - const [direct, referenceBased] = _.partition( - entries, - ([, col]) => operationDefinitionMap[col.operationType].input !== 'fullReference' - ); // If a reference has another reference as input, put it last in sort order - referenceBased.sort(([idA, a], [idB, b]) => { + entries.sort(([idA, a], [idB, b]) => { if ('references' in a && a.references.includes(idB)) { return 1; } @@ -913,12 +922,9 @@ export function getColumnOrder(layer: IndexPatternLayer): string[] { } return 0; }); - const [aggregations, metrics] = _.partition(direct, ([, col]) => col.isBucketed); + const [aggregations, metrics] = _.partition(entries, ([, col]) => col.isBucketed); - return aggregations - .map(([id]) => id) - .concat(metrics.map(([id]) => id)) - .concat(referenceBased.map(([id]) => id)); + return aggregations.map(([id]) => id).concat(metrics.map(([id]) => id)); } // Splits existing columnOrder into the three categories diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index b272e5476aa63..4f596aa282510 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -6,6 +6,7 @@ */ import type { IUiSettingsClient } from 'kibana/public'; +import { partition } from 'lodash'; import { AggFunctionsMapping, EsaggsExpressionFunctionDefinition, @@ -57,14 +58,24 @@ function getExpressionForLayer( const columnEntries = columnOrder.map((colId) => [colId, columns[colId]] as const); - if (columnEntries.length) { + const [referenceEntries, esAggEntries] = partition( + columnEntries, + ([, col]) => operationDefinitionMap[col.operationType]?.input === 'fullReference' + ); + + if (referenceEntries.length || esAggEntries.length) { const aggs: ExpressionAstExpressionBuilder[] = []; const expressions: ExpressionAstFunction[] = []; - columnEntries.forEach(([colId, col]) => { + referenceEntries.forEach(([colId, col]) => { const def = operationDefinitionMap[col.operationType]; if (def.input === 'fullReference') { expressions.push(...def.toExpression(layer, colId, indexPattern)); - } else { + } + }); + + esAggEntries.forEach(([colId, col]) => { + const def = operationDefinitionMap[col.operationType]; + if (def.input !== 'fullReference') { const wrapInFilter = Boolean(def.filterable && col.filter); let aggAst = def.toEsAggsFn( col, @@ -101,8 +112,8 @@ function getExpressionForLayer( } }); - const idMap = columnEntries.reduce((currentIdMap, [colId, column], index) => { - const esAggsId = `col-${columnEntries.length === 1 ? 0 : index}-${colId}`; + const idMap = esAggEntries.reduce((currentIdMap, [colId, column], index) => { + const esAggsId = `col-${index}-${colId}`; return { ...currentIdMap, [esAggsId]: { diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap index cb82cc5b52a01..aa22bbb0c15c6 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap @@ -74,6 +74,10 @@ exports[`xy_expression XYChart component it renders area 1`] = ` tickFormat={[Function]} title="a" /> + + + + + + + { false ); }); + + it('hides the endzone visibility flag if no setter is passed in', () => { + const component = shallow(); + expect(component.find('[data-test-subj="lnsshowEndzones"]').length).toBe(0); + }); + + it('shows the switch if setter is present', () => { + const component = shallow( + {}} /> + ); + expect(component.find('[data-test-subj="lnsshowEndzones"]').prop('checked')).toBe(true); + }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx index 2a40f6204c44d..d9c60ae666484 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx @@ -71,6 +71,14 @@ export interface AxisSettingsPopoverProps { * Toggles the axis title visibility */ toggleAxisTitleVisibility: (axis: AxesSettingsConfigKeys, checked: boolean) => void; + /** + * Set endzone visibility + */ + setEndzoneVisibility?: (checked: boolean) => void; + /** + * Flag whether endzones are visible + */ + endzonesVisible?: boolean; } const popoverConfig = ( axis: AxesSettingsConfigKeys, @@ -138,6 +146,8 @@ export const AxisSettingsPopover: React.FunctionComponent { const [title, setTitle] = useState(axisTitle); @@ -212,6 +222,20 @@ export const AxisSettingsPopover: React.FunctionComponent toggleGridlinesVisibility(axis)} checked={areGridlinesVisible} /> + {setEndzoneVisibility && ( + <> + + setEndzoneVisibility(!Boolean(endzonesVisible))} + checked={Boolean(endzonesVisible)} + /> + + )} ); }; diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index e1dbd4da4b902..fe0513caa08a8 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -44,6 +44,7 @@ import { createMockExecutionContext } from '../../../../../src/plugins/expressio import { mountWithIntl } from '@kbn/test/jest'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { EmptyPlaceholder } from '../shared_components/empty_placeholder'; +import { XyEndzones } from './x_domain'; const onClickValue = jest.fn(); const onSelectRange = jest.fn(); @@ -549,6 +550,135 @@ describe('xy_expression', () => { } `); }); + + describe('endzones', () => { + const { args } = sampleArgs(); + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: createSampleDatatableWithRows([ + { a: 1, b: 2, c: new Date('2021-04-22').valueOf(), d: 'Foo' }, + { a: 1, b: 2, c: new Date('2021-04-23').valueOf(), d: 'Foo' }, + { a: 1, b: 2, c: new Date('2021-04-24').valueOf(), d: 'Foo' }, + ]), + }, + dateRange: { + // first and last bucket are partial + fromDate: new Date('2021-04-22T12:00:00.000Z'), + toDate: new Date('2021-04-24T12:00:00.000Z'), + }, + }; + const timeArgs: XYArgs = { + ...args, + layers: [ + { + ...args.layers[0], + seriesType: 'line', + xScaleType: 'time', + isHistogram: true, + splitAccessor: undefined, + }, + ], + }; + + test('it extends interval if data is exceeding it', () => { + const component = shallow( + + ); + + expect(component.find(Settings).prop('xDomain')).toEqual({ + // shortened to 24th midnight (elastic-charts automatically adds one min interval) + max: new Date('2021-04-24').valueOf(), + // extended to 22nd midnight because of first bucket + min: new Date('2021-04-22').valueOf(), + minInterval: 24 * 60 * 60 * 1000, + }); + }); + + test('it renders endzone component bridging gap between domain and extended domain', () => { + const component = shallow( + + ); + + expect(component.find(XyEndzones).dive().find('Endzones').props()).toEqual( + expect.objectContaining({ + domainStart: new Date('2021-04-22T12:00:00.000Z').valueOf(), + domainEnd: new Date('2021-04-24T12:00:00.000Z').valueOf(), + domainMin: new Date('2021-04-22').valueOf(), + domainMax: new Date('2021-04-24').valueOf(), + }) + ); + }); + + test('should pass enabled histogram mode and min interval to endzones component', () => { + const component = shallow( + + ); + + expect(component.find(XyEndzones).dive().find('Endzones').props()).toEqual( + expect.objectContaining({ + interval: 24 * 60 * 60 * 1000, + isFullBin: false, + }) + ); + }); + + test('should pass disabled histogram mode and min interval to endzones component', () => { + const component = shallow( + + ); + + expect(component.find(XyEndzones).dive().find('Endzones').props()).toEqual( + expect.objectContaining({ + interval: 24 * 60 * 60 * 1000, + isFullBin: true, + }) + ); + }); + + test('it does not render endzones if disabled via settings', () => { + const component = shallow( + + ); + + expect(component.find(XyEndzones).length).toEqual(0); + }); + }); }); test('it has xDomain undefined if the x is not a time scale or a histogram', () => { diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 47b8dbfc15f53..5416c8eda0aa9 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -57,6 +57,7 @@ import { desanitizeFilterContext } from '../utils'; import { fittingFunctionDefinitions, getFitOptions } from './fitting_functions'; import { getAxesConfiguration } from './axes_configuration'; import { getColorAssignments } from './color_assignment'; +import { getXDomain, XyEndzones } from './x_domain'; declare global { interface Window { @@ -183,6 +184,13 @@ export const xyChart: ExpressionFunctionDefinition< defaultMessage: 'Define how curve type is rendered for a line chart', }), }, + hideEndzones: { + types: ['boolean'], + default: false, + help: i18n.translate('xpack.lens.xyChart.hideEndzones.help', { + defaultMessage: 'Hide endzone markers for partial data', + }), + }, }, fn(data: LensMultiTable, args: XYArgs) { return { @@ -330,9 +338,17 @@ export function XYChart({ renderMode, syncColors, }: XYChartRenderProps) { - const { legend, layers, fittingFunction, gridlinesVisibilitySettings, valueLabels } = args; + const { + legend, + layers, + fittingFunction, + gridlinesVisibilitySettings, + valueLabels, + hideEndzones, + } = args; const chartTheme = chartsThemeService.useChartsTheme(); const chartBaseTheme = chartsThemeService.useChartsBaseTheme(); + const darkMode = chartsThemeService.useDarkMode(); const filteredLayers = getFilteredLayers(layers, data); if (filteredLayers.length === 0) { @@ -387,15 +403,13 @@ export function XYChart({ const isTimeViz = data.dateRange && filteredLayers.every((l) => l.xScaleType === 'time'); const isHistogramViz = filteredLayers.every((l) => l.isHistogram); - const xDomain = isTimeViz - ? { - min: data.dateRange?.fromDate.getTime(), - max: data.dateRange?.toDate.getTime(), - minInterval, - } - : isHistogramViz - ? { minInterval } - : undefined; + const { baseDomain: rawXDomain, extendedDomain: xDomain } = getXDomain( + layers, + data, + minInterval, + Boolean(isTimeViz), + Boolean(isHistogramViz) + ); const getYAxesTitles = ( axisSeries: Array<{ layer: string; accessor: string }>, @@ -602,6 +616,22 @@ export function XYChart({ /> ))} + {!hideEndzones && ( + + layer.isHistogram && + (layer.seriesType.includes('stacked') || !layer.splitAccessor) && + (layer.seriesType.includes('stacked') || + !layer.seriesType.includes('bar') || + !chartHasMoreThanOneBarSeries) + )} + /> + )} + {filteredLayers.flatMap((layer, layerIndex) => layer.accessors.map((accessor, accessorIndex) => { const { diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index b726869743312..89dca6e8a3944 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -51,6 +51,7 @@ describe('#toExpression', () => { fittingFunction: 'Carry', tickLabelsVisibilitySettings: { x: false, yLeft: true, yRight: true }, gridlinesVisibilitySettings: { x: false, yLeft: true, yRight: true }, + hideEndzones: true, layers: [ { layerId: 'first', diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 6a1882edde949..02c5f3773d813 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -198,6 +198,7 @@ export const buildExpression = ( }, ], valueLabels: [state?.valueLabels || 'hide'], + hideEndzones: [state?.hideEndzones || false], layers: validLayers.map((layer) => { const columnToLabel = getColumnToLabelMap(layer, datasourceLayers[layer.layerId]); diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index 6f1a01acd6e76..0622f1c43f1c3 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -414,6 +414,7 @@ export interface XYArgs { tickLabelsVisibilitySettings?: AxesSettingsConfig & { type: 'lens_xy_tickLabelsConfig' }; gridlinesVisibilitySettings?: AxesSettingsConfig & { type: 'lens_xy_gridlinesConfig' }; curveType?: XYCurveType; + hideEndzones?: boolean; } export type XYCurveType = 'LINEAR' | 'CURVE_MONOTONE_X'; @@ -432,6 +433,7 @@ export interface XYState { tickLabelsVisibilitySettings?: AxesSettingsConfig; gridlinesVisibilitySettings?: AxesSettingsConfig; curveType?: XYCurveType; + hideEndzones?: boolean; } export type State = XYState; diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 27ef827c138ca..aa4b91b840db3 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -818,6 +818,60 @@ describe('xy_visualization', () => { }, ]); }); + + it('should return an error if two incompatible xAccessors (multiple layers) are used', () => { + // current incompatibility is only for date and numeric histograms as xAccessors + const datasourceLayers = { + first: mockDatasource.publicAPIMock, + second: createMockDatasource('testDatasource').publicAPIMock, + }; + datasourceLayers.first.getOperationForColumnId = jest.fn((id: string) => + id === 'a' + ? (({ + dataType: 'date', + scale: 'interval', + } as unknown) as Operation) + : null + ); + datasourceLayers.second.getOperationForColumnId = jest.fn((id: string) => + id === 'e' + ? (({ + dataType: 'number', + scale: 'interval', + } as unknown) as Operation) + : null + ); + expect( + xyVisualization.getErrorMessages( + { + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + splitAccessor: 'd', + xAccessor: 'a', + accessors: ['b'], + }, + { + layerId: 'second', + seriesType: 'area', + splitAccessor: 'd', + xAccessor: 'e', + accessors: ['b'], + }, + ], + }, + datasourceLayers + ) + ).toEqual([ + { + shortMessage: 'Wrong data type for Horizontal axis.', + longMessage: + 'Data type mismatch for the Horizontal axis. Cannot mix date and number interval types.', + }, + ]); + }); }); describe('#getWarningMessages', () => { diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index a6df995513fdf..dda1a444f4544 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -15,8 +15,14 @@ import { PaletteRegistry } from 'src/plugins/charts/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; import { getSuggestions } from './xy_suggestions'; import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; -import { Visualization, OperationMetadata, VisualizationType, AccessorConfig } from '../types'; -import { State, SeriesType, visualizationTypes, XYLayerConfig } from './types'; +import { + Visualization, + OperationMetadata, + VisualizationType, + AccessorConfig, + DatasourcePublicAPI, +} from '../types'; +import { State, SeriesType, visualizationTypes, XYLayerConfig, XYState } from './types'; import { isHorizontalChart } from './state_helpers'; import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression'; import { LensIconChartBarStacked } from '../assets/chart_bar_stacked'; @@ -374,6 +380,9 @@ export const getXyVisualization = ({ } if (datasourceLayers && state) { + // temporary fix for #87068 + errors.push(...checkXAccessorCompatibility(state, datasourceLayers)); + for (const layer of state.layers) { const datasourceAPI = datasourceLayers[layer.layerId]; if (datasourceAPI) { @@ -517,3 +526,47 @@ function newLayerState(seriesType: SeriesType, layerId: string): XYLayerConfig { accessors: [], }; } + +// min requirement for the bug: +// * 2 or more layers +// * at least one with date histogram +// * at least one with interval function +function checkXAccessorCompatibility( + state: XYState, + datasourceLayers: Record +) { + const errors = []; + const hasDateHistogramSet = state.layers.some(checkIntervalOperation('date', datasourceLayers)); + const hasNumberHistogram = state.layers.some(checkIntervalOperation('number', datasourceLayers)); + if (state.layers.length > 1 && hasDateHistogramSet && hasNumberHistogram) { + errors.push({ + shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXShort', { + defaultMessage: `Wrong data type for {axis}.`, + values: { + axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), + }, + }), + longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXLong', { + defaultMessage: `Data type mismatch for the {axis}. Cannot mix date and number interval types.`, + values: { + axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), + }, + }), + }); + } + return errors; +} + +function checkIntervalOperation( + dataType: 'date' | 'number', + datasourceLayers: Record +) { + return (layer: XYLayerConfig) => { + const datasourceAPI = datasourceLayers[layer.layerId]; + if (!layer.xAccessor) { + return false; + } + const operation = datasourceAPI?.getOperationForColumnId(layer.xAccessor); + return Boolean(operation?.dataType === dataType && operation.scale === 'interval'); + }; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx b/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx new file mode 100644 index 0000000000000..369063644a754 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { uniq } from 'lodash'; +import React from 'react'; +import { Endzones } from '../../../../../src/plugins/charts/public'; +import { LensMultiTable } from '../types'; +import { LayerArgs } from './types'; + +export interface XDomain { + min?: number; + max?: number; + minInterval?: number; +} + +export const getXDomain = ( + layers: LayerArgs[], + data: LensMultiTable, + minInterval: number | undefined, + isTimeViz: boolean, + isHistogram: boolean +) => { + const baseDomain = isTimeViz + ? { + min: data.dateRange?.fromDate.getTime(), + max: data.dateRange?.toDate.getTime(), + minInterval, + } + : isHistogram + ? { minInterval } + : undefined; + + if (isHistogram && isFullyQualified(baseDomain)) { + const xValues = uniq( + layers + .flatMap((layer) => + data.tables[layer.layerId].rows.map((row) => row[layer.xAccessor!].valueOf() as number) + ) + .sort() + ); + + const [firstXValue] = xValues; + const lastXValue = xValues[xValues.length - 1]; + + const domainMin = Math.min(firstXValue, baseDomain.min); + const domainMaxValue = baseDomain.max - baseDomain.minInterval; + const domainMax = Math.max(domainMaxValue, lastXValue); + + return { + extendedDomain: { + min: domainMin, + max: domainMax, + minInterval: baseDomain.minInterval, + }, + baseDomain, + }; + } + + return { + baseDomain, + extendedDomain: baseDomain, + }; +}; + +function isFullyQualified( + xDomain: XDomain | undefined +): xDomain is { min: number; max: number; minInterval: number } { + return Boolean( + xDomain && + typeof xDomain.min === 'number' && + typeof xDomain.max === 'number' && + xDomain.minInterval + ); +} + +export const XyEndzones = function ({ + baseDomain, + extendedDomain, + histogramMode, + darkMode, +}: { + baseDomain?: XDomain; + extendedDomain?: XDomain; + histogramMode: boolean; + darkMode: boolean; +}) { + return isFullyQualified(baseDomain) && isFullyQualified(extendedDomain) ? ( + + ) : null; +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index f965140a48ca0..e3e8c6e93e3aa 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -138,6 +138,29 @@ describe('XY Config panels', () => { expect(component.find(AxisSettingsPopover).length).toEqual(3); }); + + it('should pass in endzone visibility setter and current sate for time chart', () => { + (frame.datasourceLayers.first.getOperationForColumnId as jest.Mock).mockReturnValue({ + dataType: 'date', + }); + const state = testState(); + const component = shallow( + + ); + + expect(component.find(AxisSettingsPopover).at(0).prop('setEndzoneVisibility')).toBeFalsy(); + expect(component.find(AxisSettingsPopover).at(1).prop('setEndzoneVisibility')).toBeTruthy(); + expect(component.find(AxisSettingsPopover).at(1).prop('endzonesVisible')).toBe(false); + expect(component.find(AxisSettingsPopover).at(2).prop('setEndzoneVisibility')).toBeFalsy(); + }); }); describe('Dimension Editor', () => { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index c79a7e37f84d1..eccf4d9b64345 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -8,7 +8,7 @@ import './xy_config_panel.scss'; import React, { useMemo, useState, memo } from 'react'; import { i18n } from '@kbn/i18n'; -import { Position } from '@elastic/charts'; +import { Position, ScaleType } from '@elastic/charts'; import { debounce } from 'lodash'; import { EuiButtonGroup, @@ -37,7 +37,7 @@ import { TooltipWrapper } from './tooltip_wrapper'; import { getAxesConfiguration } from './axes_configuration'; import { PalettePicker } from '../shared_components'; import { getAccessorColorConfig, getColorAssignments } from './color_assignment'; -import { getSortedAccessors } from './to_expression'; +import { getScaleType, getSortedAccessors } from './to_expression'; import { VisualOptionsPopover } from './visual_options_popover/visual_options_popover'; type UnwrapArray = T extends Array ? P : T; @@ -187,6 +187,23 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp }); }; + // only allow changing endzone visibility if it could show up theoretically (if it's a time viz) + const onChangeEndzoneVisiblity = state?.layers.every( + (layer) => + layer.xAccessor && + getScaleType( + props.frame.datasourceLayers[layer.layerId].getOperationForColumnId(layer.xAccessor), + ScaleType.Linear + ) === 'time' + ) + ? (checked: boolean): void => { + setState({ + ...state, + hideEndzones: !checked, + }); + } + : undefined; + const legendMode = state?.legend.isVisible && !state?.legend.showSingleSeries ? 'auto' @@ -278,6 +295,8 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp toggleGridlinesVisibility={onGridlinesVisibilitySettingsChange} isAxisTitleVisible={axisTitlesVisibilitySettings.x} toggleAxisTitleVisibility={onAxisTitlesVisibilitySettingsChange} + endzonesVisible={!state?.hideEndzones} + setEndzoneVisibility={onChangeEndzoneVisiblity} /> { @@ -12,7 +13,7 @@ describe('cluster', () => { describe('fromUpstreamJSON factory method', () => { const upstreamJSON = { cluster_uuid: 'S-S4NNZDRV-g9c-JrIhx6A', - }; + } as estypes.RootNodeInfoResponse; it('returns correct Cluster instance', () => { const cluster = Cluster.fromUpstreamJSON(upstreamJSON); diff --git a/x-pack/plugins/logstash/server/models/cluster/cluster.ts b/x-pack/plugins/logstash/server/models/cluster/cluster.ts index e089eef623069..88789a2d29c89 100755 --- a/x-pack/plugins/logstash/server/models/cluster/cluster.ts +++ b/x-pack/plugins/logstash/server/models/cluster/cluster.ts @@ -5,28 +5,27 @@ * 2.0. */ -import { get } from 'lodash'; +import { estypes } from '@elastic/elasticsearch'; /** * 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 = { + return { uuid: this.uuid, }; - - return json; } // generate Pipeline object from elasticsearch response - static fromUpstreamJSON(upstreamCluster: Record) { - const uuid = get(upstreamCluster, 'cluster_uuid') as string; + static fromUpstreamJSON(upstreamCluster: estypes.RootNodeInfoResponse) { + const uuid = upstreamCluster.cluster_uuid; return new Cluster({ uuid }); } } diff --git a/x-pack/plugins/logstash/server/plugin.ts b/x-pack/plugins/logstash/server/plugin.ts index 1a94a25647342..f40e500671fc3 100644 --- a/x-pack/plugins/logstash/server/plugin.ts +++ b/x-pack/plugins/logstash/server/plugin.ts @@ -5,20 +5,11 @@ * 2.0. */ -import { - CoreSetup, - CoreStart, - ILegacyCustomClusterClient, - Logger, - Plugin, - PluginInitializerContext, -} from 'src/core/server'; +import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'src/core/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { SecurityPluginSetup } from '../../security/server'; - import { registerRoutes } from './routes'; -import type { LogstashRequestHandlerContext } from './types'; interface SetupDeps { licensing: LicensingPluginSetup; @@ -28,8 +19,7 @@ interface SetupDeps { export class LogstashPlugin implements Plugin { private readonly logger: Logger; - private esClient?: ILegacyCustomClusterClient; - private coreSetup?: CoreSetup; + constructor(context: PluginInitializerContext) { this.logger = context.logger.get(); } @@ -37,7 +27,6 @@ export class LogstashPlugin implements Plugin { setup(core: CoreSetup, deps: SetupDeps) { this.logger.debug('Setting up Logstash plugin'); - this.coreSetup = core; registerRoutes(core.http.createRouter(), deps.security); deps.features.registerElasticsearchFeature({ @@ -55,19 +44,5 @@ export class LogstashPlugin implements Plugin { }); } - 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(); - } - } + start(core: CoreStart) {} } diff --git a/x-pack/plugins/logstash/server/routes/cluster/load.ts b/x-pack/plugins/logstash/server/routes/cluster/load.ts index ac7bc245e51eb..1b8dc7880e8dc 100644 --- a/x-pack/plugins/logstash/server/routes/cluster/load.ts +++ b/x-pack/plugins/logstash/server/routes/cluster/load.ts @@ -18,8 +18,8 @@ export function registerClusterLoadRoute(router: LogstashPluginRouter) { }, wrapRouteWithLicenseCheck(checkLicense, async (context, request, response) => { try { - const client = context.logstash!.esClient; - const info = await client.callAsCurrentUser('info'); + const { client } = context.core.elasticsearch; + const { body: info } = await client.asCurrentUser.info(); return response.ok({ body: { cluster: Cluster.fromUpstreamJSON(info).downstreamJSON, diff --git a/x-pack/plugins/logstash/server/routes/pipeline/delete.ts b/x-pack/plugins/logstash/server/routes/pipeline/delete.ts index 77706051d1cd1..59aaaef63786e 100644 --- a/x-pack/plugins/logstash/server/routes/pipeline/delete.ts +++ b/x-pack/plugins/logstash/server/routes/pipeline/delete.ts @@ -23,14 +23,18 @@ export function registerPipelineDeleteRoute(router: LogstashPluginRouter) { wrapRouteWithLicenseCheck( checkLicense, router.handleLegacyErrors(async (context, request, response) => { - const client = context.logstash!.esClient; + const { id } = request.params; + const { client } = context.core.elasticsearch; - await client.callAsCurrentUser('transport.request', { - path: '/_logstash/pipeline/' + encodeURIComponent(request.params.id), - method: 'DELETE', - }); - - return response.noContent(); + try { + await client.asCurrentUser.logstash.deletePipeline({ id }); + return response.noContent(); + } catch (e) { + if (e.statusCode === 404) { + return response.notFound(); + } + throw e; + } }) ) ); diff --git a/x-pack/plugins/logstash/server/routes/pipeline/load.ts b/x-pack/plugins/logstash/server/routes/pipeline/load.ts index f729a40f1abad..33f24a4ad6e26 100644 --- a/x-pack/plugins/logstash/server/routes/pipeline/load.ts +++ b/x-pack/plugins/logstash/server/routes/pipeline/load.ts @@ -25,13 +25,13 @@ export function registerPipelineLoadRoute(router: LogstashPluginRouter) { wrapRouteWithLicenseCheck( checkLicense, router.handleLegacyErrors(async (context, request, response) => { - const client = context.logstash!.esClient; + const { id } = request.params; + const { client } = context.core.elasticsearch; - const result = await client.callAsCurrentUser('transport.request', { - path: '/_logstash/pipeline/' + encodeURIComponent(request.params.id), - method: 'GET', - ignore: [404], - }); + const { body: result } = await client.asCurrentUser.logstash.getPipeline( + { id }, + { ignore: [404] } + ); if (result[request.params.id] === undefined) { return response.notFound(); diff --git a/x-pack/plugins/logstash/server/routes/pipeline/save.ts b/x-pack/plugins/logstash/server/routes/pipeline/save.ts index b533f210f1cd7..48a62f83c91ca 100644 --- a/x-pack/plugins/logstash/server/routes/pipeline/save.ts +++ b/x-pack/plugins/logstash/server/routes/pipeline/save.ts @@ -42,12 +42,11 @@ export function registerPipelineSaveRoute( username = user?.username; } - const client = context.logstash!.esClient; + const { client } = context.core.elasticsearch; const pipeline = Pipeline.fromDownstreamJSON(request.body, request.params.id, username); - await client.callAsCurrentUser('transport.request', { - path: '/_logstash/pipeline/' + encodeURIComponent(pipeline.id), - method: 'PUT', + await client.asCurrentUser.logstash.putPipeline({ + id: pipeline.id, body: pipeline.upstreamJSON, }); diff --git a/x-pack/plugins/logstash/server/routes/pipelines/delete.ts b/x-pack/plugins/logstash/server/routes/pipelines/delete.ts index 84dcfef4f67fd..3609ac1520683 100644 --- a/x-pack/plugins/logstash/server/routes/pipelines/delete.ts +++ b/x-pack/plugins/logstash/server/routes/pipelines/delete.ts @@ -6,19 +6,19 @@ */ import { schema } from '@kbn/config-schema'; -import { LegacyAPICaller } from 'src/core/server'; +import { ElasticsearchClient } from 'src/core/server'; import { wrapRouteWithLicenseCheck } from '../../../../licensing/server'; import { checkLicense } from '../../lib/check_license'; import type { LogstashPluginRouter } from '../../types'; -async function deletePipelines(callWithRequest: LegacyAPICaller, pipelineIds: string[]) { +async function deletePipelines(client: ElasticsearchClient, pipelineIds: string[]) { const deletePromises = pipelineIds.map((pipelineId) => { - return callWithRequest('transport.request', { - path: '/_logstash/pipeline/' + encodeURIComponent(pipelineId), - method: 'DELETE', - }) - .then((success) => ({ success })) + return client.logstash + .deletePipeline({ + id: pipelineId, + }) + .then((response) => ({ success: response.body })) .catch((error) => ({ error })); }); @@ -45,8 +45,8 @@ export function registerPipelinesDeleteRoute(router: LogstashPluginRouter) { wrapRouteWithLicenseCheck( checkLicense, router.handleLegacyErrors(async (context, request, response) => { - const client = context.logstash.esClient; - const results = await deletePipelines(client.callAsCurrentUser, request.body.pipelineIds); + const client = context.core.elasticsearch.client.asCurrentUser; + const results = await deletePipelines(client, request.body.pipelineIds); return response.ok({ body: { results } }); }) diff --git a/x-pack/plugins/logstash/server/routes/pipelines/list.ts b/x-pack/plugins/logstash/server/routes/pipelines/list.ts index 42ff528364777..2ce57d18d3118 100644 --- a/x-pack/plugins/logstash/server/routes/pipelines/list.ts +++ b/x-pack/plugins/logstash/server/routes/pipelines/list.ts @@ -6,21 +6,22 @@ */ import { i18n } from '@kbn/i18n'; -import { LegacyAPICaller } from 'src/core/server'; +import { ElasticsearchClient } from 'src/core/server'; import type { LogstashPluginRouter } from '../../types'; import { wrapRouteWithLicenseCheck } from '../../../../licensing/server'; import { PipelineListItem } from '../../models/pipeline_list_item'; import { checkLicense } from '../../lib/check_license'; -async function fetchPipelines(callWithRequest: LegacyAPICaller) { - const params = { - path: '/_logstash/pipeline', - method: 'GET', - ignore: [404], - }; - - return await callWithRequest('transport.request', params); +async function fetchPipelines(client: ElasticsearchClient) { + const { body } = await client.transport.request( + { + method: 'GET', + path: '/_logstash/pipeline', + }, + { ignore: [404] } + ); + return body; } export function registerPipelinesListRoute(router: LogstashPluginRouter) { @@ -33,8 +34,8 @@ export function registerPipelinesListRoute(router: LogstashPluginRouter) { checkLicense, router.handleLegacyErrors(async (context, request, response) => { try { - const client = context.logstash!.esClient; - const pipelinesRecord = (await fetchPipelines(client.callAsCurrentUser)) as Record< + const { client } = context.core.elasticsearch; + const pipelinesRecord = (await fetchPipelines(client.asCurrentUser)) as Record< string, any >; diff --git a/x-pack/plugins/logstash/server/types.ts b/x-pack/plugins/logstash/server/types.ts index aef14b98c9f06..2177ae9f17f39 100644 --- a/x-pack/plugins/logstash/server/types.ts +++ b/x-pack/plugins/logstash/server/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { ILegacyScopedClusterClient, IRouter, RequestHandlerContext } from 'src/core/server'; +import type { IRouter, RequestHandlerContext } from 'src/core/server'; import type { LicensingApiRequestHandlerContext } from '../../licensing/server'; export interface PipelineListItemOptions { @@ -19,9 +19,6 @@ export interface PipelineListItemOptions { * @internal */ export interface LogstashRequestHandlerContext extends RequestHandlerContext { - logstash: { - esClient: ILegacyScopedClusterClient; - }; licensing: LicensingApiRequestHandlerContext; } diff --git a/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js b/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js index ce78ff0f48625..7b8b3bdcceb4b 100644 --- a/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js +++ b/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js @@ -152,7 +152,10 @@ export class AnomaliesTableInternal extends Component { const result = { pageIndex: page && page.index !== undefined ? page.index : tableState.pageIndex, pageSize: page && page.size !== undefined ? page.size : tableState.pageSize, - sortField: sort && sort.field !== undefined ? sort.field : tableState.sortField, + sortField: + sort && sort.field !== undefined && typeof sort.field === 'string' + ? sort.field + : tableState.sortField, sortDirection: sort && sort.direction !== undefined ? sort.direction : tableState.sortDirection, }; diff --git a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts index 6d70566af1a64..a5d50f1070f5b 100644 --- a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts +++ b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts @@ -37,8 +37,8 @@ import { TimefilterContract } from '../../../../../../../src/plugins/data/public import { AnomalyExplorerChartsService } from '../../services/anomaly_explorer_charts_service'; import { CombinedJob } from '../../../../common/types/anomaly_detection_jobs'; import { InfluencersFilterQuery } from '../../../../common/types/es_client'; -import { ExplorerChartsData } from '../explorer_charts/explorer_charts_container_service'; import { mlJobService } from '../../services/job_service'; +import { TimeBucketsInterval } from '../../util/time_buckets'; // Memoize the data fetching methods. // wrapWithLastRefreshArg() wraps any given function and preprends a `lastRefresh` argument @@ -75,7 +75,7 @@ export interface LoadExplorerDataConfig { noInfluencersConfigured: boolean; selectedCells: AppStateSelectedCells | undefined; selectedJobs: ExplorerJob[]; - swimlaneBucketInterval: any; + swimlaneBucketInterval: TimeBucketsInterval; swimlaneLimit: number; tableInterval: string; tableSeverity: number; @@ -155,7 +155,6 @@ const loadExplorerDataProvider = ( const dateFormatTz = getDateFormatTz(); const interval = swimlaneBucketInterval.asSeconds(); - // First get the data where we have all necessary args at hand using forkJoin: // annotationsData, anomalyChartRecords, influencers, overallState, tableData, topFieldValues return forkJoin({ @@ -224,7 +223,21 @@ const loadExplorerDataProvider = ( // show the view-by loading indicator // and pass on the data we already fetched. tap(explorerService.setViewBySwimlaneLoading), - tap(explorerService.setChartsDataLoading), + tap(({ anomalyChartRecords, topFieldValues }) => { + memoizedAnomalyDataChange( + lastRefresh, + explorerService, + combinedJobRecords, + swimlaneContainerWidth, + selectedCells !== undefined && Array.isArray(anomalyChartRecords) + ? anomalyChartRecords + : [], + timerange.earliestMs, + timerange.latestMs, + timefilter, + tableSeverity + ); + }), mergeMap( ({ overallAnnotations, @@ -236,18 +249,6 @@ const loadExplorerDataProvider = ( tableData, }) => forkJoin({ - anomalyChartsData: memoizedAnomalyDataChange( - lastRefresh, - combinedJobRecords, - swimlaneContainerWidth, - selectedCells !== undefined && Array.isArray(anomalyChartRecords) - ? anomalyChartRecords - : [], - timerange.earliestMs, - timerange.latestMs, - timefilter, - tableSeverity - ), filteredTopInfluencers: (selectionInfluencers.length > 0 || influencersFilterQuery !== undefined) && anomalyChartRecords !== undefined && @@ -280,9 +281,6 @@ const loadExplorerDataProvider = ( influencersFilterQuery ), }).pipe( - tap(({ anomalyChartsData }) => { - explorerService.setCharts(anomalyChartsData as ExplorerChartsData); - }), map(({ viewBySwimlaneState, filteredTopInfluencers }) => { return { overallAnnotations, diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_context_menu.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_context_menu.tsx index 9f65449169ee6..d40e9cae1a04f 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_context_menu.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_context_menu.tsx @@ -66,7 +66,7 @@ export const AnomalyContextMenu: FC = ({ return ( <> - {menuItems.length > 0 && ( + {menuItems.length > 0 && chartsCount > 0 && ( = React.memo( overallAnnotations, } = explorerState; + const annotations = useMemo(() => overallAnnotations.annotationsData, [overallAnnotations]); + const menuItems = useMemo(() => { const items = []; if (canEditDashboards) { @@ -241,7 +243,7 @@ export const AnomalyTimeline: FC = React.memo( isLoading={loading} noDataWarning={} showTimeline={false} - annotationsData={overallAnnotations.annotationsData} + annotationsData={annotations} /> diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index faab658740a70..2365e4e468902 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { Duration } from 'moment'; import { ML_RESULTS_INDEX_PATTERN } from '../../../../../common/constants/index_patterns'; import { Dictionary } from '../../../../../common/types/common'; @@ -25,6 +24,7 @@ import { import { AnnotationsTable } from '../../../../../common/types/annotations'; import { SWIM_LANE_DEFAULT_PAGE_SIZE } from '../../explorer_constants'; import { InfluencersFilterQuery } from '../../../../../common/types/es_client'; +import { TimeBucketsInterval } from '../../../util/time_buckets'; export interface ExplorerState { overallAnnotations: AnnotationsTable; @@ -46,7 +46,7 @@ export interface ExplorerState { queryString: string; selectedCells: AppStateSelectedCells | undefined; selectedJobs: ExplorerJob[] | null; - swimlaneBucketInterval: Duration | undefined; + swimlaneBucketInterval: TimeBucketsInterval | undefined; swimlaneContainerWidth: number; tableData: AnomaliesTableData; tableQueryString: string; diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_annotation_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_annotation_container.tsx index 686413ff0188b..f106aed84aa79 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_annotation_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_annotation_container.tsx @@ -143,7 +143,7 @@ export const SwimlaneAnnotationContainer: FC = .on('mouseout', () => tooltipService.hide()); }); } - }, [chartWidth, domain, annotationsData]); + }, [chartWidth, domain, annotationsData, tooltipService]); return
; }; diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index 0f445a4872417..41bbe5b66a605 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -369,13 +369,17 @@ export const SwimlaneContainer: FC = ({ [swimlaneData?.fieldName] ); - const xDomain = swimlaneData - ? { - min: swimlaneData.earliest * 1000, - max: swimlaneData.latest * 1000, - minInterval: swimlaneData.interval * 1000, - } - : undefined; + const xDomain = useMemo( + () => + swimlaneData + ? { + min: swimlaneData.earliest * 1000, + max: swimlaneData.latest * 1000, + minInterval: swimlaneData.interval * 1000, + } + : undefined, + [swimlaneData] + ); // A resize observer is required to compute the bucket span based on the chart width to fetch the data accordingly return ( @@ -392,6 +396,7 @@ export const SwimlaneContainer: FC = ({ style={{ width: '100%', overflowY: 'auto', + overflowX: 'hidden', }} grow={false} > @@ -403,11 +408,7 @@ export const SwimlaneContainer: FC = ({ onElementClick={onElementClick} showLegend={showLegend} legendPosition={Position.Top} - xDomain={{ - min: swimlaneData.earliest * 1000, - max: swimlaneData.latest * 1000, - minInterval: swimlaneData.interval * 1000, - }} + xDomain={xDomain} tooltip={tooltipOptions} debugState={window._echDebugStateFlag ?? false} /> diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.ts index 5995224ef3254..21d8413f1a704 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.ts @@ -106,6 +106,8 @@ function findFieldsInQuery(obj: object) { if (isPopulatedObject(val)) { fields.push(key); fields.push(...findFieldsInQuery(val)); + } else if (typeof val === 'string') { + fields.push(val); } else { fields.push(key); } diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts index ac61e11b1128e..5b4a5ce87a008 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts @@ -102,6 +102,7 @@ describe('AnomalyExplorerChartsService', () => { test('should return anomaly data without explorer service', async () => { const anomalyData = (await anomalyExplorerService.getAnomalyData( + undefined, (combinedJobRecords as unknown) as Record, 1000, mockAnomalyChartRecords, @@ -116,6 +117,7 @@ describe('AnomalyExplorerChartsService', () => { test('call anomalyChangeListener with empty series config', async () => { const anomalyData = (await anomalyExplorerService.getAnomalyData( + undefined, // @ts-ignore (combinedJobRecords as unknown) as Record, 1000, @@ -137,6 +139,7 @@ describe('AnomalyExplorerChartsService', () => { mockAnomalyChartRecordsClone[1].partition_field_value = 'AAL.'; const anomalyData = (await anomalyExplorerService.getAnomalyData( + undefined, (combinedJobRecords as unknown) as Record, 1000, mockAnomalyChartRecordsClone, diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts index d760ff9455a88..253868343a2a7 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts @@ -40,6 +40,7 @@ import { TimeRangeBounds } from '../util/time_buckets'; import { isDefined } from '../../../common/types/guards'; import { AppStateSelectedCells } from '../explorer/explorer_utils'; import { InfluencersFilterQuery } from '../../../common/types/es_client'; +import { ExplorerService } from '../explorer/explorer_dashboard_service'; const CHART_MAX_POINTS = 500; const ANOMALIES_MAX_RESULTS = 500; const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket. @@ -427,6 +428,7 @@ export class AnomalyExplorerChartsService { } public async getAnomalyData( + explorerService: ExplorerService | undefined, combinedJobRecords: Record, chartsContainerWidth: number, anomalyRecords: ChartRecord[] | undefined, @@ -534,6 +536,11 @@ export class AnomalyExplorerChartsService { data.errorMessages = errorMessages; } + // TODO: replace this temporary fix for flickering issue + // https://github.com/elastic/kibana/issues/97266 + if (explorerService) { + explorerService.setCharts({ ...data }); + } if (seriesConfigs.length === 0) { return data; } @@ -895,6 +902,12 @@ export class AnomalyExplorerChartsService { // push map data in if it's available data.seriesToPlot.push(...mapData); } + + // TODO: replace this temporary fix for flickering issue + if (explorerService) { + explorerService.setCharts({ ...data }); + } + return Promise.resolve(data); }) .catch((error) => { diff --git a/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts index 1521f62ac588d..54d9626edf26c 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts @@ -11,7 +11,12 @@ import { TimeRange, UI_SETTINGS, } from '../../../../../../src/plugins/data/public'; -import { getBoundsRoundedToInterval, TimeBuckets, TimeRangeBounds } from '../util/time_buckets'; +import { + getBoundsRoundedToInterval, + TimeBuckets, + TimeBucketsInterval, + TimeRangeBounds, +} from '../util/time_buckets'; import { ExplorerJob, OverallSwimlaneData, @@ -92,9 +97,10 @@ export class AnomalyTimelineService { */ public async loadOverallData( selectedJobs: ExplorerJob[], - chartWidth: number + chartWidth?: number, + bucketInterval?: TimeBucketsInterval ): Promise { - const interval = this.getSwimlaneBucketInterval(selectedJobs, chartWidth); + const interval = bucketInterval ?? this.getSwimlaneBucketInterval(selectedJobs, chartWidth!); if (!selectedJobs || !selectedJobs.length) { throw new Error('Explorer jobs collection is required'); @@ -129,9 +135,6 @@ export class AnomalyTimelineService { interval.asSeconds() ); - // eslint-disable-next-line no-console - console.log('Explorer overall swim lane data set:', overallSwimlaneData); - return overallSwimlaneData; } @@ -156,8 +159,9 @@ export class AnomalyTimelineService { swimlaneLimit: number, perPage: number, fromPage: number, - swimlaneContainerWidth: number, - influencersFilterQuery?: any + swimlaneContainerWidth?: number, + influencersFilterQuery?: any, + bucketInterval?: TimeBucketsInterval ): Promise { const timefilterBounds = this.getTimeBounds(); @@ -165,10 +169,8 @@ export class AnomalyTimelineService { throw new Error('timeRangeSelectorEnabled has to be enabled'); } - const swimlaneBucketInterval = this.getSwimlaneBucketInterval( - selectedJobs, - swimlaneContainerWidth - ); + const swimlaneBucketInterval = + bucketInterval ?? this.getSwimlaneBucketInterval(selectedJobs, swimlaneContainerWidth!); const searchBounds = getBoundsRoundedToInterval( timefilterBounds, @@ -222,8 +224,6 @@ export class AnomalyTimelineService { viewBySwimlaneFieldName, swimlaneBucketInterval.asSeconds() ); - // eslint-disable-next-line no-console - console.log('Explorer view by swim lane data set:', viewBySwimlaneData); return viewBySwimlaneData; } diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js index 9eb2390b4bf99..23fe648a67598 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js @@ -572,7 +572,6 @@ class TimeseriesChartIntl extends Component { showAnnotations, showForecast, showModelBounds, - zoomFromFocusLoaded, zoomToFocusLoaded, } = this.props; @@ -641,7 +640,7 @@ class TimeseriesChartIntl extends Component { let yMax = 0; let combinedData = data; - if (focusForecastData !== undefined && focusForecastData.length > 0) { + if (showForecast && focusForecastData !== undefined && focusForecastData.length > 0) { combinedData = data.concat(focusForecastData); } @@ -969,7 +968,6 @@ class TimeseriesChartIntl extends Component { contextForecastData, modelPlotEnabled, annotationData, - showAnnotations, } = this.props; const data = contextChartData; @@ -985,8 +983,10 @@ class TimeseriesChartIntl extends Component { contextForecastData === undefined ? data : data.concat(contextForecastData); const valuesRange = { min: Number.MAX_VALUE, max: Number.MIN_VALUE }; each(combinedData, (item) => { - valuesRange.min = Math.min(item.value, valuesRange.min); - valuesRange.max = Math.max(item.value, valuesRange.max); + const lowerBound = item.lower ?? Number.MAX_VALUE; + const upperBound = item.upper ?? Number.MIN_VALUE; + valuesRange.min = Math.min(item.value, lowerBound, valuesRange.min); + valuesRange.max = Math.max(item.value, upperBound, valuesRange.max); }); let dataMin = valuesRange.min; let dataMax = valuesRange.max; @@ -1022,9 +1022,7 @@ class TimeseriesChartIntl extends Component { .domain([chartLimits.min, chartLimits.max]); const borders = cxtGroup.append('g').attr('class', 'axis'); - const brushChartHeight = showAnnotations - ? cxtChartHeight + swlHeight + annotationHeight - : cxtChartHeight + swlHeight; + const brushChartHeight = cxtChartHeight + swlHeight + annotationHeight; // Add borders left and right. borders.append('line').attr('x1', 0).attr('y1', 0).attr('x2', 0).attr('y2', brushChartHeight); @@ -1099,7 +1097,7 @@ class TimeseriesChartIntl extends Component { const ctxAnnotations = cxtGroup .select('.mlContextAnnotations') .selectAll('g.mlContextAnnotation') - .data(showAnnotations && annotationData ? annotationData : [], (d) => d._id || ''); + .data(annotationData, (d) => d._id || ''); ctxAnnotations.enter().append('g').classed('mlContextAnnotation', true); @@ -1144,7 +1142,6 @@ class TimeseriesChartIntl extends Component { return width; }); - ctxAnnotations.classed('mlAnnotationHidden', !showAnnotations); ctxAnnotationRects.exit().remove(); // Create the path elements for the forecast value line and bounds area. diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts index 703851f3fe9b6..78874ea4299ff 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts @@ -14,13 +14,11 @@ import { MlStartDependencies } from '../../plugin'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/public'; import { AppStateSelectedCells, - ExplorerJob, getSelectionInfluencers, getSelectionJobIds, getSelectionTimeRange, } from '../../application/explorer/explorer_utils'; import { OVERALL_LABEL, SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; -import { parseInterval } from '../../../common/util/parse_interval'; import { AnomalyChartsEmbeddableInput, AnomalyChartsEmbeddableOutput, @@ -76,8 +74,8 @@ export function useAnomalyChartsInputResolver( .pipe( tap(setIsLoading.bind(null, true)), debounceTime(FETCH_RESULTS_DEBOUNCE_MS), - switchMap(([jobs, input, embeddableContainerWidth, severityValue]) => { - if (!jobs) { + switchMap(([explorerJobs, input, embeddableContainerWidth, severityValue]) => { + if (!explorerJobs) { // couldn't load the list of jobs return of(undefined); } @@ -88,15 +86,6 @@ export function useAnomalyChartsInputResolver( anomalyExplorerService.setTimeRange(timeRangeInput); - const explorerJobs: ExplorerJob[] = jobs.map((job) => { - const bucketSpan = parseInterval(job.analysis_config.bucket_span); - return { - id: job.job_id, - selected: true, - bucketSpanSeconds: bucketSpan!.asSeconds(), - }; - }); - let influencersFilterQuery: InfluencersFilterQuery; try { influencersFilterQuery = processFilters(filters, query); @@ -144,6 +133,7 @@ export function useAnomalyChartsInputResolver( return forkJoin({ chartsData: from( anomalyExplorerService.getAnomalyData( + undefined, combinedJobRecords, embeddableContainerWidth, anomalyChartRecords, diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts index 4d2e2406376e2..01b1e3acf7f95 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts @@ -55,6 +55,11 @@ describe('useSwimlaneInputResolver', () => { points: [], }) ), + getSwimlaneBucketInterval: jest.fn(() => { + return { + asSeconds: jest.fn(() => 900), + }; + }), }, anomalyDetectorService: { getJobs$: jest.fn((jobId: string[]) => { diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts index 4574c7e859c08..8b0c89bbd16b7 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts @@ -12,6 +12,8 @@ import { debounceTime, distinctUntilChanged, map, + pluck, + shareReplay, skipWhile, startWith, switchMap, @@ -27,8 +29,7 @@ import { SwimlaneType, } from '../../application/explorer/explorer_constants'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/public'; -import { ExplorerJob, OverallSwimlaneData } from '../../application/explorer/explorer_utils'; -import { parseInterval } from '../../../common/util/parse_interval'; +import { OverallSwimlaneData } from '../../application/explorer/explorer_utils'; import { isViewBySwimLaneData } from '../../application/explorer/swimlane_container'; import { ViewMode } from '../../../../../../src/plugins/embeddable/public'; import { @@ -43,7 +44,7 @@ import { getJobsObservable } from '../common/get_jobs_observable'; const FETCH_RESULTS_DEBOUNCE_MS = 500; export function useSwimlaneInputResolver( - embeddableInput: Observable, + embeddableInput$: Observable, onInputChange: (output: Partial) => void, refresh: Observable, services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices], @@ -67,6 +68,30 @@ export function useSwimlaneInputResolver( const [isLoading, setIsLoading] = useState(false); const chartWidth$ = useMemo(() => new Subject(), []); + + const selectedJobs$ = useMemo(() => { + return getJobsObservable(embeddableInput$, anomalyDetectorService, setError).pipe( + shareReplay(1) + ); + }, []); + + const bucketInterval$ = useMemo(() => { + return combineLatest([ + selectedJobs$, + chartWidth$, + embeddableInput$.pipe(pluck('timeRange')), + ]).pipe( + skipWhile(([jobs, width]) => !Array.isArray(jobs) || !width), + tap(([, , timeRange]) => { + anomalyTimelineService.setTimeRange(timeRange); + }), + map(([jobs, width]) => anomalyTimelineService.getSwimlaneBucketInterval(jobs!, width)), + distinctUntilChanged((prev, curr) => { + return prev.asSeconds() === curr.asSeconds(); + }) + ); + }, []); + const fromPage$ = useMemo(() => new Subject(), []); const perPage$ = useMemo(() => new Subject(), []); @@ -81,9 +106,9 @@ export function useSwimlaneInputResolver( useEffect(() => { const subscription = combineLatest([ - getJobsObservable(embeddableInput, anomalyDetectorService, setError), - embeddableInput, - chartWidth$.pipe(skipWhile((v) => !v)), + selectedJobs$, + embeddableInput$, + bucketInterval$, fromPage$, perPage$.pipe( startWith(undefined), @@ -97,8 +122,8 @@ export function useSwimlaneInputResolver( .pipe( tap(setIsLoading.bind(null, true)), debounceTime(FETCH_RESULTS_DEBOUNCE_MS), - switchMap(([jobs, input, swimlaneContainerWidth, fromPageInput, perPageFromState]) => { - if (!jobs) { + switchMap(([explorerJobs, input, bucketInterval, fromPageInput, perPageFromState]) => { + if (!explorerJobs) { // couldn't load the list of jobs return of(undefined); } @@ -107,27 +132,15 @@ export function useSwimlaneInputResolver( viewBy, swimlaneType: swimlaneTypeInput, perPage: perPageInput, - timeRange, filters, query, viewMode, } = input; - anomalyTimelineService.setTimeRange(timeRange); - if (!swimlaneType) { setSwimlaneType(swimlaneTypeInput); } - const explorerJobs: ExplorerJob[] = jobs.map((job) => { - const bucketSpan = parseInterval(job.analysis_config.bucket_span); - return { - id: job.job_id, - selected: true, - bucketSpanSeconds: bucketSpan!.asSeconds(), - }; - }); - let appliedFilters: any; try { appliedFilters = processFilters(filters, query, CONTROLLED_BY_SWIM_LANE_FILTER); @@ -138,7 +151,7 @@ export function useSwimlaneInputResolver( } return from( - anomalyTimelineService.loadOverallData(explorerJobs, swimlaneContainerWidth) + anomalyTimelineService.loadOverallData(explorerJobs, undefined, bucketInterval) ).pipe( switchMap((overallSwimlaneData) => { const { earliest, latest } = overallSwimlaneData; @@ -165,8 +178,9 @@ export function useSwimlaneInputResolver( : ANOMALY_SWIM_LANE_HARD_LIMIT, perPageFromState ?? perPageInput ?? SWIM_LANE_DEFAULT_PAGE_SIZE, fromPageInput, - swimlaneContainerWidth, - appliedFilters + undefined, + appliedFilters, + bucketInterval ) ).pipe( map((viewBySwimlaneData) => { diff --git a/x-pack/plugins/ml/public/embeddables/common/get_jobs_observable.ts b/x-pack/plugins/ml/public/embeddables/common/get_jobs_observable.ts index 6bdec30340b76..451eb95b4f801 100644 --- a/x-pack/plugins/ml/public/embeddables/common/get_jobs_observable.ts +++ b/x-pack/plugins/ml/public/embeddables/common/get_jobs_observable.ts @@ -6,10 +6,12 @@ */ import { Observable, of } from 'rxjs'; -import { catchError, distinctUntilChanged, pluck, switchMap } from 'rxjs/operators'; +import { catchError, distinctUntilChanged, map, pluck, switchMap } from 'rxjs/operators'; import { isEqual } from 'lodash'; import { AnomalyChartsEmbeddableInput, AnomalySwimlaneEmbeddableInput } from '../types'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; +import { ExplorerJob } from '../../application/explorer/explorer_utils'; +import { parseInterval } from '../../../common/util/parse_interval'; export function getJobsObservable( embeddableInput: Observable, @@ -20,6 +22,17 @@ export function getJobsObservable( pluck('jobIds'), distinctUntilChanged(isEqual), switchMap((jobsIds) => anomalyDetectorService.getJobs$(jobsIds)), + map((jobs) => { + const explorerJobs: ExplorerJob[] = jobs.map((job) => { + const bucketSpan = parseInterval(job.analysis_config.bucket_span); + return { + id: job.job_id, + selected: true, + bucketSpanSeconds: bucketSpan!.asSeconds(), + }; + }); + return explorerJobs; + }), catchError((e) => { setErrorHandler(e.body ?? e); return of(undefined); diff --git a/x-pack/plugins/monitoring/public/angular/index.ts b/x-pack/plugins/monitoring/public/angular/index.ts index be26f11c8aafa..0c605abb828bd 100644 --- a/x-pack/plugins/monitoring/public/angular/index.ts +++ b/x-pack/plugins/monitoring/public/angular/index.ts @@ -10,10 +10,10 @@ import { uiRoutes } from './helpers/routes'; import { Legacy } from '../legacy_shims'; import { configureAppAngularModule } from '../../../../../src/plugins/kibana_legacy/public'; import { localAppModule, appModuleName } from './app_modules'; +import { APP_WRAPPER_CLASS } from '../../../../../src/core/public'; import { MonitoringStartPluginDependencies } from '../types'; -const APP_WRAPPER_CLASS = 'monApplicationWrapper'; export class AngularApp { private injector?: angular.auto.IInjectorService; diff --git a/x-pack/plugins/monitoring/public/components/apm/apm_metrics.tsx b/x-pack/plugins/monitoring/public/components/apm/apm_metrics.tsx index 2f09b20efd8a1..dcfd3adf72168 100644 --- a/x-pack/plugins/monitoring/public/components/apm/apm_metrics.tsx +++ b/x-pack/plugins/monitoring/public/components/apm/apm_metrics.tsx @@ -24,13 +24,19 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { MonitoringTimeseriesContainer } from '../chart'; // @ts-ignore could not find declaration file import { Status } from './instance/status'; +import { checkAgentTypeMetric } from '../../lib/apm_agent'; +interface TitleType { + title?: string; + heading?: unknown; +} interface Props { - stats: unknown; + stats: { versions: string[]; [key: string]: unknown }; metrics: { [key: string]: unknown }; seriesToShow: unknown[]; title: string; summary: { + version: string; config: { container: boolean; }; @@ -47,10 +53,37 @@ const createCharts = (series: unknown[], props: Partial) => { }); }; +const getHeading = (isFleetTypeMetric: boolean) => { + const titles: TitleType = {}; + if (isFleetTypeMetric) { + titles.title = i18n.translate('xpack.monitoring.apm.metrics.topCharts.agentTitle', { + defaultMessage: 'APM & Fleet Server - Resource Usage', + }); + titles.heading = ( + + ); + } + titles.title = i18n.translate('xpack.monitoring.apm.metrics.topCharts.title', { + defaultMessage: 'APM Server - Resource Usage', + }); + titles.heading = ( + + ); + return titles; +}; + export const ApmMetrics = ({ stats, metrics, seriesToShow, title, summary, ...props }: Props) => { if (!metrics) { return null; } + + const versions = summary?.version ? [summary?.version] : stats.versions; + const isFleetTypeMetric = checkAgentTypeMetric(versions); + const titles = getHeading(isFleetTypeMetric); + const topSeries = [metrics.apm_cpu, metrics.apm_os_load]; const { config } = summary || stats; topSeries.push(config.container ? metrics.apm_memory_cgroup : metrics.apm_memory); @@ -59,12 +92,7 @@ export const ApmMetrics = ({ stats, metrics, seriesToShow, title, summary, ...pr -

- -

+

{titles.heading as FormattedMessage}

@@ -72,11 +100,7 @@ export const ApmMetrics = ({ stats, metrics, seriesToShow, title, summary, ...pr -

- {i18n.translate('xpack.monitoring.apm.metrics.topCharts.nonAgentTitle', { - defaultMessage: 'APM Server - Resource Usage', - })} -

+

{titles.title}

{createCharts(topSeries, props)} diff --git a/x-pack/plugins/monitoring/public/components/cluster/overview/apm_panel.js b/x-pack/plugins/monitoring/public/components/cluster/overview/apm_panel.js index a8b71bbfb234d..64afe8988e8bf 100644 --- a/x-pack/plugins/monitoring/public/components/cluster/overview/apm_panel.js +++ b/x-pack/plugins/monitoring/public/components/cluster/overview/apm_panel.js @@ -30,15 +30,78 @@ import { SetupModeTooltip } from '../../setup_mode/tooltip'; import { getSafeForExternalLink } from '../../../lib/get_safe_for_external_link'; import { isSetupModeFeatureEnabled } from '../../../lib/setup_mode'; import { SetupModeFeature } from '../../../../common/enums'; +import { checkAgentTypeMetric } from '../../../lib/apm_agent'; + +const getServerTitle = (isFleetTypeMetric, total) => { + const apmsTotal = {total}; + const linkLabel = {}; + if (isFleetTypeMetric) { + linkLabel.link = ( + + ); + linkLabel.aria = i18n.translate( + 'xpack.monitoring.cluster.overview.apmPanel.instancesAndFleetsTotalLinkAriaLabel', + { + defaultMessage: 'APM and Fleet server instances: {apmsTotal}', + values: { apmsTotal }, + } + ); + } + linkLabel.link = ( + + ); + linkLabel.aria = i18n.translate( + 'xpack.monitoring.cluster.overview.apmPanel.instancesTotalLinkAriaLabel', + { + defaultMessage: 'APM server instances: {apmsTotal}', + values: { apmsTotal }, + } + ); + + return linkLabel; +}; + +const getOverviewTitle = (isFleetTypeMetric) => { + if (isFleetTypeMetric) { + return i18n.translate('xpack.monitoring.cluster.overview.apmPanel.overviewFleetLinkLabel', { + defaultMessage: 'APM & Fleet server overview', + }); + } + return i18n.translate('xpack.monitoring.cluster.overview.apmPanel.overviewLinkLabel', { + defaultMessage: 'APM server overview', + }); +}; + +const getHeadingTitle = (isFleetTypeMetric) => { + if (isFleetTypeMetric) { + return i18n.translate('xpack.monitoring.cluster.overview.apmPanel.apmFleetTitle', { + defaultMessage: 'APM & Fleet server', + }); + } + return i18n.translate('xpack.monitoring.cluster.overview.apmPanel.apmTitle', { + defaultMessage: 'APM server', + }); +}; export function ApmPanel(props) { - const { setupMode } = props; + const { setupMode, versions } = props; const apmsTotal = get(props, 'apms.total') || 0; // Do not show if we are not in setup mode if (apmsTotal === 0 && !setupMode.enabled) { return null; } + const isFleetTypeMetric = checkAgentTypeMetric(versions); + const { link, aria } = getServerTitle(isFleetTypeMetric, apmsTotal); + const overviewTitle = getOverviewTitle(isFleetTypeMetric); const goToInstances = () => getSafeForExternalLink('#/apm/instances'); const setupModeData = get(setupMode.data, 'apm'); const setupModeMetricbeatMigrationTooltip = isSetupModeFeatureEnabled( @@ -52,13 +115,7 @@ export function ApmPanel(props) { ) : null; return ( - + @@ -68,18 +125,10 @@ export function ApmPanel(props) { setupModeEnabled={setupMode.enabled} setupModeData={setupModeData} href={getSafeForExternalLink('#/apm')} - aria-label={i18n.translate( - 'xpack.monitoring.cluster.overview.apmPanel.overviewLinkAriaLabel', - { - defaultMessage: 'APM server overview', - } - )} + aria-label={overviewTitle} data-test-subj="apmOverview" > - + {overviewTitle} @@ -121,22 +170,8 @@ export function ApmPanel(props) {

- - + + {link}

diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/__snapshots__/cells.test.js.snap b/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/__snapshots__/cells.test.js.snap index 89b32d37a7445..7a18ca31830a2 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/__snapshots__/cells.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/__snapshots__/cells.test.js.snap @@ -31,13 +31,19 @@ exports[`Node Listing Metric Cell should format a non-percentage metric 1`] = `
- + type="button" + > +
@@ -78,13 +84,19 @@ exports[`Node Listing Metric Cell should format a percentage metric 1`] = `
- + type="button" + > +
diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/cells.js b/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/cells.js index 465e9f1e49a5a..528b3bed3df7b 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/cells.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/cells.js @@ -11,10 +11,9 @@ import { formatMetric } from '../../../lib/format_number'; import { EuiText, EuiPopover, - EuiIcon, + EuiButtonIcon, EuiDescriptionList, EuiSpacer, - EuiKeyboardAccessible, EuiFlexGroup, EuiFlexItem, } from '@elastic/eui'; @@ -40,7 +39,7 @@ const getDirection = (slope) => { const getIcon = (slope) => { if (slope || slope === 0) { - return slope > 0 ? 'arrowUp' : 'arrowDown'; + return slope > 0 ? 'sortUp' : 'sortDown'; } return null; }; @@ -83,17 +82,22 @@ function MetricCell({ isOnline, metric = {}, isPercent, ...props }) { }, ]; + const iconLabel = i18n.translate( + 'xpack.monitoring.elasticsearch.node.cells.tooltip.iconLabel', + { + defaultMessage: 'More information about this metric', + } + ); + const button = ( - - - + ); return ( diff --git a/x-pack/plugins/monitoring/public/index.scss b/x-pack/plugins/monitoring/public/index.scss index e25885debebdd..99b8d1ecfd337 100644 --- a/x-pack/plugins/monitoring/public/index.scss +++ b/x-pack/plugins/monitoring/public/index.scss @@ -6,9 +6,3 @@ // monChart__legend // monChart__legend--small // monChart__legend-isLoading - -.monApplicationWrapper { - display: flex; - flex-direction: column; - flex-grow: 1; -} diff --git a/x-pack/plugins/monitoring/public/lib/apm_agent.ts b/x-pack/plugins/monitoring/public/lib/apm_agent.ts new file mode 100644 index 0000000000000..8884557782126 --- /dev/null +++ b/x-pack/plugins/monitoring/public/lib/apm_agent.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Legacy } from '../legacy_shims'; + +/** + * Possible temporary work arround to establish if APM might also be monitoring fleet: + * https://github.com/elastic/kibana/pull/95129/files#r604815886 + */ +export const checkAgentTypeMetric = (versions?: string[]) => { + if (!Legacy.shims.isCloud || !versions) { + return false; + } + versions.forEach((version) => { + const [major, minor] = version.split('.'); + const majorInt = Number(major); + if (majorInt > 7 || (majorInt === 7 && Number(minor) >= 13)) { + return true; + } + }); + return false; +}; diff --git a/x-pack/plugins/monitoring/server/lib/apm/_apm_stats.js b/x-pack/plugins/monitoring/server/lib/apm/_apm_stats.js index bdcd4d07f2c67..0dfcbfff834d8 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/_apm_stats.js +++ b/x-pack/plugins/monitoring/server/lib/apm/_apm_stats.js @@ -24,6 +24,7 @@ export const apmAggFilterPath = [ 'aggregations.min_mem_rss_total.value', 'aggregations.max_mem_rss_total.value', 'aggregations.max_mem_total_total.value', + 'aggregations.versions.buckets', ]; export const apmUuidsAgg = (maxBucketSize) => ({ @@ -33,6 +34,11 @@ export const apmUuidsAgg = (maxBucketSize) => ({ precision_threshold: 10000, }, }, + versions: { + terms: { + field: 'beats_stats.beat.version', + }, + }, ephemeral_ids: { terms: { field: 'beats_stats.metrics.beat.info.ephemeral_id', @@ -101,11 +107,13 @@ export const apmAggResponseHandler = (response) => { const memRssMax = get(response, 'aggregations.max_mem_rss_total.value', 0); const memRssMin = get(response, 'aggregations.min_mem_rss_total.value', 0); const memTotal = get(response, 'aggregations.max_mem_total_total.value', 0); + const versions = get(response, 'aggregations.versions.buckets', []).map(({ key }) => key); return { apmTotal, totalEvents: getDiffCalculation(eventsTotalMax, eventsTotalMin), memRss: getDiffCalculation(memRssMax, memRssMin), memTotal, + versions, }; }; diff --git a/x-pack/plugins/monitoring/server/lib/apm/get_apms_for_clusters.js b/x-pack/plugins/monitoring/server/lib/apm/get_apms_for_clusters.js index ce40c52cdde25..3ece0af0369fd 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/get_apms_for_clusters.js +++ b/x-pack/plugins/monitoring/server/lib/apm/get_apms_for_clusters.js @@ -13,7 +13,7 @@ import { apmAggResponseHandler, apmUuidsAgg, apmAggFilterPath } from './_apm_sta import { getTimeOfLastEvent } from './_get_time_of_last_event'; export function handleResponse(clusterUuid, response) { - const { apmTotal, totalEvents, memRss, memTotal } = apmAggResponseHandler(response); + const { apmTotal, totalEvents, memRss, memTotal, versions } = apmAggResponseHandler(response); // combine stats const stats = { @@ -23,6 +23,7 @@ export function handleResponse(clusterUuid, response) { apms: { total: apmTotal, }, + versions, }; return { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx index 530b8dee3a4d2..8d3060792857e 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx @@ -8,12 +8,13 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { FilterExpanded } from './filter_expanded'; -import { mockUrlStorage, mockUseValuesList, render } from '../../rtl_helpers'; +import { mockAppIndexPattern, mockUrlStorage, mockUseValuesList, render } from '../../rtl_helpers'; import { USER_AGENT_NAME } from '../../configurations/constants/elasticsearch_fieldnames'; describe('FilterExpanded', function () { it('should render properly', async function () { mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + mockAppIndexPattern(); render( field === fd); - const displayValues = (values || []).filter((opt) => - opt.toLowerCase().includes(value.toLowerCase()) - ); + const displayValues = values.filter((opt) => opt.toLowerCase().includes(value.toLowerCase())); return ( @@ -60,50 +56,70 @@ export function FilterExpanded({ seriesId, field, label, goBack, nestedField, is { setValue(evt.target.value); }} + placeholder={i18n.translate('xpack.observability.filters.expanded.search', { + defaultMessage: 'Search for {label}', + values: { label }, + })} /> - {loading && ( -
- -
- )} - {displayValues.map((opt) => ( - - - {isNegated !== false && ( + + {displayValues.map((opt) => ( + + + {isNegated !== false && ( + + )} - )} - - - - - ))} + + + + ))} +
); } +const ListWrapper = euiStyled.div` + height: 400px; + overflow-y: auto; + &::-webkit-scrollbar { + height: ${({ theme }) => theme.eui.euiScrollBar}; + width: ${({ theme }) => theme.eui.euiScrollBar}; + } + &::-webkit-scrollbar-thumb { + background-clip: content-box; + background-color: ${({ theme }) => rgba(theme.eui.euiColorDarkShade, 0.5)}; + border: ${({ theme }) => theme.eui.euiScrollBarCorner} solid transparent; + } + &::-webkit-scrollbar-corner, + &::-webkit-scrollbar-track { + background-color: transparent; + } +`; + const Wrapper = styled.div` - max-width: 400px; + width: 400px; `; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx index 88cb538263419..2d82aca658ec3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx @@ -119,7 +119,7 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P button={button} isOpen={isPopoverVisible} closePopover={closePopover} - anchorPosition="leftCenter" + anchorPosition={isNew ? 'leftCenter' : 'rightCenter'} > {!selectedField ? mainPanel : childPanel}
diff --git a/x-pack/plugins/observability/public/hooks/use_es_search.ts b/x-pack/plugins/observability/public/hooks/use_es_search.ts new file mode 100644 index 0000000000000..b6ee4a63823b1 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_es_search.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { estypes } from '@elastic/elasticsearch'; +import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import { ESSearchResponse } from '../../../../../typings/elasticsearch'; +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; +import { isCompleteResponse } from '../../../../../src/plugins/data/common'; +import { useFetcher } from './use_fetcher'; + +export const useEsSearch = ( + params: TParams, + fnDeps: any[] +) => { + const { + services: { data }, + } = useKibana<{ data: DataPublicPluginStart }>(); + + const { data: response = {}, loading } = useFetcher(() => { + return new Promise((resolve) => { + const search$ = data.search + .search({ + params, + }) + .subscribe({ + next: (result) => { + if (isCompleteResponse(result)) { + // Final result + resolve(result); + search$.unsubscribe(); + } + }, + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...fnDeps]); + + const { rawResponse } = response as any; + + return { data: rawResponse as ESSearchResponse, loading }; +}; + +export function createEsParams(params: T): T { + return params; +} diff --git a/x-pack/plugins/observability/public/hooks/use_values_list.ts b/x-pack/plugins/observability/public/hooks/use_values_list.ts index e17f515ed6cb9..147a66f3d505e 100644 --- a/x-pack/plugins/observability/public/hooks/use_values_list.ts +++ b/x-pack/plugins/observability/public/hooks/use_values_list.ts @@ -5,11 +5,12 @@ * 2.0. */ +import { capitalize, merge } from 'lodash'; +import { useEffect, useState } from 'react'; +import { useDebounce } from 'react-use'; import { IndexPattern } from '../../../../../src/plugins/data/common'; -import { useKibana } from '../../../../../src/plugins/kibana_react/public'; -import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; -import { useFetcher } from './use_fetcher'; import { ESFilter } from '../../../../../typings/elasticsearch'; +import { createEsParams, useEsSearch } from './use_es_search'; export interface Props { sourceField: string; @@ -17,6 +18,7 @@ export interface Props { indexPattern: IndexPattern; filters?: ESFilter[]; time?: { from: string; to: string }; + keepHistory?: boolean; } export const useValuesList = ({ @@ -25,38 +27,83 @@ export const useValuesList = ({ query = '', filters, time, + keepHistory, }: Props): { values: string[]; loading?: boolean } => { - const { - services: { data }, - } = useKibana<{ data: DataPublicPluginStart }>(); + const [debouncedQuery, setDebounceQuery] = useState(query); + const [values, setValues] = useState([]); const { from, to } = time ?? {}; - const { data: values, loading } = useFetcher(() => { - if (!sourceField || !indexPattern) { - return []; + let includeClause = ''; + + if (query) { + if (query[0].toLowerCase() === query[0]) { + // if first letter is lowercase we also add the capitalize option + includeClause = `(${query}|${capitalize(query)}).*`; + } else { + // otherwise we add lowercase option prefix + includeClause = `(${query}|${query.toLowerCase()}).*`; } - return data.autocomplete.getValueSuggestions({ - indexPattern, - query: query || '', - useTimeRange: !(from && to), - field: indexPattern.getFieldByName(sourceField)!, - boolFilter: - from && to - ? [ - ...(filters || []), - { - range: { - '@timestamp': { - gte: from, - lte: to, - }, - }, - }, - ] - : filters || [], - }); - }, [query, sourceField, data.autocomplete, indexPattern, from, to, filters]); - - return { values: values as string[], loading }; + } + + useDebounce( + () => { + setDebounceQuery(query); + }, + 350, + [query] + ); + + const { data, loading } = useEsSearch( + createEsParams({ + index: indexPattern.title, + body: { + query: { + bool: { + filter: [ + ...(filters ?? []), + ...(from && to + ? [ + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ] + : []), + ], + }, + }, + size: 0, + aggs: { + values: { + terms: { + field: sourceField, + size: 100, + ...(query ? { include: includeClause } : {}), + }, + }, + }, + }, + }), + [debouncedQuery, from, to] + ); + + useEffect(() => { + const newValues = + data?.aggregations?.values.buckets.map(({ key: value }) => value as string) ?? []; + + if (keepHistory) { + setValues((prevState) => { + return merge(newValues, prevState); + }); + } else { + setValues(newValues); + } + }, [data, keepHistory, loading]); + + return { values, loading }; }; diff --git a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css index 86183694330e2..508d217cdd030 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css +++ b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css @@ -23,11 +23,6 @@ filter-bar, display: none !important; } -/* override open/closed positioning of the app wrapper/nav */ -.app-wrapper { - left: 0px !important; -} - /** * Discover Tweaks */ diff --git a/x-pack/plugins/reporting/server/lib/layouts/print.css b/x-pack/plugins/reporting/server/lib/layouts/print.css index fa963ac72ab41..92cbb327a3216 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/print.css +++ b/x-pack/plugins/reporting/server/lib/layouts/print.css @@ -23,11 +23,6 @@ filter-bar, display: none !important; } -/* override open/closed positioning of the app wrapper/nav */ -.app-wrapper { - left: 0px !important; -} - /** * Discover Tweaks */ diff --git a/x-pack/plugins/reporting/server/lib/screenshots/constants.ts b/x-pack/plugins/reporting/server/lib/screenshots/constants.ts index fb5220fa39555..3d8c50782deed 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/constants.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/constants.ts @@ -5,7 +5,8 @@ * 2.0. */ -export const DEFAULT_PAGELOAD_SELECTOR = '.application'; +import { APP_WRAPPER_CLASS } from '../../../../../../src/core/server'; +export const DEFAULT_PAGELOAD_SELECTOR = `.${APP_WRAPPER_CLASS}`; export const CONTEXT_GETNUMBEROFITEMS = 'GetNumberOfItems'; export const CONTEXT_GETBROWSERDIMENSIONS = 'GetBrowserDimensions'; diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts index 31726fa42a9cb..5419775f14407 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts @@ -204,7 +204,7 @@ describe('Screenshot Observable Pipeline', () => { expect(mockOpen.mock.calls.length).toBe(2); const firstSelector = mockOpen.mock.calls[0][1].waitForSelector; - expect(firstSelector).toBe('.application'); + expect(firstSelector).toBe('.kbnAppWrapper'); const secondSelector = mockOpen.mock.calls[1][1].waitForSelector; expect(secondSelector).toBe('[data-shared-page="2"]'); diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts index 70e5b89af7e82..7405e8cff8975 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import { ReportingCore } from '../..'; +import { APP_WRAPPER_CLASS } from '../../../../../../src/core/server'; import { API_DIAGNOSE_URL } from '../../../common/constants'; import { omitBlockedHeaders } from '../../export_types/common'; import { getAbsoluteUrlFactory } from '../../export_types/common/get_absolute_url'; @@ -47,8 +48,8 @@ export const registerDiagnoseScreenshot = (reporting: ReportingCore, logger: Log height: 2024, }, selectors: { - screenshot: '.application', - renderComplete: '.application', + screenshot: `.${APP_WRAPPER_CLASS}`, + renderComplete: `.${APP_WRAPPER_CLASS}`, itemsCountAttribute: 'data-test-subj="kibanaChrome"', timefilterDurationAttribute: 'data-test-subj="kibanaChrome"', }, diff --git a/x-pack/plugins/security/public/authentication/authentication_service.ts b/x-pack/plugins/security/public/authentication/authentication_service.ts index 4f0f69ac68886..202fc1b98452c 100644 --- a/x-pack/plugins/security/public/authentication/authentication_service.ts +++ b/x-pack/plugins/security/public/authentication/authentication_service.ts @@ -42,6 +42,11 @@ export interface AuthenticationServiceSetup { areAPIKeysEnabled: () => Promise; } +/** + * Start has the same contract as Setup for now. + */ +export type AuthenticationServiceStart = AuthenticationServiceSetup; + export class AuthenticationService { public setup({ application, diff --git a/x-pack/plugins/security/public/authentication/index.mock.ts b/x-pack/plugins/security/public/authentication/index.mock.ts index 47c9dad012eae..092126e6cfeed 100644 --- a/x-pack/plugins/security/public/authentication/index.mock.ts +++ b/x-pack/plugins/security/public/authentication/index.mock.ts @@ -5,11 +5,18 @@ * 2.0. */ -import type { AuthenticationServiceSetup } from './authentication_service'; +import type { + AuthenticationServiceSetup, + AuthenticationServiceStart, +} from './authentication_service'; export const authenticationMock = { createSetup: (): jest.Mocked => ({ getCurrentUser: jest.fn(), areAPIKeysEnabled: jest.fn(), }), + createStart: (): jest.Mocked => ({ + getCurrentUser: jest.fn(), + areAPIKeysEnabled: jest.fn(), + }), }; diff --git a/x-pack/plugins/security/public/authentication/index.ts b/x-pack/plugins/security/public/authentication/index.ts index 74b4740d31ef0..50d6b0c74376e 100644 --- a/x-pack/plugins/security/public/authentication/index.ts +++ b/x-pack/plugins/security/public/authentication/index.ts @@ -5,4 +5,8 @@ * 2.0. */ -export { AuthenticationService, AuthenticationServiceSetup } from './authentication_service'; +export { + AuthenticationService, + AuthenticationServiceSetup, + AuthenticationServiceStart, +} from './authentication_service'; diff --git a/x-pack/plugins/security/public/mocks.ts b/x-pack/plugins/security/public/mocks.ts index cac556d04031e..829c3ced9dddb 100644 --- a/x-pack/plugins/security/public/mocks.ts +++ b/x-pack/plugins/security/public/mocks.ts @@ -21,6 +21,7 @@ function createSetupMock() { } function createStartMock() { return { + authc: authenticationMock.createStart(), navControlService: navControlServiceMock.createStart(), }; } diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index fa9d11422e884..d3794ddbeb1a6 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -103,6 +103,10 @@ describe('Security Plugin', () => { features: {} as FeaturesPluginStart, }) ).toEqual({ + authc: { + getCurrentUser: expect.any(Function), + areAPIKeysEnabled: expect.any(Function), + }, navControlService: { getUserMenuLinks$: expect.any(Function), addUserMenuLinks: expect.any(Function), diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index 5d86f15174633..c805d9f1caf79 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -21,7 +21,7 @@ import type { LicensingPluginSetup } from '../../licensing/public'; import type { SpacesPluginStart } from '../../spaces/public'; import { SecurityLicenseService } from '../common/licensing'; import { accountManagementApp } from './account_management'; -import type { AuthenticationServiceSetup } from './authentication'; +import type { AuthenticationServiceSetup, AuthenticationServiceStart } from './authentication'; import { AuthenticationService } from './authentication'; import type { ConfigType } from './config'; import { ManagementService } from './management'; @@ -153,7 +153,10 @@ export class SecurityPlugin this.managementService.start({ capabilities: core.application.capabilities }); } - return { navControlService: this.navControlService.start({ core }) }; + return { + navControlService: this.navControlService.start({ core }), + authc: this.authc as AuthenticationServiceStart, + }; } public stop() { diff --git a/x-pack/plugins/security_solution/public/common/components/page/index.tsx b/x-pack/plugins/security_solution/public/common/components/page/index.tsx index 3c02f0075eec4..30b89086fb99c 100644 --- a/x-pack/plugins/security_solution/public/common/components/page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/page/index.tsx @@ -32,7 +32,7 @@ export const AppGlobalStyle = createGlobalStyle<{ theme: { eui: { euiColorPrimar overflow: hidden; } - div.app-wrapper { + div.kbnAppWrapper { background-color: rgba(0,0,0,0); } diff --git a/x-pack/plugins/security_solution/public/common/components/toasters/index.tsx b/x-pack/plugins/security_solution/public/common/components/toasters/index.tsx index b9dd782d8a653..ff3d575df5686 100644 --- a/x-pack/plugins/security_solution/public/common/components/toasters/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/toasters/index.tsx @@ -20,6 +20,7 @@ export * from './errors'; * @deprecated Use x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts instead */ export interface AppToast extends Toast { + // FunFact: In a very rare case of errors this can be something other than array. We have a unit test case for it and am leaving it like this type for now. errors?: string[]; } diff --git a/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.test.tsx b/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.test.tsx index 7ec0553591103..94cf94ed46da7 100644 --- a/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.test.tsx @@ -54,6 +54,18 @@ describe('Modal all errors', () => { mockToastWithTwoError.errors.length ); }); + + // This test exists to ensure that errors will work if it is a non-array which can happen in rare corner cases. + test('it doesnt cause errors when errors is not an array which can be the rare case in corner cases', () => { + const mockToastWithTwoError = cloneDeep(mockToast); + mockToastWithTwoError.errors = ('' as unknown) as string[]; + const wrapper = shallow( + + ); + expect(wrapper.find('[data-test-subj="modal-all-errors-accordion"]').length).toBe( + mockToastWithTwoError.errors.length + ); + }); }); describe('events', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.tsx b/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.tsx index 0a78139f5fe3a..29058a87a96b5 100644 --- a/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.tsx +++ b/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.tsx @@ -23,12 +23,18 @@ import styled from 'styled-components'; import { AppToast } from '.'; import * as i18n from './translations'; +/** + * @deprecated Use x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts instead + */ interface FullErrorProps { isShowing: boolean; toast: AppToast; toggle: (toast: AppToast) => void; } +/** + * @deprecated Use x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts instead + */ const ModalAllErrorsComponent: React.FC = ({ isShowing, toast, toggle }) => { const handleClose = useCallback(() => toggle(toast), [toggle, toast]); @@ -43,7 +49,7 @@ const ModalAllErrorsComponent: React.FC = ({ isShowing, toast, t - {toast.errors != null && + {Array.isArray(toast.errors) && // FunFact: This can be a non-array in some rare cases toast.errors.map((error, index) => ( = ({ isShowing, toast, t ); }; +/** + * @deprecated Use x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts instead + */ export const ModalAllErrors = React.memo(ModalAllErrorsComponent); +/** + * @deprecated Use x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts instead + */ const MyEuiCodeBlock = styled(EuiCodeBlock)` margin-top: 4px; `; +/** + * @deprecated Use x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts instead + */ MyEuiCodeBlock.displayName = 'MyEuiCodeBlock'; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts index 27f584bb17248..da6b41080c1c7 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts @@ -9,7 +9,19 @@ import { renderHook } from '@testing-library/react-hooks'; import { IEsError } from 'src/plugins/data/public'; import { useToasts } from '../lib/kibana'; -import { useAppToasts } from './use_app_toasts'; +import { KibanaError, SecurityAppError } from '../utils/api'; +import { + appErrorToErrorStack, + convertErrorToEnumerable, + errorToErrorStack, + errorToErrorStackAdapter, + esErrorToErrorStack, + getStringifiedStack, + isEmptyObjectWhenStringified, + MaybeESError, + unknownToErrorStack, + useAppToasts, +} from './use_app_toasts'; jest.mock('../lib/kibana'); @@ -29,45 +41,449 @@ describe('useAppToasts', () => { })); }); - it('works normally with a regular error', async () => { - const error = new Error('regular error'); - const { result } = renderHook(() => useAppToasts()); + describe('useAppToasts', () => { + it('works normally with a regular error', async () => { + const error = new Error('regular error'); + const { result } = renderHook(() => useAppToasts()); - result.current.addError(error, { title: 'title' }); + result.current.addError(error, { title: 'title' }); - expect(addErrorMock).toHaveBeenCalledWith(error, { title: 'title' }); + expect(addErrorMock).toHaveBeenCalledWith(error, { title: 'title' }); + }); + + it('converts an unknown error to an Error', () => { + const unknownError = undefined; + + const { result } = renderHook(() => useAppToasts()); + + result.current.addError(unknownError, { title: 'title' }); + + expect(addErrorMock).toHaveBeenCalledWith(Error(`${undefined}`), { + title: 'title', + }); + }); + + it("uses a AppError's body.message as the toastMessage", async () => { + const kibanaApiError = { + message: 'Not Found', + body: { status_code: 404, message: 'Detailed Message' }, + }; + + const { result } = renderHook(() => useAppToasts()); + + result.current.addError(kibanaApiError, { title: 'title' }); + + expect(addErrorMock).toHaveBeenCalledWith(Error('Detailed Message (404)'), { + title: 'title', + }); + }); + + it("parses AppError's body in the stack trace", async () => { + const kibanaApiError = { + message: 'Not Found', + body: { status_code: 404, message: 'Detailed Message' }, + }; + + const { result } = renderHook(() => useAppToasts()); + + result.current.addError(kibanaApiError, { title: 'title' }); + const errorObj = addErrorMock.mock.calls[0][0]; + expect(errorObj.name).toEqual(''); + expect(JSON.parse(errorObj.stack)).toEqual({ + message: 'Not Found', + body: { status_code: 404, message: 'Detailed Message' }, + }); + }); + + it('works normally with a bsearch type error', async () => { + const error = ({ + message: 'some message', + attributes: {}, // empty object and should not show up in the output + err: { + statusCode: 400, + innerMessages: { somethingElse: 'message' }, + }, + } as unknown) as IEsError; + const { result } = renderHook(() => useAppToasts()); + + result.current.addError(error, { title: 'title' }); + const expected = Error('some message (400)'); + expect(addErrorMock).toHaveBeenCalledWith(expected, { title: 'title' }); + }); + + it('parses a bsearch correctly in the stack and name', async () => { + const error = ({ + message: 'some message', + attributes: {}, // empty object and should not show up in the output + err: { + statusCode: 400, + innerMessages: { somethingElse: 'message' }, + }, + } as unknown) as IEsError; + const { result } = renderHook(() => useAppToasts()); + result.current.addError(error, { title: 'title' }); + const errorObj = addErrorMock.mock.calls[0][0]; + expect(errorObj.name).toEqual('some message'); + expect(JSON.parse(errorObj.stack)).toEqual({ + statusCode: 400, + innerMessages: { + somethingElse: 'message', + }, + }); + }); }); - it('converts an unknown error to an Error', () => { - const unknownError = undefined; + describe('errorToErrorStackAdapter', () => { + it('works normally with a regular error', async () => { + const error = new Error('regular error'); + const result = errorToErrorStackAdapter(error); + expect(result).toEqual(error); + }); - const { result } = renderHook(() => useAppToasts()); + it('has a stack on the error with name, message, and a stack call', async () => { + const error = new Error('regular error'); + const result = errorToErrorStackAdapter(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack.name).toEqual('Error'); + expect(parsedStack.message).toEqual('regular error'); + expect(parsedStack.stack).toEqual(expect.stringContaining('Error: regular error')); + }); - result.current.addError(unknownError, { title: 'title' }); + it('converts an unknown error to an Error', () => { + const unknownError = undefined; + const result = errorToErrorStackAdapter(unknownError); + expect(result).toEqual(Error('undefined')); + }); - expect(addErrorMock).toHaveBeenCalledWith(Error(`${undefined}`), { - title: 'title', + it("uses a AppError's body.message", async () => { + const kibanaApiError = { + message: 'Not Found', + body: { status_code: 404, message: 'Detailed Message' }, + }; + const result = errorToErrorStackAdapter(kibanaApiError); + expect(result).toEqual(Error('Detailed Message (404)')); + }); + + it("parses AppError's body in the stack trace", async () => { + const kibanaApiError = { + message: 'Not Found', + body: { status_code: 404, message: 'Detailed Message' }, + }; + const result = errorToErrorStackAdapter(kibanaApiError); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack.message).toEqual('Not Found'); + expect(parsedStack.body).toEqual({ status_code: 404, message: 'Detailed Message' }); + }); + + it('works normally with a bsearch type error', async () => { + const error = ({ + message: 'some message', + attributes: {}, // empty object and should not show up in the output + err: { + statusCode: 400, + innerMessages: { somethingElse: 'message' }, + }, + } as unknown) as IEsError; + const result = errorToErrorStackAdapter(error); + expect(result).toEqual(Error('some message (400)')); + }); + + it('parses a bsearch correctly in the stack and name', async () => { + const error = ({ + message: 'some message', + attributes: {}, // empty object and should not show up in the output + err: { + statusCode: 400, + innerMessages: { somethingElse: 'message' }, + }, + } as unknown) as IEsError; + const result = errorToErrorStackAdapter(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ + statusCode: 400, + innerMessages: { + somethingElse: 'message', + }, + }); }); }); - it('works normally with a bsearch type error', async () => { - const error = ({ - message: 'some message', - attributes: {}, - err: { + describe('esErrorToErrorStack', () => { + it('works with a IEsError that is not an EsError', async () => { + const error: IEsError = { + statusCode: 200, + message: 'a message', + }; + const result = esErrorToErrorStack(error); + expect(result).toEqual(Error('a message (200)')); + }); + + it('creates a stack trace of a IEsError that is not an EsError', async () => { + const error: IEsError = { + statusCode: 200, + message: 'a message', + }; + const result = esErrorToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ statusCode: 200, message: 'a message' }); + }); + + it('prefers the attributes reason if we have it for the message', async () => { + const error: IEsError = { + attributes: { type: 'some type', reason: 'message we want' }, + statusCode: 200, + message: 'message we do not want', + }; + const result = esErrorToErrorStack(error); + expect(result).toEqual(Error('message we want (200)')); + }); + + it('works with an EsError, by using the inner error and not outer error if available', async () => { + const error: MaybeESError = { + attributes: { type: 'some type', reason: 'message we want' }, + statusCode: 400, + err: { + statusCode: 200, + attributes: { reason: 'attribute message we do not want' }, + }, + message: 'main message we do not want', + }; + const result = esErrorToErrorStack(error); + expect(result).toEqual(Error('message we want (200)')); + }); + + it('creates a stack trace of a EsError and not the outer object', async () => { + const error: MaybeESError = { + attributes: { type: 'some type', reason: 'message we do not want' }, statusCode: 400, - innerMessages: { somethingElse: 'message' }, - }, - } as unknown) as IEsError; - const { result } = renderHook(() => useAppToasts()); - - result.current.addError(error, { title: 'title' }); - const errorObj = addErrorMock.mock.calls[0][0]; - expect(errorObj).toEqual({ - message: 'some message (400)', - name: 'some message', - stack: - '{\n "statusCode": 400,\n "innerMessages": {\n "somethingElse": "message"\n }\n}', + err: { + statusCode: 200, + attributes: { reason: 'attribute message we do want' }, + }, + message: 'main message we do not want', + }; + const result = esErrorToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ + statusCode: 200, + attributes: { reason: 'attribute message we do want' }, + }); + }); + }); + + describe('appErrorToErrorStack', () => { + it('works with a AppError that is a KibanaError', async () => { + const error: KibanaError = { + message: 'message', + name: 'some name', + body: { + message: 'a message', + statusCode: 200, + }, + }; + const result = appErrorToErrorStack(error); + expect(result).toEqual(Error('a message (200)')); + }); + + it('creates a stack trace of a KibanaError', async () => { + const error: KibanaError = { + message: 'message', + name: 'some name', + body: { + message: 'a message', + statusCode: 200, + }, + }; + const result = appErrorToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ + message: 'message', + name: 'some name', + body: { + message: 'a message', + statusCode: 200, + }, + }); + }); + + it('works with a AppError that is a SecurityAppError', async () => { + const error: SecurityAppError = { + message: 'message', + name: 'some name', + body: { + message: 'a message', + status_code: 200, + }, + }; + const result = appErrorToErrorStack(error); + expect(result).toEqual(Error('a message (200)')); + }); + + it('creates a stack trace of a SecurityAppError', async () => { + const error: SecurityAppError = { + message: 'message', + name: 'some name', + body: { + message: 'a message', + status_code: 200, + }, + }; + const result = appErrorToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ + message: 'message', + name: 'some name', + body: { + message: 'a message', + status_code: 200, + }, + }); + }); + }); + + describe('errorToErrorStack', () => { + it('works with an Error', async () => { + const error: Error = { + message: 'message', + name: 'some name', + }; + const result = errorToErrorStack(error); + expect(result).toEqual(Error('message')); + }); + + it('creates a stack trace of an Error', async () => { + const error: Error = { + message: 'message', + name: 'some name', + }; + const result = errorToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ + message: 'message', + name: 'some name', + }); + }); + }); + + describe('unknownToErrorStack', () => { + it('works with a string', async () => { + const error = 'error'; + const result = unknownToErrorStack(error); + expect(result).toEqual(Error('error')); + }); + + it('works with an object that has fields by using a stringification of it', async () => { + const error = { a: 1, b: 1 }; + const result = unknownToErrorStack(error); + expect(result).toEqual(Error(JSON.stringify(error, null, 2))); + }); + + it('works with an an array that has fields by using a stringification of it', async () => { + const error = [{ a: 1, b: 1 }]; + const result = unknownToErrorStack(error); + expect(result).toEqual(Error(JSON.stringify(error, null, 2))); + }); + + it('does create a stack error from a plain string of that string', async () => { + const error = 'error'; + const result = unknownToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual(error); + }); + + it('does create a stack with an object that has fields by using a stringification of it', async () => { + const error = { a: 1, b: 1 }; + const result = unknownToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual(error); + }); + + it('does create a stack with an an array that has fields by using a stringification of it', async () => { + const error = [{ a: 1, b: 1 }]; + const result = unknownToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual(error); + }); + }); + + describe('getStringifiedStack', () => { + it('works with an Error object', async () => { + const result = getStringifiedStack(new Error('message')); + const parsedResult = JSON.parse(result ?? ''); + expect(parsedResult.name).toEqual('Error'); + expect(parsedResult.message).toEqual('message'); + expect(parsedResult.stack).toEqual(expect.stringContaining('Error: message')); + }); + + it('works with a regular object', async () => { + const regularObject = { a: 'regular object' }; + const result = getStringifiedStack(regularObject); + const parsedResult = JSON.parse(result ?? ''); + expect(parsedResult).toEqual(regularObject); + }); + + it('returns undefined with a circular reference', async () => { + const circleRef = { a: {} }; + circleRef.a = circleRef; + const result = getStringifiedStack(circleRef); + expect(result).toEqual(undefined); + }); + + it('returns undefined if given an empty object', async () => { + const emptyObj = {}; + const result = getStringifiedStack(emptyObj); + expect(result).toEqual(undefined); + }); + + it('returns a string if given a string', async () => { + const stringValue = 'some value'; + const result = getStringifiedStack(stringValue); + expect(result).toEqual(`"${stringValue}"`); + }); + + it('returns an array if given an array', async () => { + const value = ['some value']; + const result = getStringifiedStack(value); + const parsedResult = JSON.parse(result ?? ''); + expect(parsedResult).toEqual(value); + }); + + it('removes top level empty objects if found to clean things up a bit', async () => { + const objectWithEmpties = { a: {}, b: { c: 1 }, d: {}, e: {} }; + const result = getStringifiedStack(objectWithEmpties); + const parsedResult = JSON.parse(result ?? ''); + expect(parsedResult).toEqual({ b: { c: 1 } }); + }); + }); + + describe('convertErrorToEnumerable', () => { + test('it will return a stringable Error object', () => { + const converted = convertErrorToEnumerable(new Error('message')); + // delete the stack off the converted for testing determinism + delete (converted as Error).stack; + expect(JSON.stringify(converted)).toEqual( + JSON.stringify({ name: 'Error', message: 'message' }) + ); + }); + + test('it will return a value not touched if it is not an error instances', () => { + const obj = { a: 1 }; + const converted = convertErrorToEnumerable(obj); + expect(converted).toBe(obj); + }); + }); + + describe('isEmptyObjectWhenStringified', () => { + test('it returns false when handed a non-object', () => { + expect(isEmptyObjectWhenStringified('string')).toEqual(false); + }); + + test('it returns false when handed a non-empty object', () => { + expect(isEmptyObjectWhenStringified({ a: 1 })).toEqual(false); + }); + + test('it returns true when handed an empty object', () => { + expect(isEmptyObjectWhenStringified({})).toEqual(true); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts index f5a3c75747e52..61b20e137f870 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts @@ -6,11 +6,12 @@ */ import { useCallback, useRef } from 'react'; +import { isString } from 'lodash/fp'; import { IEsError, isEsError } from '../../../../../../src/plugins/data/public'; import { ErrorToastOptions, ToastsStart, Toast } from '../../../../../../src/core/public'; import { useToasts } from '../lib/kibana'; -import { isAppError } from '../utils/api'; +import { AppError, isAppError, isKibanaError, isSecurityAppError } from '../utils/api'; export type UseAppToasts = Pick & { api: ToastsStart; @@ -32,32 +33,40 @@ export const useAppToasts = (): UseAppToasts => { const _addError = useCallback( (error: unknown, options: ErrorToastOptions) => { - if (error != null && isEsError(error)) { - const err = esErrorToRequestError(error); - return addError(err, options); - } else if (isAppError(error)) { - return addError(error, options); - } else if (error instanceof Error) { - return addError(error, options); - } else { - // Best guess that this is a stringable error. - const err = new Error(String(error)); - return addError(err, options); - } + const adaptedError = errorToErrorStackAdapter(error); + return addError(adaptedError, options); }, [addError] ); - return { api: toasts, addError: _addError, addSuccess, addWarning }; }; +/** + * Given an error of one type vs. another type this tries to adapt + * the best it can to the existing error toaster which parses the .stack + * as its error when you click the button to show the full error message. + * @param error The error to adapt to. + * @returns The adapted toaster error message. + */ +export const errorToErrorStackAdapter = (error: unknown): Error => { + if (error != null && isEsError(error)) { + return esErrorToErrorStack(error); + } else if (isAppError(error)) { + return appErrorToErrorStack(error); + } else if (error instanceof Error) { + return errorToErrorStack(error); + } else { + return unknownToErrorStack(error); + } +}; + /** * See this file, we are not allowed to import files such as es_error. * So instead we say maybe err is on there so that we can unwrap it and get * our status code from it if possible within the error in our function. * src/plugins/data/public/search/errors/es_error.tsx */ -type MaybeESError = IEsError & { err?: Record }; +export type MaybeESError = IEsError & { err?: Record }; /** * This attempts its best to map between an IEsError which comes from bsearch to a error_toaster @@ -72,13 +81,152 @@ type MaybeESError = IEsError & { err?: Record }; * * Where this same technique of overriding and changing the stack is occurring. */ -export const esErrorToRequestError = (error: IEsError & MaybeESError): Error => { +export const esErrorToErrorStack = (error: IEsError & MaybeESError): Error => { const maybeUnWrapped = error.err != null ? error.err : error; - const statusCode = error.err?.statusCode != null ? `(${error.err.statusCode})` : ''; - const stringifiedError = JSON.stringify(maybeUnWrapped, null, 2); - return { - message: `${error.attributes?.reason ?? error.message} ${statusCode}`, - name: error.attributes?.reason ?? error.message, - stack: stringifiedError, - }; + const statusCode = + error.err?.statusCode != null + ? `(${error.err.statusCode})` + : error.statusCode != null + ? `(${error.statusCode})` + : ''; + const stringifiedError = getStringifiedStack(maybeUnWrapped); + const adaptedError = new Error(`${error.attributes?.reason ?? error.message} ${statusCode}`); + adaptedError.name = error.attributes?.reason ?? error.message; + if (stringifiedError != null) { + adaptedError.stack = stringifiedError; + } + return adaptedError; +}; + +/** + * This attempts its best to map between a Kibana application error which can come from backend + * REST API's that are typically of a particular format and form. + * + * The existing error_toaster code tries to consolidate network and software stack traces but really + * here and our toasters we are using them for network response errors so we can troubleshoot things + * as quick as possible. + * + * We override and use error.stack to be able to give _full_ network responses regardless of if they + * are from Kibana or if they are from elasticSearch since sometimes Kibana errors might wrap the errors. + * + * Sometimes the errors are wrapped from io-ts, Kibana Schema or something else and we want to show + * as full error messages as we can. + */ +export const appErrorToErrorStack = (error: AppError): Error => { + const statusCode = isKibanaError(error) + ? `(${error.body.statusCode})` + : isSecurityAppError(error) + ? `(${error.body.status_code})` + : ''; + const stringifiedError = getStringifiedStack(error); + const adaptedError = new Error( + `${String(error.body.message).trim() !== '' ? error.body.message : error.message} ${statusCode}` + ); + // Note although all the Typescript typings say that error.name is a string and exists, we still can encounter an undefined so we + // do an extra guard here and default to empty string if it is undefined + adaptedError.name = error.name != null ? error.name : ''; + if (stringifiedError != null) { + adaptedError.stack = stringifiedError; + } + return adaptedError; +}; + +/** + * Takes an error and tries to stringify it and use that as the stack for the error toaster + * @param error The error to convert into a message + * @returns The exception error to return back + */ +export const errorToErrorStack = (error: Error): Error => { + const stringifiedError = getStringifiedStack(error); + const adaptedError = new Error(error.message); + adaptedError.name = error.name; + if (stringifiedError != null) { + adaptedError.stack = stringifiedError; + } + return adaptedError; +}; + +/** + * Last ditch effort to take something unknown which could be a string, number, + * anything. This usually should not be called but just in case we do try our + * best to stringify it and give a message, name, and replace the stack of it. + * @param error The unknown error to convert into a message + * @returns The exception error to return back + */ +export const unknownToErrorStack = (error: unknown): Error => { + const stringifiedError = getStringifiedStack(error); + const message = isString(error) + ? error + : error instanceof Object && stringifiedError != null + ? stringifiedError + : String(error); + const adaptedError = new Error(message); + adaptedError.name = message; + if (stringifiedError != null) { + adaptedError.stack = stringifiedError; + } + return adaptedError; +}; + +/** + * Stringifies the error. However, since Errors can JSON.stringify into empty objects this will + * use a replacer to push those as enumerable properties so we can stringify them. + * @param error The error to get a string representation of + * @returns The string representation of the error + */ +export const getStringifiedStack = (error: unknown): string | undefined => { + try { + return JSON.stringify( + error, + (_, value) => { + const enumerable = convertErrorToEnumerable(value); + if (isEmptyObjectWhenStringified(enumerable)) { + return undefined; + } else { + return enumerable; + } + }, + 2 + ); + } catch (err) { + return undefined; + } +}; + +/** + * Converts an error if this is an error to have enumerable so it can stringified + * @param error The error which might not have enumerable properties. + * @returns Enumerable error + */ +export const convertErrorToEnumerable = (error: unknown): unknown => { + if (error instanceof Error) { + return { + ...error, + name: error.name, + message: error.message, + stack: error.stack, + }; + } else { + return error; + } +}; + +/** + * If the object strings into an empty object we shouldn't show it as it doesn't + * add value and sometimes different people/frameworks attach req,res,request,response + * objects which don't stringify into anything or can have circular references. + * @param item The item to see if we are empty or have a circular reference error with. + * @returns True if this is a good object to stringify, otherwise false + */ +export const isEmptyObjectWhenStringified = (item: unknown): boolean => { + if (item instanceof Object) { + try { + return JSON.stringify(item) === '{}'; + } catch (_) { + // Do nothing, return false if we have a circular reference or other oddness. + return false; + } + } else { + return false; + } }; diff --git a/x-pack/plugins/stack_alerts/server/feature.test.ts b/x-pack/plugins/stack_alerts/server/feature.test.ts new file mode 100644 index 0000000000000..62807f1c10a1c --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/feature.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AlertingBuiltinsPlugin } from './plugin'; +import { coreMock } from '../../../../src/core/server/mocks'; +import { alertsMock } from '../../alerting/server/mocks'; +import { featuresPluginMock } from '../../features/server/mocks'; +import { BUILT_IN_ALERTS_FEATURE } from './feature'; + +describe('Stack Alerts Feature Privileges', () => { + test('feature privilege should contain all built-in rule types', async () => { + const context = coreMock.createPluginInitializerContext(); + const plugin = new AlertingBuiltinsPlugin(context); + const coreSetup = coreMock.createSetup(); + const alertingSetup = alertsMock.createSetup(); + const featuresSetup = featuresPluginMock.createSetup(); + await plugin.setup(coreSetup, { alerting: alertingSetup, features: featuresSetup }); + + const typesInFeaturePrivilege = BUILT_IN_ALERTS_FEATURE.alerting; + const typesInFeaturePrivilegeAll = BUILT_IN_ALERTS_FEATURE.privileges.all.alerting.all; + const typesInFeaturePrivilegeRead = BUILT_IN_ALERTS_FEATURE.privileges.read.alerting.read; + expect(alertingSetup.registerType.mock.calls.length).toEqual(typesInFeaturePrivilege.length); + expect(alertingSetup.registerType.mock.calls.length).toEqual(typesInFeaturePrivilegeAll.length); + expect(alertingSetup.registerType.mock.calls.length).toEqual( + typesInFeaturePrivilegeRead.length + ); + + alertingSetup.registerType.mock.calls.forEach((call) => { + expect(typesInFeaturePrivilege.indexOf(call[0].id)).toBeGreaterThanOrEqual(0); + expect(typesInFeaturePrivilegeAll.indexOf(call[0].id)).toBeGreaterThanOrEqual(0); + expect(typesInFeaturePrivilegeRead.indexOf(call[0].id)).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/x-pack/plugins/task_manager/server/routes/health.test.ts b/x-pack/plugins/task_manager/server/routes/health.test.ts index dd7ed69aaf27f..0a9671d9ac37e 100644 --- a/x-pack/plugins/task_manager/server/routes/health.test.ts +++ b/x-pack/plugins/task_manager/server/routes/health.test.ts @@ -155,31 +155,6 @@ describe('healthRoute', () => { expect(await serviceStatus).toMatchObject({ level: ServiceStatusLevels.unavailable, summary: 'Task Manager is unavailable', - meta: { - status: 'error', - ...summarizeMonitoringStats( - mockHealthStats({ - last_update: expect.any(String), - stats: { - configuration: { - timestamp: expect.any(String), - }, - workload: { - timestamp: expect.any(String), - }, - runtime: { - timestamp: expect.any(String), - value: { - polling: { - last_successful_poll: expect.any(String), - }, - }, - }, - }, - }), - getTaskManagerConfig({}) - ), - }, }); }); diff --git a/x-pack/plugins/task_manager/server/routes/health.ts b/x-pack/plugins/task_manager/server/routes/health.ts index 589443b62ea42..cc2f6c6630e56 100644 --- a/x-pack/plugins/task_manager/server/routes/health.ts +++ b/x-pack/plugins/task_manager/server/routes/health.ts @@ -34,13 +34,22 @@ const LEVEL_SUMMARY = { [ServiceStatusLevels.unavailable.toString()]: 'Task Manager is unavailable', }; +/** + * We enforce a `meta` of `never` because this meta gets duplicated into *every dependant plugin*, and + * this will then get logged out when logging is set to Verbose. + * We used to pass in the the entire MonitoredHealth into this `meta` field, but this means that the + * whole MonitoredHealth JSON (which can be quite big) was duplicated dozens of times and when we + * try to view logs in Discover, it fails to render as this JSON was often dozens of levels deep. + */ +type TaskManagerServiceStatus = ServiceStatus; + export function healthRoute( router: IRouter, monitoringStats$: Observable, logger: Logger, taskManagerId: string, config: TaskManagerConfig -): Observable { +): Observable { // if "hot" health stats are any more stale than monitored_stats_required_freshness (pollInterval +1s buffer by default) // consider the system unhealthy const requiredHotStatsFreshness: number = config.monitored_stats_required_freshness; @@ -67,7 +76,7 @@ export function healthRoute( return { id: taskManagerId, timestamp, status: healthStatus, ...summarizedStats }; } - const serviceStatus$: Subject = new Subject(); + const serviceStatus$: Subject = new Subject(); /* keep track of last health summary, as we'll return that to the next call to _health */ let lastMonitoredStats: MonitoringStats | null = null; @@ -110,7 +119,7 @@ export function healthRoute( export function withServiceStatus( monitoredHealth: MonitoredHealth -): [MonitoredHealth, ServiceStatus] { +): [MonitoredHealth, TaskManagerServiceStatus] { const level = monitoredHealth.status === HealthStatus.OK ? ServiceStatusLevels.available @@ -122,7 +131,6 @@ export function withServiceStatus( { level, summary: LEVEL_SUMMARY[level.toString()], - meta: monitoredHealth, }, ]; } diff --git a/x-pack/plugins/transform/kibana.json b/x-pack/plugins/transform/kibana.json index d5da9377ed870..4216ac9761e86 100644 --- a/x-pack/plugins/transform/kibana.json +++ b/x-pack/plugins/transform/kibana.json @@ -9,7 +9,8 @@ "licensing", "management", "features", - "savedObjects" + "savedObjects", + "share" ], "optionalPlugins": [ "security", diff --git a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx index 41b7482c4c0f8..5a6f8cf72e36d 100644 --- a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx @@ -7,17 +7,28 @@ import { useContext } from 'react'; +import type { ScopedHistory } from 'kibana/public'; + import { coreMock } from '../../../../../../src/core/public/mocks'; import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; +import { savedObjectsPluginMock } from '../../../../../../src/plugins/saved_objects/public/mocks'; +import { SharePluginStart } from '../../../../../../src/plugins/share/public'; + import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import type { AppDependencies } from '../app_dependencies'; import { MlSharedContext } from './shared_context'; +import type { GetMlSharedImportsReturnType } from '../../shared_imports'; const coreSetup = coreMock.createSetup(); const coreStart = coreMock.createStart(); const dataStart = dataPluginMock.createStartContract(); -const appDependencies = { +// Replace mock to support syntax using `.then()` as used in transform code. +coreStart.savedObjects.client.find = jest.fn().mockResolvedValue({ savedObjects: [] }); + +const appDependencies: AppDependencies = { + application: coreStart.application, chrome: coreStart.chrome, data: dataStart, docLinks: coreStart.docLinks, @@ -28,11 +39,15 @@ const appDependencies = { storage: ({ get: jest.fn() } as unknown) as Storage, overlays: coreStart.overlays, http: coreSetup.http, + history: {} as ScopedHistory, + savedObjectsPlugin: savedObjectsPluginMock.createStartContract(), + share: ({ urlGenerators: { getUrlGenerator: jest.fn() } } as unknown) as SharePluginStart, + ml: {} as GetMlSharedImportsReturnType, }; export const useAppDependencies = () => { const ml = useContext(MlSharedContext); - return { ...appDependencies, ml, savedObjects: jest.fn() }; + return { ...appDependencies, ml }; }; export const useToastNotifications = () => { diff --git a/x-pack/plugins/transform/public/app/app_dependencies.tsx b/x-pack/plugins/transform/public/app/app_dependencies.tsx index c49ab8183521f..c39aa5a49e5e9 100644 --- a/x-pack/plugins/transform/public/app/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/app_dependencies.tsx @@ -5,17 +5,19 @@ * 2.0. */ -import { CoreSetup, CoreStart } from 'src/core/public'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { SavedObjectsStart } from 'src/plugins/saved_objects/public'; -import { ScopedHistory } from 'kibana/public'; +import type { CoreSetup, CoreStart } from 'src/core/public'; +import type { DataPublicPluginStart } from 'src/plugins/data/public'; +import type { SavedObjectsStart } from 'src/plugins/saved_objects/public'; +import type { ScopedHistory } from 'kibana/public'; +import type { SharePluginStart } from 'src/plugins/share/public'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; -import { Storage } from '../../../../../src/plugins/kibana_utils/public'; +import type { Storage } from '../../../../../src/plugins/kibana_utils/public'; import type { GetMlSharedImportsReturnType } from '../shared_imports'; export interface AppDependencies { + application: CoreStart['application']; chrome: CoreStart['chrome']; data: DataPublicPluginStart; docLinks: CoreStart['docLinks']; @@ -28,6 +30,7 @@ export interface AppDependencies { overlays: CoreStart['overlays']; history: ScopedHistory; savedObjectsPlugin: SavedObjectsStart; + share: SharePluginStart; ml: GetMlSharedImportsReturnType; } diff --git a/x-pack/plugins/transform/public/app/common/index.ts b/x-pack/plugins/transform/public/app/common/index.ts index 8fa97139ab967..ccd90f8759358 100644 --- a/x-pack/plugins/transform/public/app/common/index.ts +++ b/x-pack/plugins/transform/public/app/common/index.ts @@ -28,7 +28,6 @@ export { } from './transform'; export { TRANSFORM_LIST_COLUMN, TransformListAction, TransformListRow } from './transform_list'; export { getTransformProgress, isCompletedBatchTransform } from './transform_stats'; -export { getDiscoverUrl } from './navigation'; export { getEsAggFromAggConfig, isPivotAggsConfigWithUiSupport, diff --git a/x-pack/plugins/transform/public/app/common/navigation.test.tsx b/x-pack/plugins/transform/public/app/common/navigation.test.tsx deleted file mode 100644 index af2f586873961..0000000000000 --- a/x-pack/plugins/transform/public/app/common/navigation.test.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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getDiscoverUrl } from './navigation'; - -describe('navigation', () => { - test('getDiscoverUrl should provide encoded url to Discover page', () => { - expect(getDiscoverUrl('farequote-airline', 'http://example.com')).toBe( - 'http://example.com/app/discover#?_g=()&_a=(index:farequote-airline)' - ); - }); -}); diff --git a/x-pack/plugins/transform/public/app/common/navigation.tsx b/x-pack/plugins/transform/public/app/common/navigation.tsx index 9daaf8c840755..b847ac66de58e 100644 --- a/x-pack/plugins/transform/public/app/common/navigation.tsx +++ b/x-pack/plugins/transform/public/app/common/navigation.tsx @@ -7,28 +7,9 @@ import React, { FC } from 'react'; import { Redirect } from 'react-router-dom'; -import rison from 'rison-node'; import { SECTION_SLUG } from '../constants'; -/** - * Gets a url for navigating to Discover page. - * @param indexPatternId Index pattern ID. - * @param baseUrl Base url. - */ -export function getDiscoverUrl(indexPatternId: string, baseUrl: string): string { - const _g = rison.encode({}); - - // Add the index pattern ID to the appState part of the URL. - const _a = rison.encode({ - index: indexPatternId, - }); - - const hash = `/discover#?_g=${_g}&_a=${_a}`; - - return `${baseUrl}/app${hash}`; -} - export const RedirectToTransformManagement: FC = () => ; export const RedirectToCreateTransform: FC<{ savedObjectId: string }> = ({ savedObjectId }) => ( diff --git a/x-pack/plugins/transform/public/app/mount_management_section.ts b/x-pack/plugins/transform/public/app/mount_management_section.ts index 019e1f56cee06..1d39d233f8284 100644 --- a/x-pack/plugins/transform/public/app/mount_management_section.ts +++ b/x-pack/plugins/transform/public/app/mount_management_section.ts @@ -28,8 +28,8 @@ export async function mountManagementSection( const { http, notifications, getStartServices } = coreSetup; const startServices = await getStartServices(); const [core, plugins] = startServices; - const { chrome, docLinks, i18n, overlays, savedObjects, uiSettings } = core; - const { data } = plugins; + const { application, chrome, docLinks, i18n, overlays, savedObjects, uiSettings } = core; + const { data, share } = plugins; const { docTitle } = chrome; // Initialize services @@ -39,6 +39,7 @@ export async function mountManagementSection( // AppCore/AppPlugins to be passed on as React context const appDependencies: AppDependencies = { + application, chrome, data, docLinks, @@ -51,6 +52,7 @@ export async function mountManagementSection( uiSettings, history, savedObjectsPlugin: plugins.savedObjects, + share, ml: await getMlSharedImports(), }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx index 36bdca7921622..526f59e7dad41 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx @@ -26,6 +26,11 @@ import { import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; +import { + DISCOVER_APP_URL_GENERATOR, + DiscoverUrlGeneratorState, +} from '../../../../../../../../../src/plugins/discover/public'; + import type { PutTransformsResponseSchema } from '../../../../../../common/api_schemas/transforms'; import { isGetTransformsStatsResponseSchema, @@ -36,7 +41,7 @@ import { PROGRESS_REFRESH_INTERVAL_MS } from '../../../../../../common/constants import { getErrorMessage } from '../../../../../../common/utils/errors'; -import { getTransformProgress, getDiscoverUrl } from '../../../../common'; +import { getTransformProgress } from '../../../../common'; import { useApi } from '../../../../hooks/use_api'; import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies'; import { RedirectToTransformManagement } from '../../../../common/navigation'; @@ -86,13 +91,45 @@ export const StepCreateForm: FC = React.memo( const [progressPercentComplete, setProgressPercentComplete] = useState( undefined ); + const [discoverLink, setDiscoverLink] = useState(); const deps = useAppDependencies(); const indexPatterns = deps.data.indexPatterns; const toastNotifications = useToastNotifications(); + const { getUrlGenerator } = deps.share.urlGenerators; + const isDiscoverAvailable = deps.application.capabilities.discover?.show ?? false; useEffect(() => { + let unmounted = false; + onChange({ created, started, indexPatternId }); + + const getDiscoverUrl = async (): Promise => { + const state: DiscoverUrlGeneratorState = { + indexPatternId, + }; + + let discoverUrlGenerator; + try { + discoverUrlGenerator = getUrlGenerator(DISCOVER_APP_URL_GENERATOR); + } catch (error) { + // ignore error thrown when url generator is not available + return; + } + + const discoverUrl = await discoverUrlGenerator.createUrl(state); + if (!unmounted) { + setDiscoverLink(discoverUrl); + } + }; + + if (started === true && indexPatternId !== undefined && isDiscoverAvailable) { + getDiscoverUrl(); + } + + return () => { + unmounted = true; + }; // custom comparison // eslint-disable-next-line react-hooks/exhaustive-deps }, [created, started, indexPatternId]); @@ -477,7 +514,7 @@ export const StepCreateForm: FC = React.memo(
)} - {started === true && indexPatternId !== undefined && ( + {isDiscoverAvailable && discoverLink !== undefined && ( } @@ -490,7 +527,7 @@ export const StepCreateForm: FC = React.memo( defaultMessage: 'Use Discover to explore the transform.', } )} - href={getDiscoverUrl(indexPatternId, deps.http.basePath.get())} + href={discoverLink} data-test-subj="transformWizardCardDiscover" /> diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx new file mode 100644 index 0000000000000..8dba93399792c --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { cloneDeep } from 'lodash'; +import React from 'react'; +import { IntlProvider } from 'react-intl'; + +import { render, waitFor, screen } from '@testing-library/react'; + +import { TransformListRow } from '../../../../common'; +import { isDiscoverActionDisabled, DiscoverActionName } from './discover_action_name'; + +import transformListRow from '../../../../common/__mocks__/transform_list_row.json'; + +jest.mock('../../../../../shared_imports'); +jest.mock('../../../../../app/app_dependencies'); + +// @ts-expect-error mock data is too loosely typed +const item: TransformListRow = transformListRow; + +describe('Transform: Transform List Actions isDiscoverActionDisabled()', () => { + it('should be disabled when more than one item is passed in', () => { + expect(isDiscoverActionDisabled([item, item], false, true)).toBe(true); + }); + it('should be disabled when forceDisable is true', () => { + expect(isDiscoverActionDisabled([item], true, true)).toBe(true); + }); + it('should be disabled when the index pattern is not available', () => { + expect(isDiscoverActionDisabled([item], false, false)).toBe(true); + }); + it('should be disabled when the transform started but has no index pattern', () => { + const itemCopy = cloneDeep(item); + itemCopy.stats.state = 'started'; + expect(isDiscoverActionDisabled([itemCopy], false, false)).toBe(true); + }); + it('should be enabled when the transform started and has an index pattern', () => { + const itemCopy = cloneDeep(item); + itemCopy.stats.state = 'started'; + expect(isDiscoverActionDisabled([itemCopy], false, true)).toBe(false); + }); + it('should be enabled when the index pattern is available', () => { + expect(isDiscoverActionDisabled([item], false, true)).toBe(false); + }); +}); + +describe('Transform: Transform List Actions ', () => { + it('renders an enabled button', async () => { + // prepare + render( + + + + ); + + // assert + await waitFor(() => { + expect( + screen.queryByTestId('transformDiscoverActionNameText disabled') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('transformDiscoverActionNameText enabled')).toBeInTheDocument(); + expect(screen.queryByText('View in Discover')).toBeInTheDocument(); + }); + }); + + it('renders a disabled button', async () => { + // prepare + const itemCopy = cloneDeep(item); + itemCopy.stats.checkpointing.last.checkpoint = 0; + render( + + + + ); + + // assert + await waitFor(() => { + expect(screen.queryByTestId('transformDiscoverActionNameText disabled')).toBeInTheDocument(); + expect( + screen.queryByTestId('transformDiscoverActionNameText enabled') + ).not.toBeInTheDocument(); + expect(screen.queryByText('View in Discover')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx new file mode 100644 index 0000000000000..259bf82371dba --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiToolTip } from '@elastic/eui'; + +import { TRANSFORM_STATE } from '../../../../../../common/constants'; + +import { getTransformProgress, TransformListRow } from '../../../../common'; + +export const discoverActionNameText = i18n.translate( + 'xpack.transform.transformList.discoverActionNameText', + { + defaultMessage: 'View in Discover', + } +); + +export const isDiscoverActionDisabled = ( + items: TransformListRow[], + forceDisable: boolean, + indexPatternExists: boolean +) => { + if (items.length !== 1) { + return true; + } + + const item = items[0]; + + // Disable discover action if it's a batch transform and was never started + const stoppedTransform = item.stats.state === TRANSFORM_STATE.STOPPED; + const transformProgress = getTransformProgress(item); + const isBatchTransform = typeof item.config.sync === 'undefined'; + const transformNeverStarted = + stoppedTransform === true && transformProgress === undefined && isBatchTransform === true; + + return forceDisable === true || indexPatternExists === false || transformNeverStarted === true; +}; + +export interface DiscoverActionNameProps { + indexPatternExists: boolean; + items: TransformListRow[]; +} +export const DiscoverActionName: FC = ({ indexPatternExists, items }) => { + const isBulkAction = items.length > 1; + + const item = items[0]; + + // Disable discover action if it's a batch transform and was never started + const stoppedTransform = item.stats.state === TRANSFORM_STATE.STOPPED; + const transformProgress = getTransformProgress(item); + const isBatchTransform = typeof item.config.sync === 'undefined'; + const transformNeverStarted = + stoppedTransform && transformProgress === undefined && isBatchTransform === true; + + let disabledTransformMessage; + if (isBulkAction === true) { + disabledTransformMessage = i18n.translate( + 'xpack.transform.transformList.discoverTransformBulkToolTip', + { + defaultMessage: 'Links to Discover are not supported as a bulk action.', + } + ); + } else if (!indexPatternExists) { + disabledTransformMessage = i18n.translate( + 'xpack.transform.transformList.discoverTransformNoIndexPatternToolTip', + { + defaultMessage: `A Kibana index pattern is required for the destination index to be viewable in Discover`, + } + ); + } else if (transformNeverStarted) { + disabledTransformMessage = i18n.translate( + 'xpack.transform.transformList.discoverTransformToolTip', + { + defaultMessage: `The transform needs to be started before it's available in Discover.`, + } + ); + } + + if (typeof disabledTransformMessage !== 'undefined') { + return ( + + + {discoverActionNameText} + + + ); + } + + return ( + {discoverActionNameText} + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/index.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/index.ts new file mode 100644 index 0000000000000..b8ba624faf02c --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { useDiscoverAction } from './use_action_discover'; +export { DiscoverActionName } from './discover_action_name'; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx new file mode 100644 index 0000000000000..468ed0e6b892d --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { + DiscoverUrlGeneratorState, + DISCOVER_APP_URL_GENERATOR, +} from '../../../../../../../../../src/plugins/discover/public'; + +import { TransformListAction, TransformListRow } from '../../../../common'; + +import { useSearchItems } from '../../../../hooks/use_search_items'; +import { useAppDependencies } from '../../../../app_dependencies'; + +import { + isDiscoverActionDisabled, + discoverActionNameText, + DiscoverActionName, +} from './discover_action_name'; + +const getIndexPatternTitleFromTargetIndex = (item: TransformListRow) => + Array.isArray(item.config.dest.index) ? item.config.dest.index.join(',') : item.config.dest.index; + +export type DiscoverAction = ReturnType; +export const useDiscoverAction = (forceDisable: boolean) => { + const appDeps = useAppDependencies(); + const savedObjectsClient = appDeps.savedObjects.client; + const indexPatterns = appDeps.data.indexPatterns; + const { getUrlGenerator } = appDeps.share.urlGenerators; + const isDiscoverAvailable = !!appDeps.application.capabilities.discover?.show; + + const { getIndexPatternIdByTitle, loadIndexPatterns } = useSearchItems(undefined); + + const [indexPatternsLoaded, setIndexPatternsLoaded] = useState(false); + + useEffect(() => { + async function checkIndexPatternAvailability() { + await loadIndexPatterns(savedObjectsClient, indexPatterns); + setIndexPatternsLoaded(true); + } + + checkIndexPatternAvailability(); + }, [indexPatterns, loadIndexPatterns, savedObjectsClient]); + + const clickHandler = useCallback( + async (item: TransformListRow) => { + let discoverUrlGenerator; + try { + discoverUrlGenerator = getUrlGenerator(DISCOVER_APP_URL_GENERATOR); + } catch (error) { + // ignore error thrown when url generator is not available + return; + } + + const indexPatternTitle = getIndexPatternTitleFromTargetIndex(item); + const indexPatternId = getIndexPatternIdByTitle(indexPatternTitle); + const state: DiscoverUrlGeneratorState = { + indexPatternId, + }; + const path = await discoverUrlGenerator.createUrl(state); + appDeps.application.navigateToApp('discover', { path }); + }, + [appDeps.application, getIndexPatternIdByTitle, getUrlGenerator] + ); + + const indexPatternExists = useCallback( + (item: TransformListRow) => { + const indexPatternTitle = getIndexPatternTitleFromTargetIndex(item); + const indexPatternId = getIndexPatternIdByTitle(indexPatternTitle); + return indexPatternId !== undefined; + }, + [getIndexPatternIdByTitle] + ); + + const action: TransformListAction = useMemo( + () => ({ + name: (item: TransformListRow) => { + return ; + }, + available: () => isDiscoverAvailable, + enabled: (item: TransformListRow) => + indexPatternsLoaded && + !isDiscoverActionDisabled([item], forceDisable, indexPatternExists(item)), + description: discoverActionNameText, + icon: 'visTable', + type: 'icon', + onClick: clickHandler, + 'data-test-subj': 'transformActionDiscover', + }), + [forceDisable, indexPatternExists, indexPatternsLoaded, isDiscoverAvailable, clickHandler] + ); + + return { action }; +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx index d25f8c62a4e94..77d20dc4d9078 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; import moment from 'moment-timezone'; import { TransformListRow } from '../../../../common'; @@ -41,20 +41,26 @@ describe('Transform: Transform List ', () => { ); - expect(getByText('Details')).toBeInTheDocument(); - expect(getByText('Stats')).toBeInTheDocument(); - expect(getByText('JSON')).toBeInTheDocument(); - expect(getByText('Messages')).toBeInTheDocument(); - expect(getByText('Preview')).toBeInTheDocument(); + await waitFor(() => { + expect(getByText('Details')).toBeInTheDocument(); + expect(getByText('Stats')).toBeInTheDocument(); + expect(getByText('JSON')).toBeInTheDocument(); + expect(getByText('Messages')).toBeInTheDocument(); + expect(getByText('Preview')).toBeInTheDocument(); - const tabContent = getByTestId('transformDetailsTabContent'); - expect(tabContent).toBeInTheDocument(); + const tabContent = getByTestId('transformDetailsTabContent'); + expect(tabContent).toBeInTheDocument(); - expect(getByTestId('transformDetailsTab')).toHaveAttribute('aria-selected', 'true'); - expect(within(tabContent).getByText('General')).toBeInTheDocument(); + expect(getByTestId('transformDetailsTab')).toHaveAttribute('aria-selected', 'true'); + expect(within(tabContent).getByText('General')).toBeInTheDocument(); + }); fireEvent.click(getByTestId('transformStatsTab')); - expect(getByTestId('transformStatsTab')).toHaveAttribute('aria-selected', 'true'); - expect(within(tabContent).getByText('Stats')).toBeInTheDocument(); + + await waitFor(() => { + expect(getByTestId('transformStatsTab')).toHaveAttribute('aria-selected', 'true'); + const tabContent = getByTestId('transformDetailsTabContent'); + expect(within(tabContent).getByText('Stats')).toBeInTheDocument(); + }); }); }); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx index 90487d21610ea..b7d5a2b7104ae 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx @@ -7,20 +7,26 @@ import { renderHook } from '@testing-library/react-hooks'; -import { useActions } from './use_actions'; - jest.mock('../../../../../shared_imports'); jest.mock('../../../../../app/app_dependencies'); +import { useActions } from './use_actions'; + describe('Transform: Transform List Actions', () => { - test('useActions()', () => { - const { result } = renderHook(() => useActions({ forceDisable: false, transformNodes: 1 })); + test('useActions()', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useActions({ forceDisable: false, transformNodes: 1 }) + ); + + await waitForNextUpdate(); + const actions = result.current.actions; // Using `any` for the callback. Somehow the EUI types don't pass // on the `data-test-subj` attribute correctly. We're interested // in the runtime result here anyway. expect(actions.map((a: any) => a['data-test-subj'])).toStrictEqual([ + 'transformActionDiscover', 'transformActionStart', 'transformActionStop', 'transformActionEdit', diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx index d9b9008490666..ddf41d356529a 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx @@ -13,6 +13,7 @@ import { TransformListRow } from '../../../../common'; import { useCloneAction } from '../action_clone'; import { useDeleteAction, DeleteActionModal } from '../action_delete'; +import { useDiscoverAction } from '../action_discover'; import { EditTransformFlyout } from '../edit_transform_flyout'; import { useEditAction } from '../action_edit'; import { useStartAction, StartActionModal } from '../action_start'; @@ -30,6 +31,7 @@ export const useActions = ({ } => { const cloneAction = useCloneAction(forceDisable, transformNodes); const deleteAction = useDeleteAction(forceDisable); + const discoverAction = useDiscoverAction(forceDisable); const editAction = useEditAction(forceDisable, transformNodes); const startAction = useStartAction(forceDisable, transformNodes); const stopAction = useStopAction(forceDisable); @@ -45,6 +47,7 @@ export const useActions = ({ ), actions: [ + discoverAction.action, startAction.action, stopAction.action, editAction.action, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx index 53eed01f1226d..f3974430b662c 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx @@ -13,8 +13,11 @@ jest.mock('../../../../../shared_imports'); jest.mock('../../../../../app/app_dependencies'); describe('Transform: Job List Columns', () => { - test('useColumns()', () => { - const { result } = renderHook(() => useColumns([], () => {}, 1, [])); + test('useColumns()', async () => { + const { result, waitForNextUpdate } = renderHook(() => useColumns([], () => {}, 1, [])); + + await waitForNextUpdate(); + const columns: ReturnType['columns'] = result.current.columns; expect(columns).toHaveLength(7); diff --git a/x-pack/plugins/transform/public/plugin.ts b/x-pack/plugins/transform/public/plugin.ts index 67abd8a7f1a78..b058be46d677b 100644 --- a/x-pack/plugins/transform/public/plugin.ts +++ b/x-pack/plugins/transform/public/plugin.ts @@ -7,11 +7,12 @@ import { i18n as kbnI18n } from '@kbn/i18n'; -import { CoreSetup } from 'src/core/public'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { HomePublicPluginSetup } from 'src/plugins/home/public'; -import { SavedObjectsStart } from 'src/plugins/saved_objects/public'; -import { ManagementSetup } from '../../../../src/plugins/management/public'; +import type { CoreSetup } from 'src/core/public'; +import type { DataPublicPluginStart } from 'src/plugins/data/public'; +import type { HomePublicPluginSetup } from 'src/plugins/home/public'; +import type { SavedObjectsStart } from 'src/plugins/saved_objects/public'; +import type { ManagementSetup } from 'src/plugins/management/public'; +import type { SharePluginStart } from 'src/plugins/share/public'; import { registerFeature } from './register_feature'; export interface PluginsDependencies { @@ -19,6 +20,7 @@ export interface PluginsDependencies { management: ManagementSetup; home: HomePublicPluginSetup; savedObjects: SavedObjectsStart; + share: SharePluginStart; } export class TransformUiPlugin { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b66fa50ae168a..2d900f9e5dc85 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5653,7 +5653,6 @@ "xpack.apm.transactionsTable.nameColumnLabel": "名前", "xpack.apm.transactionsTable.notFoundLabel": "トランザクションが見つかりませんでした。", "xpack.apm.transactionsTable.throughputColumnLabel": "スループット", - "xpack.apm.transactionTypeSelectLabel": "型", "xpack.apm.tutorial.apmServer.title": "APM Server", "xpack.apm.tutorial.elasticCloud.textPre": "APM Server を有効にするには、[the Elastic Cloud console] (https://cloud.elastic.co/deployments?q={cloudId}) に移動し、展開設定で APM を有効にします。有効になったら、このページを更新してください。", "xpack.apm.tutorial.elasticCloudInstructions.title": "APM エージェント", @@ -8118,20 +8117,7 @@ "xpack.features.ossFeatures.visualizeShortUrlSubFeatureName": "短い URL", "xpack.features.savedObjectsManagementFeatureName": "保存されたオブジェクトの管理", "xpack.features.visualizeFeatureName": "Visualizeライブラリ", - "xpack.fileUpload.httpService.fetchError": "フェッチ実行エラー:{error}", - "xpack.fileUpload.httpService.noUrl": "URLが指定されていません", - "xpack.fileUpload.indexNameReqField": "インデックス名、必須フィールド", - "xpack.fileUpload.indexSettings.enterIndexNameLabel": "インデックス名", "xpack.fileUpload.indexSettings.enterIndexTypeLabel": "インデックスタイプ", - "xpack.fileUpload.indexSettings.guidelines.cannotBe": ".または..にすることはできません。", - "xpack.fileUpload.indexSettings.guidelines.cannotInclude": "\\\\、/、*、?、\"、<、>、|、 \" \" (スペース文字) 、, (カンマ) 、#を使用することはできません。", - "xpack.fileUpload.indexSettings.guidelines.cannotStartWith": "-、_、+を先頭にすることはできません", - "xpack.fileUpload.indexSettings.guidelines.length": "256バイト以上にすることはできません (これはバイト数であるため、複数バイト文字では255文字の文字制限のカウントが速くなります) ", - "xpack.fileUpload.indexSettings.guidelines.lowercaseOnly": "小文字のみ", - "xpack.fileUpload.indexSettings.guidelines.mustBeNewIndex": "新しいインデックスを作成する必要があります", - "xpack.fileUpload.indexSettings.indexNameAlreadyExistsErrorMessage": "インデックス名またはパターンはすでに存在します。", - "xpack.fileUpload.indexSettings.indexNameContainsIllegalCharactersErrorMessage": "インデックス名に許可されていない文字が含まれています。", - "xpack.fileUpload.indexSettings.indexNameGuidelines": "インデックス名ガイドライン", "xpack.fileUpload.jsonUploadAndParse.dataIndexingError": "データインデックスエラー", "xpack.fileUpload.jsonUploadAndParse.indexPatternError": "インデックスパターンエラー", "xpack.fleet.agentBulkActions.clearSelection": "選択した項目をクリア", @@ -15247,7 +15233,6 @@ "xpack.monitoring.cluster.overview.apmPanel.lastEventDescription": "{timeOfLastEvent} 前", "xpack.monitoring.cluster.overview.apmPanel.lastEventLabel": "最後のイベント", "xpack.monitoring.cluster.overview.apmPanel.memoryUsageLabel": "メモリー使用状況", - "xpack.monitoring.cluster.overview.apmPanel.overviewLinkAriaLabel": "APM Server 概要", "xpack.monitoring.cluster.overview.apmPanel.overviewLinkLabel": "APM Server 概要", "xpack.monitoring.cluster.overview.apmPanel.processedEventsLabel": "処理済みのイベント", "xpack.monitoring.cluster.overview.apmPanel.serversTotalLinkLabel": "APM Server:{apmsTotal}", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 5fc97e9792083..69d84e7a0f56b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5692,7 +5692,6 @@ "xpack.apm.transactionsTable.nameColumnLabel": "名称", "xpack.apm.transactionsTable.notFoundLabel": "未找到任何事务。", "xpack.apm.transactionsTable.throughputColumnLabel": "吞吐量", - "xpack.apm.transactionTypeSelectLabel": "类型", "xpack.apm.tutorial.apmServer.title": "APM Server", "xpack.apm.tutorial.elasticCloud.textPre": "要启用 APM Server,请前往 [Elastic Cloud 控制台](https://cloud.elastic.co/deployments?q={cloudId}) 并在部署设置中启用 APM。启用后,请刷新此页面。", "xpack.apm.tutorial.elasticCloudInstructions.title": "APM 代理", @@ -8191,20 +8190,7 @@ "xpack.features.ossFeatures.visualizeShortUrlSubFeatureName": "短 URL", "xpack.features.savedObjectsManagementFeatureName": "已保存对象管理", "xpack.features.visualizeFeatureName": "Visualize 库", - "xpack.fileUpload.httpService.fetchError": "执行提取时出错:{error}", - "xpack.fileUpload.httpService.noUrl": "未提供 URL", - "xpack.fileUpload.indexNameReqField": "索引名称,必填字段", - "xpack.fileUpload.indexSettings.enterIndexNameLabel": "索引名称", "xpack.fileUpload.indexSettings.enterIndexTypeLabel": "索引类型", - "xpack.fileUpload.indexSettings.guidelines.cannotBe": "不能为 . 或 ..", - "xpack.fileUpload.indexSettings.guidelines.cannotInclude": "不能包含 \\\\、/、*、?、\"、<、>、|、 “ ” (空格字符) 、, (逗号) 、#", - "xpack.fileUpload.indexSettings.guidelines.cannotStartWith": "不能以 -、_、+ 开头", - "xpack.fileUpload.indexSettings.guidelines.length": "不能长于 255 字节 (注意是字节, 因此多字节字符将更快达到 255 字节限制) ", - "xpack.fileUpload.indexSettings.guidelines.lowercaseOnly": "仅小写", - "xpack.fileUpload.indexSettings.guidelines.mustBeNewIndex": "必须是新索引", - "xpack.fileUpload.indexSettings.indexNameAlreadyExistsErrorMessage": "索引名称或模式已存在。", - "xpack.fileUpload.indexSettings.indexNameContainsIllegalCharactersErrorMessage": "索引名称包含非法字符。", - "xpack.fileUpload.indexSettings.indexNameGuidelines": "索引名称指引", "xpack.fileUpload.jsonUploadAndParse.dataIndexingError": "数据索引错误", "xpack.fileUpload.jsonUploadAndParse.indexPatternError": "索引模式错误", "xpack.fleet.agentBulkActions.agentsSelected": "已选择 {count, plural, other {# 个代理}}", @@ -15472,7 +15458,6 @@ "xpack.monitoring.cluster.overview.apmPanel.lastEventDescription": "{timeOfLastEvent}前", "xpack.monitoring.cluster.overview.apmPanel.lastEventLabel": "最后事件", "xpack.monitoring.cluster.overview.apmPanel.memoryUsageLabel": "内存利用率", - "xpack.monitoring.cluster.overview.apmPanel.overviewLinkAriaLabel": "APM 服务器概览", "xpack.monitoring.cluster.overview.apmPanel.overviewLinkLabel": "APM 服务器概览", "xpack.monitoring.cluster.overview.apmPanel.processedEventsLabel": "已处理事件", "xpack.monitoring.cluster.overview.apmPanel.serversTotalLinkLabel": "APM 服务器:{apmsTotal}", diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index 8e049be75434d..84c012eb01cf7 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -118,7 +118,7 @@ const Application = (props: UptimeAppProps) => { - +
diff --git a/x-pack/plugins/uptime/public/components/overview/query_bar/query_bar.tsx b/x-pack/plugins/uptime/public/components/overview/query_bar/query_bar.tsx index 54e2789dc666f..0543e5868bb9e 100644 --- a/x-pack/plugins/uptime/public/components/overview/query_bar/query_bar.tsx +++ b/x-pack/plugins/uptime/public/components/overview/query_bar/query_bar.tsx @@ -36,7 +36,7 @@ export const QueryBar = () => { const { query, setQuery } = useQueryBar(); - const { index_pattern: indexPattern } = useIndexPattern(query.language ?? SyntaxType.text); + const { index_pattern: indexPattern } = useIndexPattern(); const [inputVal, setInputVal] = useState(query.query); diff --git a/x-pack/plugins/uptime/public/components/overview/query_bar/use_index_pattern.ts b/x-pack/plugins/uptime/public/components/overview/query_bar/use_index_pattern.ts index ab10afb5b231e..b0e567c40ed73 100644 --- a/x-pack/plugins/uptime/public/components/overview/query_bar/use_index_pattern.ts +++ b/x-pack/plugins/uptime/public/components/overview/query_bar/use_index_pattern.ts @@ -9,18 +9,17 @@ import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { getIndexPattern } from '../../../state/actions'; import { selectIndexPattern } from '../../../state/selectors'; -import { SyntaxType } from './use_query_bar'; -export const useIndexPattern = (queryLanguage?: string) => { +export const useIndexPattern = () => { const dispatch = useDispatch(); const indexPattern = useSelector(selectIndexPattern); useEffect(() => { // we only use index pattern for kql queries - if (!indexPattern.index_pattern && (!queryLanguage || queryLanguage === SyntaxType.kuery)) { + if (!indexPattern.index_pattern) { dispatch(getIndexPattern()); } - }, [indexPattern.index_pattern, dispatch, queryLanguage]); + }, [indexPattern.index_pattern, dispatch]); return indexPattern; }; diff --git a/x-pack/plugins/uptime/public/components/overview/query_bar/use_query_bar.ts b/x-pack/plugins/uptime/public/components/overview/query_bar/use_query_bar.ts index 9e3691497eab6..0d8a2ee17994a 100644 --- a/x-pack/plugins/uptime/public/components/overview/query_bar/use_query_bar.ts +++ b/x-pack/plugins/uptime/public/components/overview/query_bar/use_query_bar.ts @@ -44,7 +44,7 @@ export const useQueryBar = () => { } ); - const { index_pattern: indexPattern } = useIndexPattern(query.language); + const { index_pattern: indexPattern } = useIndexPattern(); const updateUrlParams = useUrlParams()[1]; diff --git a/x-pack/test/api_integration/apis/logstash/cluster/load.ts b/x-pack/test/api_integration/apis/logstash/cluster/load.ts index fbfdc4d51dde9..1997b65c5a871 100644 --- a/x-pack/test/api_integration/apis/logstash/cluster/load.ts +++ b/x-pack/test/api_integration/apis/logstash/cluster/load.ts @@ -10,13 +10,13 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); 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(); + const { body: responseFromES } = await es.info(); expect(body.cluster.uuid).to.eql(responseFromES.cluster_uuid); }); }); diff --git a/x-pack/test/api_integration/apis/monitoring/apm/fixtures/cluster.json b/x-pack/test/api_integration/apis/monitoring/apm/fixtures/cluster.json index 197d8f8fe6c2c..1b89349785f26 100644 --- a/x-pack/test/api_integration/apis/monitoring/apm/fixtures/cluster.json +++ b/x-pack/test/api_integration/apis/monitoring/apm/fixtures/cluster.json @@ -9,7 +9,10 @@ "config": { "container": false }, - "timeOfLastEvent": "2018-08-31T13:59:21.199Z" + "timeOfLastEvent": "2018-08-31T13:59:21.199Z", + "versions": [ + "7.0.0-alpha1" + ] }, "metrics": { "apm_cpu": [ diff --git a/x-pack/test/api_integration/apis/monitoring/cluster/fixtures/multicluster.json b/x-pack/test/api_integration/apis/monitoring/cluster/fixtures/multicluster.json index 2bbd9029e6982..6abd3a8ecff9d 100644 --- a/x-pack/test/api_integration/apis/monitoring/cluster/fixtures/multicluster.json +++ b/x-pack/test/api_integration/apis/monitoring/cluster/fixtures/multicluster.json @@ -82,7 +82,8 @@ }, "config": { "container": false - } + }, + "versions": [ ] }, "alerts": { "alertsMeta": { @@ -178,7 +179,8 @@ }, "config": { "container": false - } + }, + "versions": [ ] }, "alerts": { "alertsMeta": { @@ -274,7 +276,8 @@ }, "config": { "container": false - } + }, + "versions": [ ] }, "alerts": { "alertsMeta": { diff --git a/x-pack/test/api_integration/apis/monitoring/cluster/fixtures/overview.json b/x-pack/test/api_integration/apis/monitoring/cluster/fixtures/overview.json index 7eaf2bbee289f..4f1024f2c94b0 100644 --- a/x-pack/test/api_integration/apis/monitoring/cluster/fixtures/overview.json +++ b/x-pack/test/api_integration/apis/monitoring/cluster/fixtures/overview.json @@ -96,7 +96,8 @@ }, "config": { "container": false - } + }, + "versions": [ ] }, "isCcrEnabled": true, "isPrimary": true, diff --git a/x-pack/test/api_integration/apis/monitoring/standalone_cluster/fixtures/cluster.json b/x-pack/test/api_integration/apis/monitoring/standalone_cluster/fixtures/cluster.json index e3ed40b197cc1..3e590656753f1 100644 --- a/x-pack/test/api_integration/apis/monitoring/standalone_cluster/fixtures/cluster.json +++ b/x-pack/test/api_integration/apis/monitoring/standalone_cluster/fixtures/cluster.json @@ -55,7 +55,8 @@ }, "config": { "container": false - } + }, + "versions": [] }, "isPrimary": false }] diff --git a/x-pack/test/api_integration/apis/monitoring/standalone_cluster/fixtures/clusters.json b/x-pack/test/api_integration/apis/monitoring/standalone_cluster/fixtures/clusters.json index 0034199325e5c..8f20dce44ee8a 100644 --- a/x-pack/test/api_integration/apis/monitoring/standalone_cluster/fixtures/clusters.json +++ b/x-pack/test/api_integration/apis/monitoring/standalone_cluster/fixtures/clusters.json @@ -82,7 +82,8 @@ }, "config": { "container": false - } + }, + "versions": [] }, "alerts": { "alertsMeta": { @@ -158,7 +159,8 @@ }, "config": { "container": false - } + }, + "versions": [] }, "alerts": { "alertsMeta": { diff --git a/x-pack/test/functional/apps/infra/constants.ts b/x-pack/test/functional/apps/infra/constants.ts index 04f4334201377..72d291158d6dd 100644 --- a/x-pack/test/functional/apps/infra/constants.ts +++ b/x-pack/test/functional/apps/infra/constants.ts @@ -29,3 +29,12 @@ export const DATES = { }, }, }; + +export const ML_JOB_IDS = [ + 'kibana-metrics-ui-default-default-hosts_memory_usage', + 'kibana-metrics-ui-default-default-hosts_network_out', + 'kibana-metrics-ui-default-default-hosts_network_in', + 'kibana-metrics-ui-default-default-k8s_network_out', + 'kibana-metrics-ui-default-default-k8s_network_in', + 'kibana-metrics-ui-default-default-k8s_memory_usage', +]; diff --git a/x-pack/test/functional/apps/infra/index.ts b/x-pack/test/functional/apps/infra/index.ts index e71f16bb868e2..9c828253245d0 100644 --- a/x-pack/test/functional/apps/infra/index.ts +++ b/x-pack/test/functional/apps/infra/index.ts @@ -10,7 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ loadTestFile }: FtrProviderContext) => { describe('InfraOps app', function () { this.tags('ciGroup7'); - + loadTestFile(require.resolve('./metrics_anomalies')); loadTestFile(require.resolve('./home_page')); loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./log_entry_categories_tab')); diff --git a/x-pack/test/functional/apps/infra/metrics_anomalies.ts b/x-pack/test/functional/apps/infra/metrics_anomalies.ts new file mode 100644 index 0000000000000..19bba77e5ca18 --- /dev/null +++ b/x-pack/test/functional/apps/infra/metrics_anomalies.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { ML_JOB_IDS } from './constants'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const pageObjects = getPageObjects(['common', 'infraHome']); + const infraSourceConfigurationForm = getService('infraSourceConfigurationForm'); + + describe('Metrics UI Anomaly Flyout', function () { + before(async () => { + await esArchiver.load('empty_kibana'); + }); + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('with no anomalies present', () => { + it('renders the anomaly flyout', async () => { + await pageObjects.common.navigateToApp('infraOps'); + await pageObjects.infraHome.openAnomalyFlyout(); + }); + it('renders the anomaly table with no anomalies', async () => { + await pageObjects.infraHome.goToAnomaliesTab(); + await pageObjects.infraHome.clickHostsAnomaliesDropdown(); + await pageObjects.infraHome.getNoAnomaliesMsg(); + await pageObjects.infraHome.clickK8sAnomaliesDropdown(); + await pageObjects.infraHome.getNoAnomaliesMsg(); + await pageObjects.infraHome.closeFlyout(); + }); + }); + + describe('with anomalies present', () => { + before(async () => { + await esArchiver.load('infra/metrics_anomalies'); + // create the ml jobs saved objects + await Promise.all( + ML_JOB_IDS.map((id) => + kibanaServer.savedObjects.create({ + type: 'ml-job', + id: `anomaly-detector-${id}`, + overwrite: true, + attributes: { + job_id: id, + datafeed_id: `datafeed-${id}`, + type: 'anomaly-detector', + }, + }) + ) + ); + }); + after(async () => { + await esArchiver.unload('infra/metrics_anomalies'); + }); + it('renders the anomaly table with anomalies', async () => { + await pageObjects.infraHome.openAnomalyFlyout(); + await pageObjects.infraHome.goToAnomaliesTab(); + await pageObjects.infraHome.clickHostsAnomaliesDropdown(); + await pageObjects.infraHome.setAnomaliesDate('Apr 21, 2021 @ 00:00:00.000'); + const hostAnomalies = await pageObjects.infraHome.findAnomalies(); + // expect 2 anomalies with default Anomaly Severity Threshold setting (50) + expect(hostAnomalies.length).to.be(2); + await pageObjects.infraHome.clickK8sAnomaliesDropdown(); + const k8sAnomalies = await pageObjects.infraHome.findAnomalies(); + // expect 3 anomalies with default Anomaly Severity Threshold setting (50) + expect(k8sAnomalies.length).to.be(1); + await pageObjects.infraHome.closeFlyout(); + }); + it('renders the anomaly table after a date change with no anomalies', async () => { + await pageObjects.infraHome.openAnomalyFlyout(); + await pageObjects.infraHome.goToAnomaliesTab(); + await pageObjects.infraHome.clickHostsAnomaliesDropdown(); + await pageObjects.infraHome.setAnomaliesDate('Apr 23, 2021 @ 11:00:00.000'); + await pageObjects.infraHome.getNoAnomaliesMsg(); + await pageObjects.infraHome.clickK8sAnomaliesDropdown(); + await pageObjects.infraHome.getNoAnomaliesMsg(); + await pageObjects.infraHome.closeFlyout(); + }); + it('renders more anomalies on threshold change', async () => { + await pageObjects.infraHome.goToSettings(); + await pageObjects.infraHome.setAnomaliesThreshold('25'); + await infraSourceConfigurationForm.saveConfiguration(); + await pageObjects.infraHome.goToInventory(); + await pageObjects.infraHome.openAnomalyFlyout(); + await pageObjects.infraHome.goToAnomaliesTab(); + await pageObjects.infraHome.clickHostsAnomaliesDropdown(); + const hostAnomalies = await pageObjects.infraHome.findAnomalies(); + expect(hostAnomalies.length).to.be(4); + await pageObjects.infraHome.clickK8sAnomaliesDropdown(); + const k8sAnomalies = await pageObjects.infraHome.findAnomalies(); + expect(k8sAnomalies.length).to.be(3); + }); + }); + }); +}; diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts b/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts index 66d45c801b81a..c713343b3e380 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts @@ -324,6 +324,17 @@ export default function ({ getService }: FtrProviderContext) { await ml.anomalyExplorer.assertAnomalyExplorerChartsCount(0); }); + it('allows to change the anomalies table pagination', async () => { + await ml.testExecution.logTestStep('displays the anomalies table with default config'); + await ml.anomaliesTable.assertTableExists(); + await ml.anomaliesTable.assertRowsNumberPerPage(25); + await ml.anomaliesTable.assertTableRowsCount(25); + + await ml.testExecution.logTestStep('updates table pagination'); + await ml.anomaliesTable.setRowsNumberPerPage(10); + await ml.anomaliesTable.assertTableRowsCount(10); + }); + it('adds swim lane embeddable to a dashboard', async () => { // should be the last step because it navigates away from the Anomaly Explorer page await ml.testExecution.logTestStep( diff --git a/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts b/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts index 65bc68db25aa1..a8fed205a9e56 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts @@ -269,6 +269,9 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('imports the file'); await ml.dataVisualizerFileBased.startImportAndWaitForProcessing(); + + await ml.testExecution.logTestStep('creates filebeat config'); + await ml.dataVisualizerFileBased.selectCreateFilebeatConfig(); }); }); } diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index a720aec6bb478..61579ac68ae53 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -89,6 +89,7 @@ export default function ({ getService }: FtrProviderContext) { get destinationIndex(): string { return `user-${this.transformId}`; }, + discoverAdjustSuperDatePicker: true, expected: { pivotAdvancedEditorValueArr: ['{', ' "group_by": {', ' "category.keyword": {'], pivotAdvancedEditorValue: { @@ -210,6 +211,7 @@ export default function ({ getService }: FtrProviderContext) { ], }, ], + discoverQueryHits: '7,270', }, } as PivotTransformTestData, { @@ -247,6 +249,7 @@ export default function ({ getService }: FtrProviderContext) { get destinationIndex(): string { return `user-${this.transformId}`; }, + discoverAdjustSuperDatePicker: false, expected: { pivotAdvancedEditorValueArr: ['{', ' "group_by": {', ' "geoip.country_iso_code": {'], pivotAdvancedEditorValue: { @@ -294,6 +297,7 @@ export default function ({ getService }: FtrProviderContext) { rows: 5, }, histogramCharts: [], + discoverQueryHits: '10', }, } as PivotTransformTestData, { @@ -317,6 +321,7 @@ export default function ({ getService }: FtrProviderContext) { get destinationIndex(): string { return `user-${this.transformId}`; }, + discoverAdjustSuperDatePicker: true, expected: { latestPreview: { column: 0, @@ -342,6 +347,7 @@ export default function ({ getService }: FtrProviderContext) { 'July 12th 2019, 23:31:12', ], }, + discoverQueryHits: '10', }, } as LatestTransformTestData, ]; @@ -533,6 +539,26 @@ export default function ({ getService }: FtrProviderContext) { progress: testData.expected.row.progress, }); }); + + it('navigates to discover and displays results of the destination index', async () => { + await transform.testExecution.logTestStep('should show the actions popover'); + await transform.table.assertTransformRowActions(testData.transformId, false); + + await transform.testExecution.logTestStep('should navigate to discover'); + await transform.table.clickTransformRowAction('Discover'); + + if (testData.discoverAdjustSuperDatePicker) { + await transform.discover.assertNoResults(testData.destinationIndex); + await transform.testExecution.logTestStep( + 'should switch quick select lookback to years' + ); + await transform.discover.assertSuperDatePickerToggleQuickMenuButtonExists(); + await transform.discover.openSuperDatePicker(); + await transform.discover.quickSelectYears(); + } + + await transform.discover.assertDiscoverQueryHits(testData.expected.discoverQueryHits); + }); }); } }); diff --git a/x-pack/test/functional/apps/transform/index.ts b/x-pack/test/functional/apps/transform/index.ts index 89ac903b16d01..ca82459c47f2f 100644 --- a/x-pack/test/functional/apps/transform/index.ts +++ b/x-pack/test/functional/apps/transform/index.ts @@ -66,6 +66,7 @@ export interface BaseTransformTestData { transformDescription: string; expected: any; destinationIndex: string; + discoverAdjustSuperDatePicker: boolean; } export interface PivotTransformTestData extends BaseTransformTestData { diff --git a/x-pack/test/functional/es_archives/infra/metrics_anomalies/data.json.gz b/x-pack/test/functional/es_archives/infra/metrics_anomalies/data.json.gz new file mode 100644 index 0000000000000..68ad6965d0fb1 Binary files /dev/null and b/x-pack/test/functional/es_archives/infra/metrics_anomalies/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/infra/metrics_anomalies/mappings.json b/x-pack/test/functional/es_archives/infra/metrics_anomalies/mappings.json new file mode 100644 index 0000000000000..82f4b49201ef1 --- /dev/null +++ b/x-pack/test/functional/es_archives/infra/metrics_anomalies/mappings.json @@ -0,0 +1,1894 @@ +{ + "type": "index", + "value": { + "aliases": { + }, + "index": ".ds-metrics-kubernetes.pod-slingshot-2021.04.23-000001", + "mappings": { + "_data_stream_timestamp": { + "enabled": true + }, + "date_detection": false, + "dynamic_templates": [ + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "cloud": { + "properties": { + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "data_stream": { + "properties": { + "dataset": { + "type": "constant_keyword" + }, + "namespace": { + "type": "constant_keyword" + }, + "type": { + "type": "constant_keyword", + "value": "metrics" + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "ip": { + "type": "ip" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "kubernetes": { + "properties": { + "namespace": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pod": { + "properties": { + "cpu": { + "properties": { + "usage": { + "properties": { + "limit": { + "properties": { + "pct": { + "type": "long" + } + } + }, + "node": { + "properties": { + "pct": { + "type": "float" + } + } + } + } + } + } + }, + "host_ip": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "ignore_above": 1024, + "type": "keyword" + }, + "memory": { + "properties": { + "usage": { + "properties": { + "limit": { + "properties": { + "pct": { + "type": "float" + } + } + }, + "node": { + "properties": { + "pct": { + "type": "float" + } + } + } + } + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "network": { + "properties": { + "in": { + "properties": { + "bytes": { + "type": "float" + }, + "errors": { + "type": "float" + } + } + }, + "out": { + "properties": { + "bytes": { + "type": "float" + }, + "errors": { + "type": "float" + } + } + } + } + }, + "status": { + "properties": { + "phase": { + "ignore_above": 1024, + "type": "keyword" + }, + "ready": { + "type": "boolean" + }, + "scheduled": { + "type": "boolean" + } + } + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "metricset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "period": { + "type": "long" + } + } + }, + "service": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "codec": "best_compression", + "hidden": "true", + "lifecycle": { + "name": "metrics" + }, + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} + +{ + "type": "index", + "value": { + "aliases": { + }, + "index": ".ds-metrics-system-slingshot-2021.04.23-000001", + "mappings": { + "_data_stream_timestamp": { + "enabled": true + }, + "date_detection": false, + "dynamic_templates": [ + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "cloud": { + "properties": { + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "data_stream": { + "properties": { + "dataset": { + "type": "constant_keyword" + }, + "namespace": { + "type": "constant_keyword" + }, + "type": { + "type": "constant_keyword", + "value": "metrics" + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "cpu": { + "properties": { + "pct": { + "type": "float" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "network": { + "properties": { + "egress": { + "properties": { + "bytes": { + "type": "float" + } + } + }, + "in": { + "properties": { + "bytes": { + "type": "float" + } + } + }, + "ingress": { + "properties": { + "bytes": { + "type": "float" + } + } + }, + "out": { + "properties": { + "bytes": { + "type": "float" + } + } + } + } + }, + "os": { + "properties": { + "platform": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "metricset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "period": { + "type": "long" + } + } + }, + "service": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "system": { + "properties": { + "cpu": { + "properties": { + "cores": { + "type": "long" + }, + "idle": { + "properties": { + "norm": { + "properties": { + "pct": { + "type": "float" + } + } + }, + "pct": { + "type": "float" + } + } + }, + "nice": { + "properties": { + "norm": { + "properties": { + "pct": { + "type": "long" + } + } + }, + "pct": { + "type": "long" + } + } + }, + "system": { + "properties": { + "norm": { + "properties": { + "pct": { + "type": "float" + } + } + }, + "pct": { + "type": "float" + } + } + }, + "total": { + "properties": { + "norm": { + "properties": { + "pct": { + "type": "float" + } + } + }, + "pct": { + "type": "float" + } + } + }, + "user": { + "properties": { + "norm": { + "properties": { + "pct": { + "type": "float" + } + } + }, + "pct": { + "type": "float" + } + } + } + } + }, + "load": { + "properties": { + "1": { + "type": "float" + }, + "15": { + "type": "float" + }, + "5": { + "type": "float" + }, + "norm": { + "properties": { + "1": { + "type": "float" + }, + "15": { + "type": "float" + }, + "5": { + "type": "float" + } + } + } + } + }, + "memory": { + "properties": { + "actual": { + "properties": { + "free": { + "type": "long" + }, + "used": { + "properties": { + "bytes": { + "type": "long" + }, + "pct": { + "type": "float" + } + } + } + } + }, + "free": { + "type": "long" + }, + "swap": { + "properties": { + "free": { + "type": "long" + }, + "total": { + "type": "long" + }, + "used": { + "properties": { + "bytes": { + "type": "long" + }, + "pct": { + "type": "float" + } + } + } + } + }, + "total": { + "type": "long" + }, + "used": { + "properties": { + "bytes": { + "type": "long" + }, + "pct": { + "type": "float" + } + } + } + } + }, + "network": { + "properties": { + "in": { + "properties": { + "bytes": { + "type": "float" + }, + "dropped": { + "type": "long" + }, + "errors": { + "type": "long" + }, + "packets": { + "type": "float" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "out": { + "properties": { + "bytes": { + "type": "float" + }, + "dropped": { + "type": "float" + }, + "errors": { + "type": "float" + }, + "packets": { + "type": "float" + } + } + } + } + }, + "uptime": { + "properties": { + "duration": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + } + } + } + } + }, + "settings": { + "index": { + "codec": "best_compression", + "hidden": "true", + "lifecycle": { + "name": "metrics" + }, + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} + +{ + "type": "index", + "value": { + "aliases": { + ".ml-anomalies-.write-kibana-metrics-ui-default-default-hosts_memory_usage": { + "is_hidden": true + }, + ".ml-anomalies-.write-kibana-metrics-ui-default-default-hosts_network_in": { + "is_hidden": true + }, + ".ml-anomalies-.write-kibana-metrics-ui-default-default-hosts_network_out": { + "is_hidden": true + }, + ".ml-anomalies-.write-kibana-metrics-ui-default-default-k8s_memory_usage": { + "is_hidden": true + }, + ".ml-anomalies-.write-kibana-metrics-ui-default-default-k8s_network_in": { + "is_hidden": true + }, + ".ml-anomalies-.write-kibana-metrics-ui-default-default-k8s_network_out": { + "is_hidden": true + }, + ".ml-anomalies-kibana-metrics-ui-default-default-hosts_memory_usage": { + "filter": { + "term": { + "job_id": { + "boost": 1, + "value": "kibana-metrics-ui-default-default-hosts_memory_usage" + } + } + }, + "is_hidden": true + }, + ".ml-anomalies-kibana-metrics-ui-default-default-hosts_network_in": { + "filter": { + "term": { + "job_id": { + "boost": 1, + "value": "kibana-metrics-ui-default-default-hosts_network_in" + } + } + }, + "is_hidden": true + }, + ".ml-anomalies-kibana-metrics-ui-default-default-hosts_network_out": { + "filter": { + "term": { + "job_id": { + "boost": 1, + "value": "kibana-metrics-ui-default-default-hosts_network_out" + } + } + }, + "is_hidden": true + }, + ".ml-anomalies-kibana-metrics-ui-default-default-k8s_memory_usage": { + "filter": { + "term": { + "job_id": { + "boost": 1, + "value": "kibana-metrics-ui-default-default-k8s_memory_usage" + } + } + }, + "is_hidden": true + }, + ".ml-anomalies-kibana-metrics-ui-default-default-k8s_network_in": { + "filter": { + "term": { + "job_id": { + "boost": 1, + "value": "kibana-metrics-ui-default-default-k8s_network_in" + } + } + }, + "is_hidden": true + }, + ".ml-anomalies-kibana-metrics-ui-default-default-k8s_network_out": { + "filter": { + "term": { + "job_id": { + "boost": 1, + "value": "kibana-metrics-ui-default-default-k8s_network_out" + } + } + }, + "is_hidden": true + } + }, + "index": ".ml-anomalies-shared", + "mappings": { + "_meta": { + "version": "8.0.0" + }, + "dynamic_templates": [ + { + "strings_as_keywords": { + "mapping": { + "type": "keyword" + }, + "match": "*" + } + } + ], + "properties": { + "actual": { + "type": "double" + }, + "all_field_values": { + "analyzer": "whitespace", + "type": "text" + }, + "anomaly_score": { + "type": "double" + }, + "assignment_memory_basis": { + "type": "keyword" + }, + "average_bucket_processing_time_ms": { + "type": "double" + }, + "bucket_allocation_failures_count": { + "type": "long" + }, + "bucket_count": { + "type": "long" + }, + "bucket_influencers": { + "properties": { + "anomaly_score": { + "type": "double" + }, + "bucket_span": { + "type": "long" + }, + "influencer_field_name": { + "type": "keyword" + }, + "initial_anomaly_score": { + "type": "double" + }, + "is_interim": { + "type": "boolean" + }, + "job_id": { + "type": "keyword" + }, + "probability": { + "type": "double" + }, + "raw_anomaly_score": { + "type": "double" + }, + "result_type": { + "type": "keyword" + }, + "timestamp": { + "type": "date" + } + }, + "type": "nested" + }, + "bucket_span": { + "type": "long" + }, + "by_field_name": { + "type": "keyword" + }, + "by_field_value": { + "copy_to": [ + "all_field_values" + ], + "type": "keyword" + }, + "categorization_status": { + "type": "keyword" + }, + "categorized_doc_count": { + "type": "keyword" + }, + "category_id": { + "type": "long" + }, + "causes": { + "properties": { + "actual": { + "type": "double" + }, + "by_field_name": { + "type": "keyword" + }, + "by_field_value": { + "copy_to": [ + "all_field_values" + ], + "type": "keyword" + }, + "correlated_by_field_value": { + "copy_to": [ + "all_field_values" + ], + "type": "keyword" + }, + "field_name": { + "type": "keyword" + }, + "function": { + "type": "keyword" + }, + "function_description": { + "type": "keyword" + }, + "geo_results": { + "properties": { + "actual_point": { + "type": "geo_point" + }, + "typical_point": { + "type": "geo_point" + } + } + }, + "over_field_name": { + "type": "keyword" + }, + "over_field_value": { + "copy_to": [ + "all_field_values" + ], + "type": "keyword" + }, + "partition_field_name": { + "type": "keyword" + }, + "partition_field_value": { + "copy_to": [ + "all_field_values" + ], + "type": "keyword" + }, + "probability": { + "type": "double" + }, + "typical": { + "type": "double" + } + }, + "type": "nested" + }, + "dead_category_count": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "detector_index": { + "type": "integer" + }, + "earliest_record_timestamp": { + "type": "date" + }, + "empty_bucket_count": { + "type": "long" + }, + "event_count": { + "type": "long" + }, + "examples": { + "type": "text" + }, + "exponential_average_bucket_processing_time_ms": { + "type": "double" + }, + "exponential_average_calculation_context": { + "properties": { + "incremental_metric_value_ms": { + "type": "double" + }, + "latest_timestamp": { + "type": "date" + }, + "previous_exponential_average_ms": { + "type": "double" + } + } + }, + "failed_category_count": { + "type": "keyword" + }, + "field_name": { + "type": "keyword" + }, + "forecast_create_timestamp": { + "type": "date" + }, + "forecast_end_timestamp": { + "type": "date" + }, + "forecast_expiry_timestamp": { + "type": "date" + }, + "forecast_id": { + "type": "keyword" + }, + "forecast_lower": { + "type": "double" + }, + "forecast_memory_bytes": { + "type": "long" + }, + "forecast_messages": { + "type": "keyword" + }, + "forecast_prediction": { + "type": "double" + }, + "forecast_progress": { + "type": "double" + }, + "forecast_start_timestamp": { + "type": "date" + }, + "forecast_status": { + "type": "keyword" + }, + "forecast_upper": { + "type": "double" + }, + "frequent_category_count": { + "type": "keyword" + }, + "function": { + "type": "keyword" + }, + "function_description": { + "type": "keyword" + }, + "geo_results": { + "properties": { + "actual_point": { + "type": "geo_point" + }, + "typical_point": { + "type": "geo_point" + } + } + }, + "host": { + "properties": { + "name": { + "type": "keyword" + } + } + }, + "influencer_field_name": { + "type": "keyword" + }, + "influencer_field_value": { + "copy_to": [ + "all_field_values" + ], + "type": "keyword" + }, + "influencer_score": { + "type": "double" + }, + "influencers": { + "properties": { + "influencer_field_name": { + "type": "keyword" + }, + "influencer_field_values": { + "copy_to": [ + "all_field_values" + ], + "type": "keyword" + } + }, + "type": "nested" + }, + "initial_anomaly_score": { + "type": "double" + }, + "initial_influencer_score": { + "type": "double" + }, + "initial_record_score": { + "type": "double" + }, + "input_bytes": { + "type": "long" + }, + "input_field_count": { + "type": "long" + }, + "input_record_count": { + "type": "long" + }, + "invalid_date_count": { + "type": "long" + }, + "is_interim": { + "type": "boolean" + }, + "job_id": { + "copy_to": [ + "all_field_values" + ], + "type": "keyword" + }, + "kubernetes": { + "properties": { + "namespace": { + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "type": "keyword" + } + } + }, + "pod": { + "properties": { + "uid": { + "type": "keyword" + } + } + } + } + }, + "last_data_time": { + "type": "date" + }, + "latest_empty_bucket_timestamp": { + "type": "date" + }, + "latest_record_time_stamp": { + "type": "date" + }, + "latest_record_timestamp": { + "type": "date" + }, + "latest_result_time_stamp": { + "type": "date" + }, + "latest_sparse_bucket_timestamp": { + "type": "date" + }, + "log_time": { + "type": "date" + }, + "max_matching_length": { + "type": "long" + }, + "maximum_bucket_processing_time_ms": { + "type": "double" + }, + "memory_status": { + "type": "keyword" + }, + "min_version": { + "type": "keyword" + }, + "minimum_bucket_processing_time_ms": { + "type": "double" + }, + "missing_field_count": { + "type": "long" + }, + "mlcategory": { + "type": "keyword" + }, + "model_bytes": { + "type": "long" + }, + "model_bytes_exceeded": { + "type": "keyword" + }, + "model_bytes_memory_limit": { + "type": "keyword" + }, + "model_feature": { + "type": "keyword" + }, + "model_lower": { + "type": "double" + }, + "model_median": { + "type": "double" + }, + "model_size_stats": { + "properties": { + "assignment_memory_basis": { + "type": "keyword" + }, + "bucket_allocation_failures_count": { + "type": "long" + }, + "categorization_status": { + "type": "keyword" + }, + "categorized_doc_count": { + "type": "keyword" + }, + "dead_category_count": { + "type": "keyword" + }, + "failed_category_count": { + "type": "keyword" + }, + "frequent_category_count": { + "type": "keyword" + }, + "job_id": { + "type": "keyword" + }, + "log_time": { + "type": "date" + }, + "memory_status": { + "type": "keyword" + }, + "model_bytes": { + "type": "long" + }, + "model_bytes_exceeded": { + "type": "keyword" + }, + "model_bytes_memory_limit": { + "type": "keyword" + }, + "peak_model_bytes": { + "type": "long" + }, + "rare_category_count": { + "type": "keyword" + }, + "result_type": { + "type": "keyword" + }, + "timestamp": { + "type": "date" + }, + "total_by_field_count": { + "type": "long" + }, + "total_category_count": { + "type": "keyword" + }, + "total_over_field_count": { + "type": "long" + }, + "total_partition_field_count": { + "type": "long" + } + } + }, + "model_upper": { + "type": "double" + }, + "multi_bucket_impact": { + "type": "double" + }, + "num_matches": { + "type": "long" + }, + "out_of_order_timestamp_count": { + "type": "long" + }, + "over_field_name": { + "type": "keyword" + }, + "over_field_value": { + "copy_to": [ + "all_field_values" + ], + "type": "keyword" + }, + "partition_field_name": { + "type": "keyword" + }, + "partition_field_value": { + "copy_to": [ + "all_field_values" + ], + "type": "keyword" + }, + "peak_model_bytes": { + "type": "keyword" + }, + "preferred_to_categories": { + "type": "long" + }, + "probability": { + "type": "double" + }, + "processed_field_count": { + "type": "long" + }, + "processed_record_count": { + "type": "long" + }, + "processing_time_ms": { + "type": "long" + }, + "quantiles": { + "enabled": false, + "type": "object" + }, + "rare_category_count": { + "type": "keyword" + }, + "raw_anomaly_score": { + "type": "double" + }, + "record_score": { + "type": "double" + }, + "regex": { + "type": "keyword" + }, + "result_type": { + "type": "keyword" + }, + "retain": { + "type": "boolean" + }, + "scheduled_events": { + "type": "keyword" + }, + "search_count": { + "type": "long" + }, + "snapshot_doc_count": { + "type": "integer" + }, + "snapshot_id": { + "type": "keyword" + }, + "sparse_bucket_count": { + "type": "long" + }, + "terms": { + "type": "text" + }, + "timestamp": { + "type": "date" + }, + "total_by_field_count": { + "type": "long" + }, + "total_category_count": { + "type": "keyword" + }, + "total_over_field_count": { + "type": "long" + }, + "total_partition_field_count": { + "type": "long" + }, + "total_search_time_ms": { + "type": "double" + }, + "typical": { + "type": "double" + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "hidden": "true", + "number_of_replicas": "0", + "number_of_shards": "1", + "translog": { + "durability": "async" + } + } + } + } +} + +{ + "type": "index", + "value": { + "aliases": { + }, + "index": ".ml-config", + "mappings": { + "_meta": { + "version": "8.0.0" + }, + "dynamic_templates": [ + { + "strings_as_keywords": { + "mapping": { + "type": "keyword" + }, + "match": "*" + } + } + ], + "properties": { + "aggregations": { + "enabled": false, + "type": "object" + }, + "allow_lazy_open": { + "type": "keyword" + }, + "allow_lazy_start": { + "type": "keyword" + }, + "analysis": { + "properties": { + "classification": { + "properties": { + "alpha": { + "type": "double" + }, + "class_assignment_objective": { + "type": "keyword" + }, + "dependent_variable": { + "type": "keyword" + }, + "downsample_factor": { + "type": "double" + }, + "early_stopping_enabled": { + "type": "boolean" + }, + "eta": { + "type": "double" + }, + "eta_growth_rate_per_tree": { + "type": "double" + }, + "feature_bag_fraction": { + "type": "double" + }, + "feature_processors": { + "enabled": false, + "type": "object" + }, + "gamma": { + "type": "double" + }, + "lambda": { + "type": "double" + }, + "max_optimization_rounds_per_hyperparameter": { + "type": "integer" + }, + "max_trees": { + "type": "integer" + }, + "num_top_classes": { + "type": "integer" + }, + "num_top_feature_importance_values": { + "type": "integer" + }, + "prediction_field_name": { + "type": "keyword" + }, + "randomize_seed": { + "type": "keyword" + }, + "soft_tree_depth_limit": { + "type": "double" + }, + "soft_tree_depth_tolerance": { + "type": "double" + }, + "training_percent": { + "type": "double" + } + } + }, + "outlier_detection": { + "properties": { + "compute_feature_influence": { + "type": "keyword" + }, + "feature_influence_threshold": { + "type": "double" + }, + "method": { + "type": "keyword" + }, + "n_neighbors": { + "type": "integer" + }, + "outlier_fraction": { + "type": "keyword" + }, + "standardization_enabled": { + "type": "keyword" + } + } + }, + "regression": { + "properties": { + "alpha": { + "type": "double" + }, + "dependent_variable": { + "type": "keyword" + }, + "downsample_factor": { + "type": "double" + }, + "early_stopping_enabled": { + "type": "boolean" + }, + "eta": { + "type": "double" + }, + "eta_growth_rate_per_tree": { + "type": "double" + }, + "feature_bag_fraction": { + "type": "double" + }, + "feature_processors": { + "enabled": false, + "type": "object" + }, + "gamma": { + "type": "double" + }, + "lambda": { + "type": "double" + }, + "loss_function": { + "type": "keyword" + }, + "loss_function_parameter": { + "type": "double" + }, + "max_optimization_rounds_per_hyperparameter": { + "type": "integer" + }, + "max_trees": { + "type": "integer" + }, + "num_top_feature_importance_values": { + "type": "integer" + }, + "prediction_field_name": { + "type": "keyword" + }, + "randomize_seed": { + "type": "keyword" + }, + "soft_tree_depth_limit": { + "type": "double" + }, + "soft_tree_depth_tolerance": { + "type": "double" + }, + "training_percent": { + "type": "double" + } + } + } + } + }, + "analysis_config": { + "properties": { + "bucket_span": { + "type": "keyword" + }, + "categorization_analyzer": { + "enabled": false, + "type": "object" + }, + "categorization_field_name": { + "type": "keyword" + }, + "categorization_filters": { + "type": "keyword" + }, + "detectors": { + "properties": { + "by_field_name": { + "type": "keyword" + }, + "custom_rules": { + "properties": { + "actions": { + "type": "keyword" + }, + "conditions": { + "properties": { + "applies_to": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "value": { + "type": "double" + } + }, + "type": "nested" + }, + "scope": { + "enabled": false, + "type": "object" + } + }, + "type": "nested" + }, + "detector_description": { + "type": "text" + }, + "detector_index": { + "type": "integer" + }, + "exclude_frequent": { + "type": "keyword" + }, + "field_name": { + "type": "keyword" + }, + "function": { + "type": "keyword" + }, + "over_field_name": { + "type": "keyword" + }, + "partition_field_name": { + "type": "keyword" + }, + "use_null": { + "type": "boolean" + } + } + }, + "influencers": { + "type": "keyword" + }, + "latency": { + "type": "keyword" + }, + "multivariate_by_fields": { + "type": "boolean" + }, + "per_partition_categorization": { + "properties": { + "enabled": { + "type": "boolean" + }, + "stop_on_warn": { + "type": "boolean" + } + } + }, + "summary_count_field_name": { + "type": "keyword" + } + } + }, + "analysis_limits": { + "properties": { + "categorization_examples_limit": { + "type": "long" + }, + "model_memory_limit": { + "type": "keyword" + } + } + }, + "analyzed_fields": { + "enabled": false, + "type": "object" + }, + "background_persist_interval": { + "type": "keyword" + }, + "chunking_config": { + "properties": { + "mode": { + "type": "keyword" + }, + "time_span": { + "type": "keyword" + } + } + }, + "config_type": { + "type": "keyword" + }, + "create_time": { + "type": "date" + }, + "custom_settings": { + "enabled": false, + "type": "object" + }, + "daily_model_snapshot_retention_after_days": { + "type": "long" + }, + "data_description": { + "properties": { + "field_delimiter": { + "type": "keyword" + }, + "format": { + "type": "keyword" + }, + "quote_character": { + "type": "keyword" + }, + "time_field": { + "type": "keyword" + }, + "time_format": { + "type": "keyword" + } + } + }, + "datafeed_id": { + "type": "keyword" + }, + "delayed_data_check_config": { + "properties": { + "check_window": { + "type": "keyword" + }, + "enabled": { + "type": "boolean" + } + } + }, + "deleting": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "dest": { + "properties": { + "index": { + "type": "keyword" + }, + "results_field": { + "type": "keyword" + } + } + }, + "finished_time": { + "type": "date" + }, + "frequency": { + "type": "keyword" + }, + "groups": { + "type": "keyword" + }, + "headers": { + "enabled": false, + "type": "object" + }, + "id": { + "type": "keyword" + }, + "indices": { + "type": "keyword" + }, + "indices_options": { + "enabled": false, + "type": "object" + }, + "job_id": { + "type": "keyword" + }, + "job_type": { + "type": "keyword" + }, + "job_version": { + "type": "keyword" + }, + "max_empty_searches": { + "type": "keyword" + }, + "max_num_threads": { + "type": "integer" + }, + "model_memory_limit": { + "type": "keyword" + }, + "model_plot_config": { + "properties": { + "annotations_enabled": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "terms": { + "type": "keyword" + } + } + }, + "model_snapshot_id": { + "type": "keyword" + }, + "model_snapshot_min_version": { + "type": "keyword" + }, + "model_snapshot_retention_days": { + "type": "long" + }, + "query": { + "enabled": false, + "type": "object" + }, + "query_delay": { + "type": "keyword" + }, + "renormalization_window_days": { + "type": "long" + }, + "results_index_name": { + "type": "keyword" + }, + "results_retention_days": { + "type": "long" + }, + "runtime_mappings": { + "enabled": false, + "type": "object" + }, + "script_fields": { + "enabled": false, + "type": "object" + }, + "scroll_size": { + "type": "long" + }, + "source": { + "properties": { + "_source": { + "enabled": false, + "type": "object" + }, + "index": { + "type": "keyword" + }, + "query": { + "enabled": false, + "type": "object" + }, + "runtime_mappings": { + "enabled": false, + "type": "object" + } + } + }, + "version": { + "type": "keyword" + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "max_result_window": "10000", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} \ No newline at end of file diff --git a/x-pack/test/functional/page_objects/infra_home_page.ts b/x-pack/test/functional/page_objects/infra_home_page.ts index 17f1efd62d21a..04dfbe5da0002 100644 --- a/x-pack/test/functional/page_objects/infra_home_page.ts +++ b/x-pack/test/functional/page_objects/infra_home_page.ts @@ -57,6 +57,14 @@ export function InfraHomePageProvider({ getService }: FtrProviderContext) { return await testSubjects.click('goToDocker'); }, + async goToSettings() { + await testSubjects.click('infrastructureNavLink_/settings'); + }, + + async goToInventory() { + await testSubjects.click('infrastructureNavLink_/inventory'); + }, + async goToMetricExplorer() { return await testSubjects.click('infrastructureNavLink_/infrastructure/metrics-explorer'); }, @@ -102,5 +110,45 @@ export function InfraHomePageProvider({ getService }: FtrProviderContext) { async waitForLoading() { await testSubjects.missingOrFail('loadingMessage', { timeout: 20000 }); }, + + async openAnomalyFlyout() { + await testSubjects.click('openAnomalyFlyoutButton'); + await testSubjects.exists('loadMLFlyout'); + }, + async closeFlyout() { + await testSubjects.click('euiFlyoutCloseButton'); + }, + async goToAnomaliesTab() { + await testSubjects.click('anomalyFlyoutAnomaliesTab'); + }, + async getNoAnomaliesMsg() { + await testSubjects.find('noAnomaliesFoundMsg'); + }, + async clickHostsAnomaliesDropdown() { + await testSubjects.click('anomaliesComboBoxType'); + await testSubjects.click('anomaliesHostComboBoxItem'); + }, + async clickK8sAnomaliesDropdown() { + await testSubjects.click('anomaliesComboBoxType'); + await testSubjects.click('anomaliesK8sComboBoxItem'); + }, + async findAnomalies() { + return testSubjects.findAll('anomalyRow'); + }, + async setAnomaliesDate(date: string) { + await testSubjects.click('superDatePickerShowDatesButton'); + await testSubjects.click('superDatePickerstartDatePopoverButton'); + await testSubjects.click('superDatePickerAbsoluteTab'); + const datePickerInput = await testSubjects.find('superDatePickerAbsoluteDateInput'); + await datePickerInput.clearValueWithKeyboard(); + await datePickerInput.type([date]); + }, + async setAnomaliesThreshold(threshold: string) { + const thresholdInput = await find.byCssSelector( + `.euiFieldNumber.euiRangeInput.euiRangeInput--max` + ); + await thresholdInput.clearValueWithKeyboard({ charByChar: true }); + await thresholdInput.type([threshold]); + }, }; } diff --git a/x-pack/test/functional/page_objects/security_page.ts b/x-pack/test/functional/page_objects/security_page.ts index 97a5c517db794..e6f372a79f0a3 100644 --- a/x-pack/test/functional/page_objects/security_page.ts +++ b/x-pack/test/functional/page_objects/security_page.ts @@ -108,7 +108,7 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider if (expectedResult === 'chrome') { await find.byCssSelector( - '[data-test-subj="kibanaChrome"] .app-wrapper:not(.hidden-chrome)', + '[data-test-subj="kibanaChrome"] .kbnAppWrapper:not(.kbnAppWrapper--hiddenChrome)', 20000 ); log.debug(`Finished login process currentUrl = ${await browser.getCurrentUrl()}`); diff --git a/x-pack/test/functional/services/ml/anomalies_table.ts b/x-pack/test/functional/services/ml/anomalies_table.ts index 30bb3e67bc862..52dfaa1a70855 100644 --- a/x-pack/test/functional/services/ml/anomalies_table.ts +++ b/x-pack/test/functional/services/ml/anomalies_table.ts @@ -22,6 +22,10 @@ export function MachineLearningAnomaliesTableProvider({ getService }: FtrProvide return await testSubjects.findAll('mlAnomaliesTable > ~mlAnomaliesListRow'); }, + /** + * Asserts the number of rows rendered in a table + * @param expectedCount + */ async assertTableRowsCount(expectedCount: number) { const actualCount = (await this.getTableRows()).length; expect(actualCount).to.eql( @@ -118,5 +122,32 @@ export function MachineLearningAnomaliesTableProvider({ getService }: FtrProvide }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); }, + + /** + * Asserts selected number of rows per page on the pagination control. + * @param rowsNumber + */ + async assertRowsNumberPerPage(rowsNumber: 10 | 25 | 100) { + const textContent = await testSubjects.getVisibleText( + 'mlAnomaliesTable > tablePaginationPopoverButton' + ); + expect(textContent).to.be(`Rows per page: ${rowsNumber}`); + }, + + async ensurePagePopupOpen() { + await retry.tryForTime(5000, async () => { + const isOpen = await testSubjects.exists('tablePagination-10-rows'); + if (!isOpen) { + await testSubjects.click('mlAnomaliesTable > tablePaginationPopoverButton'); + await testSubjects.existOrFail('tablePagination-10-rows'); + } + }); + }, + + async setRowsNumberPerPage(rowsNumber: 10 | 25 | 100) { + await this.ensurePagePopupOpen(); + await testSubjects.click(`tablePagination-${rowsNumber}-rows`); + await this.assertRowsNumberPerPage(rowsNumber); + }, }; } diff --git a/x-pack/test/functional/services/ml/data_visualizer_file_based.ts b/x-pack/test/functional/services/ml/data_visualizer_file_based.ts index d5b9664fc6d9c..e32c04f82804c 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_file_based.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_file_based.ts @@ -125,5 +125,11 @@ export function MachineLearningDataVisualizerFileBasedProvider( await testSubjects.existOrFail('mlFileImportSuccessCallout'); }); }, + + async selectCreateFilebeatConfig() { + await testSubjects.scrollIntoView('fileDataVisFilebeatConfigLink'); + await testSubjects.click('fileDataVisFilebeatConfigLink'); + await testSubjects.existOrFail('fileDataVisFilebeatConfigPanel'); + }, }; } diff --git a/x-pack/test/functional/services/transform/discover.ts b/x-pack/test/functional/services/transform/discover.ts new file mode 100644 index 0000000000000..a98f7e5ae9890 --- /dev/null +++ b/x-pack/test/functional/services/transform/discover.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export function TransformDiscoverProvider({ getService }: FtrProviderContext) { + const find = getService('find'); + const testSubjects = getService('testSubjects'); + + return { + async assertDiscoverQueryHits(expectedDiscoverQueryHits: string) { + await testSubjects.existOrFail('discoverQueryHits'); + + const actualDiscoverQueryHits = await testSubjects.getVisibleText('discoverQueryHits'); + + expect(actualDiscoverQueryHits).to.eql( + expectedDiscoverQueryHits, + `Discover query hits should be ${expectedDiscoverQueryHits}, got ${actualDiscoverQueryHits}` + ); + }, + + async assertNoResults(expectedDestinationIndex: string) { + // Discover should use the destination index pattern + const actualIndexPatternSwitchLinkText = await ( + await testSubjects.find('indexPattern-switch-link') + ).getVisibleText(); + expect(actualIndexPatternSwitchLinkText).to.eql( + expectedDestinationIndex, + `Destination index should be ${expectedDestinationIndex}, got ${actualIndexPatternSwitchLinkText}` + ); + + await testSubjects.existOrFail('discoverNoResults'); + }, + + async assertSuperDatePickerToggleQuickMenuButtonExists() { + await testSubjects.existOrFail('superDatePickerToggleQuickMenuButton'); + }, + + async openSuperDatePicker() { + await testSubjects.click('superDatePickerToggleQuickMenuButton'); + await testSubjects.existOrFail('superDatePickerQuickMenu'); + }, + + async quickSelectYears() { + const quickMenuElement = await testSubjects.find('superDatePickerQuickMenu'); + + // No test subject, select "Years" to look back 15 years instead of 15 minutes. + await find.selectValue(`[aria-label*="Time unit"]`, 'y'); + + // Apply + const applyButton = await quickMenuElement.findByClassName('euiQuickSelect__applyButton'); + const actualApplyButtonText = await applyButton.getVisibleText(); + expect(actualApplyButtonText).to.be('Apply'); + + await applyButton.click(); + await testSubjects.existOrFail('discoverQueryHits'); + }, + }; +} diff --git a/x-pack/test/functional/services/transform/index.ts b/x-pack/test/functional/services/transform/index.ts index 36265fb9369d3..c9179cc307aaf 100644 --- a/x-pack/test/functional/services/transform/index.ts +++ b/x-pack/test/functional/services/transform/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; import { TransformAPIProvider } from './api'; import { TransformEditFlyoutProvider } from './edit_flyout'; +import { TransformDiscoverProvider } from './discover'; import { TransformManagementProvider } from './management'; import { TransformNavigationProvider } from './navigation'; import { TransformSecurityCommonProvider } from './security_common'; @@ -22,6 +23,7 @@ import { MachineLearningTestResourcesProvider } from '../ml/test_resources'; export function TransformProvider(context: FtrProviderContext) { const api = TransformAPIProvider(context); + const discover = TransformDiscoverProvider(context); const editFlyout = TransformEditFlyoutProvider(context); const management = TransformManagementProvider(context); const navigation = TransformNavigationProvider(context); @@ -35,6 +37,7 @@ export function TransformProvider(context: FtrProviderContext) { return { api, + discover, editFlyout, management, navigation, diff --git a/x-pack/test/functional/services/transform/transform_table.ts b/x-pack/test/functional/services/transform/transform_table.ts index 17d4e56e0cdf9..cafaa2606f255 100644 --- a/x-pack/test/functional/services/transform/transform_table.ts +++ b/x-pack/test/functional/services/transform/transform_table.ts @@ -9,6 +9,8 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +type TransformRowActionName = 'Clone' | 'Delete' | 'Edit' | 'Start' | 'Stop' | 'Discover'; + export function TransformTableProvider({ getService }: FtrProviderContext) { const retry = getService('retry'); const testSubjects = getService('testSubjects'); @@ -238,6 +240,7 @@ export function TransformTableProvider({ getService }: FtrProviderContext) { await testSubjects.existOrFail('transformActionClone'); await testSubjects.existOrFail('transformActionDelete'); + await testSubjects.existOrFail('transformActionDiscover'); await testSubjects.existOrFail('transformActionEdit'); if (isTransformRunning) { @@ -251,7 +254,7 @@ export function TransformTableProvider({ getService }: FtrProviderContext) { public async assertTransformRowActionEnabled( transformId: string, - action: 'Delete' | 'Start' | 'Stop' | 'Clone' | 'Edit', + action: TransformRowActionName, expectedValue: boolean ) { const selector = `transformAction${action}`; @@ -274,7 +277,7 @@ export function TransformTableProvider({ getService }: FtrProviderContext) { public async clickTransformRowActionWithRetry( transformId: string, - action: 'Delete' | 'Start' | 'Stop' | 'Clone' | 'Edit' + action: TransformRowActionName ) { await retry.tryForTime(30 * 1000, async () => { await browser.pressKeys(browser.keys.ESCAPE); @@ -285,7 +288,7 @@ export function TransformTableProvider({ getService }: FtrProviderContext) { }); } - public async clickTransformRowAction(action: string) { + public async clickTransformRowAction(action: TransformRowActionName) { await testSubjects.click(`transformAction${action}`); } diff --git a/x-pack/test/security_functional/tests/oidc/url_capture.ts b/x-pack/test/security_functional/tests/oidc/url_capture.ts index a3b87def6176f..968e1001a5f56 100644 --- a/x-pack/test/security_functional/tests/oidc/url_capture.ts +++ b/x-pack/test/security_functional/tests/oidc/url_capture.ts @@ -43,7 +43,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await find.byCssSelector( - '[data-test-subj="kibanaChrome"] .app-wrapper:not(.hidden-chrome)', + '[data-test-subj="kibanaChrome"] .kbnAppWrapper:not(.kbnAppWrapper--hiddenChrome)', 20000 ); diff --git a/x-pack/test/security_functional/tests/saml/url_capture.ts b/x-pack/test/security_functional/tests/saml/url_capture.ts index 68ec96fbf4fa9..80f6528334cc0 100644 --- a/x-pack/test/security_functional/tests/saml/url_capture.ts +++ b/x-pack/test/security_functional/tests/saml/url_capture.ts @@ -43,7 +43,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await find.byCssSelector( - '[data-test-subj="kibanaChrome"] .app-wrapper:not(.hidden-chrome)', + '[data-test-subj="kibanaChrome"] .kbnAppWrapper:not(.kbnAppWrapper--hiddenChrome)', 20000 ); diff --git a/yarn.lock b/yarn.lock index 1c33d64afbec0..133f21a200f70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1359,10 +1359,10 @@ dependencies: object-hash "^1.3.0" -"@elastic/charts@29.0.0": - version "29.0.0" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-29.0.0.tgz#6f4ea5bba2caab9700e900fc0bb72685306d1184" - integrity sha512-df8fYiwOWzO7boIBXMsiWY9oHw5//WZJ2MogJ/38pZeDMRHwjIvQCzj1NL641ijFlFBfWwPSmPur9vbF5xTjbg== +"@elastic/charts@29.1.0": + version "29.1.0" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-29.1.0.tgz#2850aa30d5e00aa8a1ab4974ea36f3c960a8e457" + integrity sha512-/nHT8niLtvSwX3dyEeIQWXEEZrB3xgjLIdlnqZhQXEdHqDQnxlehOMsTqWWws7jS/5uRq/sg+8N2z1xEb+odDw== dependencies: "@popperjs/core" "^2.4.0" chroma-js "^2.1.0" @@ -2633,7 +2633,7 @@ version "0.0.0" uid "" -"@kbn/expect@link:packages/kbn-expect": +"@kbn/expect@link:bazel-bin/packages/kbn-expect/npm_module": version "0.0.0" uid ""