diff --git a/.bazelrc b/.bazelrc index 741067e4ff18e5..158338ec5f093c 100644 --- a/.bazelrc +++ b/.bazelrc @@ -2,8 +2,17 @@ # Import shared settings first so we can override below import %workspace%/.bazelrc.common +## Disabled for now # Remote cache settings for local env # build --remote_cache=https://storage.googleapis.com/kibana-bazel-cache # build --incompatible_remote_results_ignore_disk=true # build --remote_accept_cached=true # build --remote_upload_local_results=false + +# BuildBuddy +## Metadata settings +build --workspace_status_command=$(pwd)/src/dev/bazel_workspace_status.sh +# Enable this in case you want to share your build info +# build --build_metadata=VISIBILITY=PUBLIC +build --build_metadata=TEST_GROUPS=//packages + diff --git a/.eslintignore b/.eslintignore index 2d169c45214fee..5513ad1320232c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -16,6 +16,7 @@ target snapshots.js !/.eslintrc.js +!.storybook # plugin overrides /src/core/lib/kbn_internal_native_observable diff --git a/.eslintrc.js b/.eslintrc.js index dadebc922df9e8..9430b9bf24466e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1112,12 +1112,22 @@ module.exports = { /** * Enterprise Search overrides + * NOTE: We also have a single rule at the bottom of the file that + * overrides Prettier's default of not linting unnecessary backticks */ { + // All files files: ['x-pack/plugins/enterprise_search/**/*.{ts,tsx}'], - excludedFiles: ['x-pack/plugins/enterprise_search/**/*.{test,mock}.{ts,tsx}'], rules: { 'react-hooks/exhaustive-deps': 'off', + 'react/jsx-boolean-value': ['error', 'never'], + }, + }, + { + // Source files only - allow `any` in test/mock files + files: ['x-pack/plugins/enterprise_search/**/*.{ts,tsx}'], + excludedFiles: ['x-pack/plugins/enterprise_search/**/*.{test,mock}.{ts,tsx}'], + rules: { '@typescript-eslint/no-explicit-any': 'error', }, }, @@ -1260,6 +1270,16 @@ module.exports = { ...require('eslint-config-prettier/@typescript-eslint').rules, }, }, + /** + * Enterprise Search Prettier override + * Lints unnecessary backticks - @see https://github.com/prettier/eslint-config-prettier/blob/main/README.md#forbid-unnecessary-backticks + */ + { + files: ['x-pack/plugins/enterprise_search/**/*.{ts,tsx}'], + rules: { + quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }], + }, + }, { files: [ diff --git a/docs/developer/architecture/index.asciidoc b/docs/developer/architecture/index.asciidoc index 7fa7d80ef97293..4bdd693979b491 100644 --- a/docs/developer/architecture/index.asciidoc +++ b/docs/developer/architecture/index.asciidoc @@ -13,8 +13,8 @@ To begin plugin development, we recommend reading our overview of how plugins wo * <> Our developer services are changing all the time. One of the best ways to discover and learn about them is to read the available -READMEs from all the plugins inside our {kib-repo}tree/{branch}/src/plugins[open source plugins folder] and our -{kib-repo}/tree/{branch}/x-pack/plugins[commercial plugins folder]. +READMEs inside our plugins folders: {kib-repo}tree/{branch}/src/plugins[src/plugins] and +{kib-repo}/tree/{branch}/x-pack/plugins[x-pack/plugins]. A few services also automatically generate api documentation which can be browsed inside the {kib-repo}tree/{branch}/docs/development[docs/development section of our repo] diff --git a/docs/developer/getting-started/running-kibana-advanced.asciidoc b/docs/developer/getting-started/running-kibana-advanced.asciidoc index 277e52a3dc8e91..68a4951ea1c21b 100644 --- a/docs/developer/getting-started/running-kibana-advanced.asciidoc +++ b/docs/developer/getting-started/running-kibana-advanced.asciidoc @@ -23,24 +23,6 @@ By default, you can log in with username `elastic` and password `changeme`. See the `--help` options on `yarn es ` if you’d like to configure a different password. -[discrete] -=== Running {kib} in Open-Source mode - -If you’re looking to only work with the open-source software, supply the -license type to `yarn es`: - -[source,bash] ----- -yarn es snapshot --license oss ----- - -And start {kib} with only open-source code: - -[source,bash] ----- -yarn start --oss ----- - [discrete] === Unsupported URL Type diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index 51e8d1a0b6befc..fd46a8a0f82c12 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -21,6 +21,7 @@ readonly links: { readonly installation: string; readonly configuration: string; readonly elasticsearchOutput: string; + readonly elasticsearchModule: string; readonly startup: string; readonly exportedFields: string; }; @@ -29,6 +30,10 @@ readonly links: { }; readonly metricbeat: { readonly base: string; + readonly configure: string; + readonly httpEndpoint: string; + readonly install: string; + readonly start: string; }; readonly enterpriseSearch: { readonly base: string; diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md new file mode 100644 index 00000000000000..2b897db7bba4c3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [createIndexAliasNotFoundError](./kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md) + +## SavedObjectsErrorHelpers.createIndexAliasNotFoundError() method + +Signature: + +```typescript +static createIndexAliasNotFoundError(alias: string): DecoratedError; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| alias | string | | + +Returns: + +`DecoratedError` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.decorateindexaliasnotfounderror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.decorateindexaliasnotfounderror.md new file mode 100644 index 00000000000000..c7e10fc42ead19 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.decorateindexaliasnotfounderror.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [decorateIndexAliasNotFoundError](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateindexaliasnotfounderror.md) + +## SavedObjectsErrorHelpers.decorateIndexAliasNotFoundError() method + +Signature: + +```typescript +static decorateIndexAliasNotFoundError(error: Error, alias: string): DecoratedError; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| error | Error | | +| alias | string | | + +Returns: + +`DecoratedError` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.isgeneralerror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.isgeneralerror.md new file mode 100644 index 00000000000000..4b4ede2f77a7e2 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.isgeneralerror.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [isGeneralError](./kibana-plugin-core-server.savedobjectserrorhelpers.isgeneralerror.md) + +## SavedObjectsErrorHelpers.isGeneralError() method + +Signature: + +```typescript +static isGeneralError(error: Error | DecoratedError): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| error | Error | DecoratedError | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md index 9b69012ed5f123..2dc78f2df3a833 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md @@ -18,6 +18,7 @@ export declare class SavedObjectsErrorHelpers | [createBadRequestError(reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.createbadrequesterror.md) | static | | | [createConflictError(type, id, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md) | static | | | [createGenericNotFoundError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.creategenericnotfounderror.md) | static | | +| [createIndexAliasNotFoundError(alias)](./kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md) | static | | | [createInvalidVersionError(versionInput)](./kibana-plugin-core-server.savedobjectserrorhelpers.createinvalidversionerror.md) | static | | | [createTooManyRequestsError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.createtoomanyrequestserror.md) | static | | | [createUnsupportedTypeError(type)](./kibana-plugin-core-server.savedobjectserrorhelpers.createunsupportedtypeerror.md) | static | | @@ -27,6 +28,7 @@ export declare class SavedObjectsErrorHelpers | [decorateEsUnavailableError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateesunavailableerror.md) | static | | | [decorateForbiddenError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateforbiddenerror.md) | static | | | [decorateGeneralError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorategeneralerror.md) | static | | +| [decorateIndexAliasNotFoundError(error, alias)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateindexaliasnotfounderror.md) | static | | | [decorateNotAuthorizedError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decoratenotauthorizederror.md) | static | | | [decorateRequestEntityTooLargeError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decoraterequestentitytoolargeerror.md) | static | | | [decorateTooManyRequestsError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decoratetoomanyrequestserror.md) | static | | @@ -35,6 +37,7 @@ export declare class SavedObjectsErrorHelpers | [isEsCannotExecuteScriptError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isescannotexecutescripterror.md) | static | | | [isEsUnavailableError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isesunavailableerror.md) | static | | | [isForbiddenError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isforbiddenerror.md) | static | | +| [isGeneralError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isgeneralerror.md) | static | | | [isInvalidVersionError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isinvalidversionerror.md) | static | | | [isNotAuthorizedError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isnotauthorizederror.md) | static | | | [isNotFoundError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isnotfounderror.md) | static | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsfetcher.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsfetcher.md index 3ba3c862bf16a9..608d738676bcfd 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsfetcher.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsfetcher.md @@ -22,4 +22,5 @@ export declare class IndexPatternsFetcher | --- | --- | --- | | [getFieldsForTimePattern(options)](./kibana-plugin-plugins-data-server.indexpatternsfetcher.getfieldsfortimepattern.md) | | Get a list of field objects for a time pattern | | [getFieldsForWildcard(options)](./kibana-plugin-plugins-data-server.indexpatternsfetcher.getfieldsforwildcard.md) | | Get a list of field objects for an index pattern that may contain wildcards | +| [validatePatternListActive(patternList)](./kibana-plugin-plugins-data-server.indexpatternsfetcher.validatepatternlistactive.md) | | Returns an index pattern list of only those index pattern strings in the given list that return indices | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsfetcher.validatepatternlistactive.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsfetcher.validatepatternlistactive.md new file mode 100644 index 00000000000000..8944c412043237 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsfetcher.validatepatternlistactive.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IndexPatternsFetcher](./kibana-plugin-plugins-data-server.indexpatternsfetcher.md) > [validatePatternListActive](./kibana-plugin-plugins-data-server.indexpatternsfetcher.validatepatternlistactive.md) + +## IndexPatternsFetcher.validatePatternListActive() method + +Returns an index pattern list of only those index pattern strings in the given list that return indices + +Signature: + +```typescript +validatePatternListActive(patternList: string[]): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| patternList | string[] | | + +Returns: + +`Promise` + diff --git a/docs/maps/connect-to-ems.asciidoc b/docs/maps/connect-to-ems.asciidoc index a5b8010f21f97f..8e4695bfc66624 100644 --- a/docs/maps/connect-to-ems.asciidoc +++ b/docs/maps/connect-to-ems.asciidoc @@ -86,7 +86,7 @@ endif::[] [cols="2*<"] |=== -| [[ems-hostname]]`hostname` +| [[ems-host]]`host` | Specifies the host of the backend server. To allow remote users to connect, set the value to the IP address or DNS name of the {hosted-ems} container. *Default: _your-hostname_*. <>. | `port` @@ -199,7 +199,7 @@ TIP: The available basemaps and boundaries can be explored from the `/maps` endp [[elastic-maps-server-kibana]] ==== Kibana configuration -With {hosted-ems} running, add the `map.emsUrl` configuration key in your <> file pointing to the root of the service. This setting will point {kib} to request EMS basemaps and boundaries from {hosted-ems}. Typically this will be the URL to the <> of {hosted-ems}. For example, `map.emsUrl: https://my-ems-server:8080`. +With {hosted-ems} running, add the `map.emsUrl` configuration key in your <> file pointing to the root of the service. This setting will point {kib} to request EMS basemaps and boundaries from {hosted-ems}. Typically this will be the URL to the <> of {hosted-ems}. For example, `map.emsUrl: https://my-ems-server:8080`. [float] diff --git a/docs/maps/maps-aggregations.asciidoc b/docs/maps/maps-aggregations.asciidoc index 3c66e187bf59cd..265bf6bfaea304 100644 --- a/docs/maps/maps-aggregations.asciidoc +++ b/docs/maps/maps-aggregations.asciidoc @@ -68,9 +68,9 @@ To enable a blended layer that dynamically shows clusters or documents: [role="xpack"] [[maps-top-hits-aggregation]] -=== Top hits per entity +=== Display the most relevant documents per entity -You can display the most relevant documents per entity, for example, the most recent GPS tracks per flight. +Use *Top hits per entity* to display the most relevant documents per entity, for example, the most recent GPS tracks per flight route. To get this data, {es} first groups your data using a {ref}/search-aggregations-bucket-terms-aggregation.html[terms aggregation], then accumulates the most relevant documents based on sort order for each entry using a {ref}/search-aggregations-metrics-top-hits-aggregation.html[top hits metric aggregation]. diff --git a/docs/setup/docker.asciidoc b/docs/setup/docker.asciidoc index 2e70df09b5c37b..75a9799d70fbdb 100644 --- a/docs/setup/docker.asciidoc +++ b/docs/setup/docker.asciidoc @@ -11,12 +11,8 @@ A list of all published Docker images and tags is available at https://www.docker.elastic.co[www.docker.elastic.co]. The source code is in https://github.com/elastic/dockerfiles/tree/{branch}/kibana[GitHub]. -These images are free to use under the Elastic license. They contain open source -and free commercial features and access to paid commercial features. -<> to try out all of the -paid commercial features. See the -https://www.elastic.co/subscriptions[Subscriptions] page for information about -Elastic license levels. +These images contain both free and subscription features. +<> to try out all of the features. [float] [[pull-image]] diff --git a/docs/setup/install/deb.asciidoc b/docs/setup/install/deb.asciidoc index 8edd2f93121681..6012ae394c832a 100644 --- a/docs/setup/install/deb.asciidoc +++ b/docs/setup/install/deb.asciidoc @@ -8,12 +8,8 @@ The Debian package for Kibana can be <> or from our <>. It can be used to install Kibana on any Debian-based system such as Debian and Ubuntu. -This package is free to use under the Elastic license. It contains open source -and free commercial features and access to paid commercial features. -<> to try out all of the -paid commercial features. See the -https://www.elastic.co/subscriptions[Subscriptions] page for information about -Elastic license levels. +This package contains both free and subscription features. +<> to try out all of the features. The latest stable version of Kibana can be found on the link:/downloads/kibana[Download Kibana] page. Other versions can diff --git a/docs/setup/install/rpm.asciidoc b/docs/setup/install/rpm.asciidoc index 01a9c5718f14bf..216ec849147b41 100644 --- a/docs/setup/install/rpm.asciidoc +++ b/docs/setup/install/rpm.asciidoc @@ -13,12 +13,8 @@ and Oracle Enterprise. NOTE: RPM install is not supported on distributions with old versions of RPM, such as SLES 11 and CentOS 5. Please see <> instead. -This package is free to use under the Elastic license. It contains open source -and free commercial features and access to paid commercial features. -<> to try out all of the -paid commercial features. See the -https://www.elastic.co/subscriptions[Subscriptions] page for information about -Elastic license levels. +This package contains both free and subscription features. +<> to try out all of the features. The latest stable version of Kibana can be found on the link:/downloads/kibana[Download Kibana] page. Other versions can diff --git a/docs/setup/install/targz.asciidoc b/docs/setup/install/targz.asciidoc index 8eef43f7961670..bb51d98a4f922b 100644 --- a/docs/setup/install/targz.asciidoc +++ b/docs/setup/install/targz.asciidoc @@ -7,12 +7,8 @@ Kibana is provided for Linux and Darwin as a `.tar.gz` package. These packages are the easiest formats to use when trying out Kibana. -These packages are free to use under the Elastic license. They contain open -source and free commercial features and access to paid commercial features. -<> to try out all of the -paid commercial features. See the -https://www.elastic.co/subscriptions[Subscriptions] page for information about -Elastic license levels. +This package contains both free and subscription features. +<> to try out all of the features. The latest stable version of Kibana can be found on the link:/downloads/kibana[Download Kibana] page. diff --git a/docs/setup/install/windows.asciidoc b/docs/setup/install/windows.asciidoc index 4a5a855e0bbcf2..b4204cc623f0f3 100644 --- a/docs/setup/install/windows.asciidoc +++ b/docs/setup/install/windows.asciidoc @@ -6,12 +6,8 @@ Kibana can be installed on Windows using the `.zip` package. -This package is free to use under the Elastic license. It contains open source -and free commercial features and access to paid commercial features. -<> to try out all of the -paid commercial features. See the -https://www.elastic.co/subscriptions[Subscriptions] page for information about -Elastic license levels. +This package contains both free and subscription features. +<> to try out all of the features. The latest stable version of Kibana can be found on the link:/downloads/kibana[Download Kibana] page. diff --git a/docs/user/alerting/action-types/email.asciidoc b/docs/user/alerting/action-types/email.asciidoc index 83e7edc5a016a6..d7a9373a6e2a99 100644 --- a/docs/user/alerting/action-types/email.asciidoc +++ b/docs/user/alerting/action-types/email.asciidoc @@ -14,7 +14,7 @@ Name:: The name of the connector. The name is used to identify a connector Sender:: The from address for all emails sent with this connector, specified in `user@host-name` format. Host:: Host name of the service provider. If you are using the <> setting, make sure this hostname is added to the allowed hosts. Port:: The port to connect to on the service provider. -Secure:: If true the connection will use TLS when connecting to the service provider. See https://nodemailer.com/smtp/#tls-options[nodemailer TLS documentation] for more information. +Secure:: If true, the connection will use TLS when connecting to the service provider. Refer to the https://nodemailer.com/smtp/#tls-options[Nodemailer TLS documentation] for more information. If not true, the connection will initially connect over TCP, then attempt to switch to TLS via the SMTP STARTTLS command. Username:: username for 'login' type authentication. Password:: password for 'login' type authentication. @@ -92,6 +92,8 @@ systems, refer to: * <> * <> +For other email servers, you can check the list of well-known services that Nodemailer supports in the JSON file https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json[well-known/services.json]. The properties of the objects in those files — `host`, `port`, and `secure` — correspond to the same email action configuration properties. A missing `secure` property in the "well-known/services.json" file is considered `false`. Typically, `port: 465` uses `secure: true`, and `port: 25` and `port: 587` use `secure: false`. + [float] [[gmail]] ===== Sending email from Gmail @@ -109,7 +111,6 @@ https://mail.google.com[Gmail] SMTP service: user: password: -------------------------------------------------- -// CONSOLE If you get an authentication error that indicates that you need to continue the sign-in process from a web browser when the action attempts to send email, you need @@ -131,9 +132,9 @@ https://www.outlook.com/[Outlook.com] SMTP service: [source,text] -------------------------------------------------- config: - host: smtp-mail.outlook.com - port: 465 - secure: true + host: smtp.office365.com + port: 587 + secure: false secrets: user: password: @@ -163,7 +164,7 @@ secrets: user: password: -------------------------------------------------- -<1> `smtp.host` varies depending on the region +<1> `config.host` varies depending on the region NOTE: You must use your Amazon SES SMTP credentials to send email through Amazon SES. For more information, see diff --git a/docs/user/alerting/geo-alert-types.asciidoc b/docs/user/alerting/geo-alert-types.asciidoc index c04cf4bca4320d..f79885e3bc7163 100644 --- a/docs/user/alerting/geo-alert-types.asciidoc +++ b/docs/user/alerting/geo-alert-types.asciidoc @@ -2,13 +2,8 @@ [[geo-alert-types]] == Geo alert types -experimental[] Two additional stack alerts are available: -<> and <>. To enable, -add the following configuration to your `kibana.yml`: - -```yml -xpack.stack_alerts.enableGeoAlerting: true -``` +Two additional stack alerts are available: +<> and <>. As with other stack alerts, you need `all` access to the *Stack Alerts* feature to be able to create and edit either of the geo alerts. diff --git a/docs/user/reporting/reporting-troubleshooting.asciidoc b/docs/user/reporting/reporting-troubleshooting.asciidoc index 1f07b0b57d8c7a..ebe095e0881b38 100644 --- a/docs/user/reporting/reporting-troubleshooting.asciidoc +++ b/docs/user/reporting/reporting-troubleshooting.asciidoc @@ -15,6 +15,7 @@ Having trouble? Here are solutions to common problems you might encounter while * <> * <> * <> +* <> [float] [[reporting-diagnostics]] @@ -156,3 +157,9 @@ requests to render. If the {kib} instance doesn't have enough memory to run the report, the report fails with an error such as `Error: Page crashed!` In this case, try increasing the memory for the {kib} instance to 2GB. + +[float] +[[reporting-troubleshooting-arm-systems]] +=== ARM systems + +Chromium is not compatible with ARM RHEL/CentOS. diff --git a/package.json b/package.json index 27cbbf3fb1299a..a5c6fa6f7b3c2e 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "@elastic/datemath": "link:packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary", "@elastic/ems-client": "7.11.0", - "@elastic/eui": "31.3.0", + "@elastic/eui": "31.4.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", "@elastic/node-crypto": "1.2.1", @@ -220,7 +220,7 @@ "http-proxy-agent": "^2.1.0", "https-proxy-agent": "^5.0.0", "idx": "^2.5.6", - "immer": "^1.5.0", + "immer": "^8.0.1", "inline-style": "^2.0.0", "intl": "^1.2.5", "intl-format-cache": "^2.1.0", @@ -636,7 +636,7 @@ "eslint-plugin-ban": "^1.4.0", "eslint-plugin-cypress": "^2.11.2", "eslint-plugin-eslint-comments": "^3.2.0", - "eslint-plugin-import": "^2.19.1", + "eslint-plugin-import": "^2.22.1", "eslint-plugin-jest": "^24.0.2", "eslint-plugin-jsx-a11y": "^6.2.3", "eslint-plugin-mocha": "^6.2.2", diff --git a/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts b/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts index d3494512d055a4..f86865ffa66704 100644 --- a/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts +++ b/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts @@ -25,5 +25,6 @@ export async function emptyKibanaIndexAction({ await cleanKibanaIndices({ client, stats, log, kibanaPluginIds }); await migrateKibanaIndex({ client, kbnClient }); - return stats; + stats.createdIndex('.kibana'); + return stats.toJSON(); } diff --git a/packages/kbn-es-archiver/src/es_archiver.ts b/packages/kbn-es-archiver/src/es_archiver.ts index 70dc5370c5a269..b00b9fb8b3f25f 100644 --- a/packages/kbn-es-archiver/src/es_archiver.ts +++ b/packages/kbn-es-archiver/src/es_archiver.ts @@ -155,7 +155,7 @@ export class EsArchiver { * @return Promise */ async emptyKibanaIndex() { - await emptyKibanaIndexAction({ + return await emptyKibanaIndexAction({ client: this.client, log: this.log, kbnClient: this.kbnClient, diff --git a/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts b/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts index 6d48c0b2bbaead..64e5626c94c8bb 100644 --- a/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts +++ b/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts @@ -82,7 +82,9 @@ export async function migrateKibanaIndex({ */ async function fetchKibanaIndices(client: Client) { const resp = await client.cat.indices({ index: '.kibana*', format: 'json' }); - const isKibanaIndex = (index: string) => /^\.kibana(:?_\d*)?$/.test(index); + const isKibanaIndex = (index: string) => + /^\.kibana(:?_\d*)?$/.test(index) || + /^\.kibana(_task_manager)?_(pre)?\d+\.\d+\.\d+/.test(index); if (!Array.isArray(resp.body)) { throw new Error(`expected response to be an array ${inspect(resp.body)}`); @@ -115,7 +117,7 @@ export async function cleanKibanaIndices({ while (true) { const resp = await client.deleteByQuery( { - index: `.kibana`, + index: `.kibana,.kibana_task_manager`, body: { query: { bool: { @@ -129,7 +131,7 @@ export async function cleanKibanaIndices({ }, }, { - ignore: [409], + ignore: [404, 409], } ); diff --git a/packages/kbn-legacy-logging/src/utils/get_payload_size.test.ts b/packages/kbn-legacy-logging/src/utils/get_payload_size.test.ts index c70f95b9ddc110..3bb97e57ca0a3c 100644 --- a/packages/kbn-legacy-logging/src/utils/get_payload_size.test.ts +++ b/packages/kbn-legacy-logging/src/utils/get_payload_size.test.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { createGunzip } from 'zlib'; import mockFs from 'mock-fs'; import { createReadStream } from 'fs'; @@ -54,6 +55,11 @@ describe('getPayloadSize', () => { const result = getResponsePayloadBytes(readStream); expect(result).toBe(Buffer.byteLength(data)); }); + + test('ignores streams that are not instances of ReadStream', async () => { + const result = getResponsePayloadBytes(createGunzip()); + expect(result).toBe(undefined); + }); }); describe('handles plain responses', () => { @@ -72,6 +78,11 @@ describe('getPayloadSize', () => { const result = getResponsePayloadBytes(payload); expect(result).toBe(JSON.stringify(payload).length); }); + + test('returns undefined when source is not plain object', () => { + const result = getResponsePayloadBytes([1, 2, 3]); + expect(result).toBe(undefined); + }); }); describe('handles content-length header', () => { diff --git a/packages/kbn-legacy-logging/src/utils/get_payload_size.ts b/packages/kbn-legacy-logging/src/utils/get_payload_size.ts index de96ad70027315..c7aeb0e8cac2bb 100644 --- a/packages/kbn-legacy-logging/src/utils/get_payload_size.ts +++ b/packages/kbn-legacy-logging/src/utils/get_payload_size.ts @@ -6,14 +6,13 @@ * Side Public License, v 1. */ -import type { ReadStream } from 'fs'; +import { isPlainObject } from 'lodash'; +import { ReadStream } from 'fs'; import type { ResponseObject } from '@hapi/hapi'; const isBuffer = (obj: unknown): obj is Buffer => Buffer.isBuffer(obj); -const isObject = (obj: unknown): obj is Record => - typeof obj === 'object' && obj !== null; const isFsReadStream = (obj: unknown): obj is ReadStream => - typeof obj === 'object' && obj !== null && 'bytesRead' in obj; + typeof obj === 'object' && obj !== null && 'bytesRead' in obj && obj instanceof ReadStream; const isString = (obj: unknown): obj is string => typeof obj === 'string'; /** @@ -56,7 +55,7 @@ export function getResponsePayloadBytes( return Buffer.byteLength(payload); } - if (isObject(payload)) { + if (isPlainObject(payload)) { return Buffer.byteLength(JSON.stringify(payload)); } diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 4d065411f91b61..d939e7b3000fa3 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -48106,23 +48106,59 @@ async function isBazelBinAvailable() { } } +async function isBazeliskInstalled(bazeliskVersion) { + try { + const { + stdout: bazeliskPkgInstallStdout + } = await Object(_child_process__WEBPACK_IMPORTED_MODULE_2__["spawn"])('npm', ['ls', '--global', '--parseable', '--long', `@bazel/bazelisk@${bazeliskVersion}`], { + stdio: 'pipe' + }); + return bazeliskPkgInstallStdout.includes(`@bazel/bazelisk@${bazeliskVersion}`); + } catch { + return false; + } +} + +async function tryRemoveBazeliskFromYarnGlobal() { + try { + // Check if Bazelisk is installed on the yarn global scope + const { + stdout: bazeliskPkgInstallStdout + } = await Object(_child_process__WEBPACK_IMPORTED_MODULE_2__["spawn"])('yarn', ['global', 'list'], { + stdio: 'pipe' + }); // Bazelisk was found on yarn global scope so lets remove it + + if (bazeliskPkgInstallStdout.includes(`@bazel/bazelisk@`)) { + await Object(_child_process__WEBPACK_IMPORTED_MODULE_2__["spawn"])('yarn', ['global', 'remove', `@bazel/bazelisk`], { + stdio: 'pipe' + }); + _log__WEBPACK_IMPORTED_MODULE_4__["log"].info(`[bazel_tools] bazelisk was installed on Yarn global packages and is now removed`); + return true; + } + + return false; + } catch { + return false; + } +} + async function installBazelTools(repoRootPath) { _log__WEBPACK_IMPORTED_MODULE_4__["log"].debug(`[bazel_tools] reading bazel tools versions from version files`); const bazeliskVersion = await readBazelToolsVersionFile(repoRootPath, '.bazeliskversion'); const bazelVersion = await readBazelToolsVersionFile(repoRootPath, '.bazelversion'); // Check what globals are installed - _log__WEBPACK_IMPORTED_MODULE_4__["log"].debug(`[bazel_tools] verify if bazelisk is installed`); - const { - stdout: bazeliskPkgInstallStdout - } = await Object(_child_process__WEBPACK_IMPORTED_MODULE_2__["spawn"])('yarn', ['global', 'list'], { - stdio: 'pipe' - }); - const isBazelBinAlreadyAvailable = await isBazelBinAvailable(); // Install bazelisk if not installed + _log__WEBPACK_IMPORTED_MODULE_4__["log"].debug(`[bazel_tools] verify if bazelisk is installed`); // Test if bazelisk is already installed in the correct version - if (!bazeliskPkgInstallStdout.includes(`@bazel/bazelisk@${bazeliskVersion}`) || !isBazelBinAlreadyAvailable) { + const isBazeliskPkgInstalled = await isBazeliskInstalled(bazeliskVersion); // Test if bazel bin is available + + const isBazelBinAlreadyAvailable = await isBazelBinAvailable(); // Check if we need to remove bazelisk from yarn + + await tryRemoveBazeliskFromYarnGlobal(); // Install bazelisk if not installed + + if (!isBazeliskPkgInstalled || !isBazelBinAlreadyAvailable) { _log__WEBPACK_IMPORTED_MODULE_4__["log"].info(`[bazel_tools] installing Bazel tools`); _log__WEBPACK_IMPORTED_MODULE_4__["log"].debug(`[bazel_tools] bazelisk is not installed. Installing @bazel/bazelisk@${bazeliskVersion} and bazel@${bazelVersion}`); - await Object(_child_process__WEBPACK_IMPORTED_MODULE_2__["spawn"])('yarn', ['global', 'add', `@bazel/bazelisk@${bazeliskVersion}`], { + await Object(_child_process__WEBPACK_IMPORTED_MODULE_2__["spawn"])('npm', ['install', '--global', `@bazel/bazelisk@${bazeliskVersion}`], { env: { USE_BAZEL_VERSION: bazelVersion }, @@ -48132,7 +48168,7 @@ async function installBazelTools(repoRootPath) { if (!isBazelBinAvailableAfterInstall) { throw new Error(dedent__WEBPACK_IMPORTED_MODULE_0___default.a` - [bazel_tools] an error occurred when installing the Bazel tools. Please make sure 'yarn global bin' is on your $PATH, otherwise just add it there + [bazel_tools] an error occurred when installing the Bazel tools. Please make sure you have access to npm globally installed modules on your $PATH `); } } @@ -59771,10 +59807,11 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var _build_non_bazel_production_projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(745); -/* harmony import */ var _utils_fs__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(131); -/* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(246); -/* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(251); -/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(248); +/* harmony import */ var _utils_bazel_run__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(374); +/* harmony import */ var _utils_fs__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(131); +/* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(246); +/* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(251); +/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(248); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -59790,17 +59827,19 @@ __webpack_require__.r(__webpack_exports__); + async function buildBazelProductionProjects({ kibanaRoot, buildRoot, onlyOSS }) { - const projects = await Object(_utils_projects__WEBPACK_IMPORTED_MODULE_7__["getBazelProjectsOnly"])(await Object(_build_non_bazel_production_projects__WEBPACK_IMPORTED_MODULE_3__["getProductionProjects"])(kibanaRoot, onlyOSS)); + const projects = await Object(_utils_projects__WEBPACK_IMPORTED_MODULE_8__["getBazelProjectsOnly"])(await Object(_build_non_bazel_production_projects__WEBPACK_IMPORTED_MODULE_3__["getProductionProjects"])(kibanaRoot, onlyOSS)); const projectNames = [...projects.values()].map(project => project.name); - _utils_log__WEBPACK_IMPORTED_MODULE_5__["log"].info(`Preparing Bazel projects production build for [${projectNames.join(', ')}]`); + _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].info(`Preparing Bazel projects production build for [${projectNames.join(', ')}]`); + await Object(_utils_bazel_run__WEBPACK_IMPORTED_MODULE_4__["runBazel"])(['build', '//packages:build']); + _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].info(`All Bazel projects production builds for [${projectNames.join(', ')}] are complete}]`); for (const project of projects.values()) { - await Object(_build_non_bazel_production_projects__WEBPACK_IMPORTED_MODULE_3__["buildProject"])(project); await copyToBuild(project, kibanaRoot, buildRoot); await applyCorrectPermissions(project, kibanaRoot, buildRoot); } @@ -59835,9 +59874,9 @@ async function copyToBuild(project, kibanaRoot, buildRoot) { // the intermediate build, we fall back to using the project's already defined // `package.json`. - const packageJson = (await Object(_utils_fs__WEBPACK_IMPORTED_MODULE_4__["isFile"])(Object(path__WEBPACK_IMPORTED_MODULE_2__["join"])(buildProjectPath, 'package.json'))) ? await Object(_utils_package_json__WEBPACK_IMPORTED_MODULE_6__["readPackageJson"])(buildProjectPath) : project.json; - const preparedPackageJson = Object(_utils_package_json__WEBPACK_IMPORTED_MODULE_6__["createProductionPackageJson"])(packageJson); - await Object(_utils_package_json__WEBPACK_IMPORTED_MODULE_6__["writePackageJson"])(buildProjectPath, preparedPackageJson); + const packageJson = (await Object(_utils_fs__WEBPACK_IMPORTED_MODULE_5__["isFile"])(Object(path__WEBPACK_IMPORTED_MODULE_2__["join"])(buildProjectPath, 'package.json'))) ? await Object(_utils_package_json__WEBPACK_IMPORTED_MODULE_7__["readPackageJson"])(buildProjectPath) : project.json; + const preparedPackageJson = Object(_utils_package_json__WEBPACK_IMPORTED_MODULE_7__["createProductionPackageJson"])(packageJson); + await Object(_utils_package_json__WEBPACK_IMPORTED_MODULE_7__["writePackageJson"])(buildProjectPath, preparedPackageJson); } async function applyCorrectPermissions(project, kibanaRoot, buildRoot) { @@ -59852,12 +59891,12 @@ async function applyCorrectPermissions(project, kibanaRoot, buildRoot) { for (const pluginPath of allPluginPaths) { const resolvedPluginPath = Object(path__WEBPACK_IMPORTED_MODULE_2__["resolve"])(buildRoot, pluginPath); - if (await Object(_utils_fs__WEBPACK_IMPORTED_MODULE_4__["isFile"])(resolvedPluginPath)) { - await Object(_utils_fs__WEBPACK_IMPORTED_MODULE_4__["chmod"])(resolvedPluginPath, 0o644); + if (await Object(_utils_fs__WEBPACK_IMPORTED_MODULE_5__["isFile"])(resolvedPluginPath)) { + await Object(_utils_fs__WEBPACK_IMPORTED_MODULE_5__["chmod"])(resolvedPluginPath, 0o644); } - if (await Object(_utils_fs__WEBPACK_IMPORTED_MODULE_4__["isDirectory"])(resolvedPluginPath)) { - await Object(_utils_fs__WEBPACK_IMPORTED_MODULE_4__["chmod"])(resolvedPluginPath, 0o755); + if (await Object(_utils_fs__WEBPACK_IMPORTED_MODULE_5__["isDirectory"])(resolvedPluginPath)) { + await Object(_utils_fs__WEBPACK_IMPORTED_MODULE_5__["chmod"])(resolvedPluginPath, 0o755); } } } diff --git a/packages/kbn-pm/src/production/build_bazel_production_projects.ts b/packages/kbn-pm/src/production/build_bazel_production_projects.ts index cd40653a6b54c2..a54d6c753d8d77 100644 --- a/packages/kbn-pm/src/production/build_bazel_production_projects.ts +++ b/packages/kbn-pm/src/production/build_bazel_production_projects.ts @@ -10,7 +10,8 @@ import copy from 'cpy'; import globby from 'globby'; import { basename, join, relative, resolve } from 'path'; -import { buildProject, getProductionProjects } from './build_non_bazel_production_projects'; +import { getProductionProjects } from './build_non_bazel_production_projects'; +import { runBazel } from '../utils/bazel/run'; import { chmod, isFile, isDirectory } from '../utils/fs'; import { log } from '../utils/log'; import { @@ -35,8 +36,10 @@ export async function buildBazelProductionProjects({ const projectNames = [...projects.values()].map((project) => project.name); log.info(`Preparing Bazel projects production build for [${projectNames.join(', ')}]`); + await runBazel(['build', '//packages:build']); + log.info(`All Bazel projects production builds for [${projectNames.join(', ')}] are complete}]`); + for (const project of projects.values()) { - await buildProject(project); await copyToBuild(project, kibanaRoot, buildRoot); await applyCorrectPermissions(project, kibanaRoot, buildRoot); } diff --git a/packages/kbn-pm/src/utils/bazel/install_tools.ts b/packages/kbn-pm/src/utils/bazel/install_tools.ts index dfd20f5974d67d..b547c2bc141bda 100644 --- a/packages/kbn-pm/src/utils/bazel/install_tools.ts +++ b/packages/kbn-pm/src/utils/bazel/install_tools.ts @@ -36,6 +36,45 @@ async function isBazelBinAvailable() { } } +async function isBazeliskInstalled(bazeliskVersion: string) { + try { + const { stdout: bazeliskPkgInstallStdout } = await spawn( + 'npm', + ['ls', '--global', '--parseable', '--long', `@bazel/bazelisk@${bazeliskVersion}`], + { + stdio: 'pipe', + } + ); + + return bazeliskPkgInstallStdout.includes(`@bazel/bazelisk@${bazeliskVersion}`); + } catch { + return false; + } +} + +async function tryRemoveBazeliskFromYarnGlobal() { + try { + // Check if Bazelisk is installed on the yarn global scope + const { stdout: bazeliskPkgInstallStdout } = await spawn('yarn', ['global', 'list'], { + stdio: 'pipe', + }); + + // Bazelisk was found on yarn global scope so lets remove it + if (bazeliskPkgInstallStdout.includes(`@bazel/bazelisk@`)) { + await spawn('yarn', ['global', 'remove', `@bazel/bazelisk`], { + stdio: 'pipe', + }); + + log.info(`[bazel_tools] bazelisk was installed on Yarn global packages and is now removed`); + return true; + } + + return false; + } catch { + return false; + } +} + export async function installBazelTools(repoRootPath: string) { log.debug(`[bazel_tools] reading bazel tools versions from version files`); const bazeliskVersion = await readBazelToolsVersionFile(repoRootPath, '.bazeliskversion'); @@ -43,23 +82,24 @@ export async function installBazelTools(repoRootPath: string) { // Check what globals are installed log.debug(`[bazel_tools] verify if bazelisk is installed`); - const { stdout: bazeliskPkgInstallStdout } = await spawn('yarn', ['global', 'list'], { - stdio: 'pipe', - }); + // Test if bazelisk is already installed in the correct version + const isBazeliskPkgInstalled = await isBazeliskInstalled(bazeliskVersion); + + // Test if bazel bin is available const isBazelBinAlreadyAvailable = await isBazelBinAvailable(); + // Check if we need to remove bazelisk from yarn + await tryRemoveBazeliskFromYarnGlobal(); + // Install bazelisk if not installed - if ( - !bazeliskPkgInstallStdout.includes(`@bazel/bazelisk@${bazeliskVersion}`) || - !isBazelBinAlreadyAvailable - ) { + if (!isBazeliskPkgInstalled || !isBazelBinAlreadyAvailable) { log.info(`[bazel_tools] installing Bazel tools`); log.debug( `[bazel_tools] bazelisk is not installed. Installing @bazel/bazelisk@${bazeliskVersion} and bazel@${bazelVersion}` ); - await spawn('yarn', ['global', 'add', `@bazel/bazelisk@${bazeliskVersion}`], { + await spawn('npm', ['install', '--global', `@bazel/bazelisk@${bazeliskVersion}`], { env: { USE_BAZEL_VERSION: bazelVersion, }, @@ -69,7 +109,7 @@ export async function installBazelTools(repoRootPath: string) { const isBazelBinAvailableAfterInstall = await isBazelBinAvailable(); if (!isBazelBinAvailableAfterInstall) { throw new Error(dedent` - [bazel_tools] an error occurred when installing the Bazel tools. Please make sure 'yarn global bin' is on your $PATH, otherwise just add it there + [bazel_tools] an error occurred when installing the Bazel tools. Please make sure you have access to npm globally installed modules on your $PATH `); } } diff --git a/rfcs/images/api_doc_pick.png b/rfcs/images/api_doc_pick.png new file mode 100644 index 00000000000000..825fa47b266cb9 Binary files /dev/null and b/rfcs/images/api_doc_pick.png differ diff --git a/rfcs/images/api_doc_tech.png b/rfcs/images/api_doc_tech.png new file mode 100644 index 00000000000000..8c06d4ef3ebe8f Binary files /dev/null and b/rfcs/images/api_doc_tech.png differ diff --git a/rfcs/images/api_doc_tech_compare.png b/rfcs/images/api_doc_tech_compare.png new file mode 100644 index 00000000000000..46388b2a09a507 Binary files /dev/null and b/rfcs/images/api_doc_tech_compare.png differ diff --git a/rfcs/images/api_docs.png b/rfcs/images/api_docs.png new file mode 100644 index 00000000000000..d7e2e517e64650 Binary files /dev/null and b/rfcs/images/api_docs.png differ diff --git a/rfcs/images/api_docs_package_current.png b/rfcs/images/api_docs_package_current.png new file mode 100644 index 00000000000000..1a8f26dfad446c Binary files /dev/null and b/rfcs/images/api_docs_package_current.png differ diff --git a/rfcs/images/api_info.png b/rfcs/images/api_info.png new file mode 100644 index 00000000000000..dc5ecc845cb722 Binary files /dev/null and b/rfcs/images/api_info.png differ diff --git a/rfcs/images/current_api_doc_links.png b/rfcs/images/current_api_doc_links.png new file mode 100644 index 00000000000000..e52a273cf24e38 Binary files /dev/null and b/rfcs/images/current_api_doc_links.png differ diff --git a/rfcs/images/new_api_docs_with_links.png b/rfcs/images/new_api_docs_with_links.png new file mode 100644 index 00000000000000..bfa514b9195338 Binary files /dev/null and b/rfcs/images/new_api_docs_with_links.png differ diff --git a/rfcs/images/repeat_primitive_signature.png b/rfcs/images/repeat_primitive_signature.png new file mode 100644 index 00000000000000..7c98eefbcf50df Binary files /dev/null and b/rfcs/images/repeat_primitive_signature.png differ diff --git a/rfcs/images/repeat_type_links.png b/rfcs/images/repeat_type_links.png new file mode 100644 index 00000000000000..bff54d90e9cae0 Binary files /dev/null and b/rfcs/images/repeat_type_links.png differ diff --git a/rfcs/text/0014_api_documentation.md b/rfcs/text/0014_api_documentation.md new file mode 100644 index 00000000000000..b70636c63aad37 --- /dev/null +++ b/rfcs/text/0014_api_documentation.md @@ -0,0 +1,442 @@ +- Start Date: 2020-12-21 +- RFC PR: (leave this empty) +- Kibana Issue: (leave this empty) +- [POC PR](https://github.com/elastic/kibana/pull/86232) + +# Goal + +Automatically generate API documentation for every plugin that exposes a public API within Kibana in order to help Kibana plugin developers +find and understand the services available to them. Automatic generation ensures the APIs are _always_ up to date. The system will make it easy to find +APIs that are lacking documentation. + +Note this does not cover REST API docs, but is targetted towards our javascript +plugin APIs. + +# Technology: ts-morph vs api-extractor + +[Api-extractor](https://api-extractor.com/) is a utility built from microsoft that parses typescript code into json files that can then be used in a custom [api-documenter](https://api-extractor.com/pages/setup/generating_docs/) in order to build documentation. This is what we [have now](https://github.com/elastic/kibana/tree/master/docs/development), except we use the default api-documenter. + +## Limitations with the current implementation using api-extractor & api-documenter + +The current implementation relies on the default api-documenter. It has the following limitations: + +- One page per API item +- Files are .md not .mdx +- There is no entry page per plugin (just an index.md per plugin/public and plugin/server) +- Incorrectly marks these entries as packages. + +![image](../images/api_docs_package_current.png) + +- Does not generate links to APIs exposed from other plugins, nor inside the same plugin. + +![image](../images/current_api_doc_links.png) + +## Options to improve + +We have two options to improve on the current implementation. We can use a custom api-documenter, or use ts-morph. + +### Custom Api-Documenter + +- According to the current maintainer of the sample api-documenter, it's a surprising amount of work to maintain. +- If we wish to re-use code from the sample api-documenter, we'll have to fork the rush-stack repo, or copy their code into our system. +- No verified ability to support cross plugin links. We do have some ideas (can explore creating a package.json for every page, and/or adding source file information to every node). +- More limited feature set, we wouldn't get thinks like references and source file paths. +- There are very few examples of other companies using custom api-documenters to drive their documentation systems (I could not find any on github). + +### Custom implementation using ts-morph + +[ts-morph](https://github.com/dsherret/ts-morph) is a utility built and maintained by a single person, which sits a layer above the raw typescript compiler. + +- Requires manually converting the types to how we want them to be displayed in the UI. Certain types have to be handled specially to show up +in the right way (for example, for arrow functions to be categorized as functions). This special handling is the bulk of the logic in the PR, and +may be a maintenance burden. +- Relies on a package maintained by a single person, albiet they have been very responsive and have a history of keeping the library up to date with +typescript upgrades. +- Affords us flexibility to do things like extract the setup and start types, grab source file paths to create links to github, and get +reference counts (reference counts not implemented in MVP). +- There are some issues with type links and signatures not working correctly (see https://github.com/dsherret/ts-morph/issues/923). + +![image](../images/new_api_docs_with_links.png) + +## Recommendation: ts-morph for the short term, switch to api-extractor when limitations can be worked around + +Both approaches will have a decent amount of code to maintain, but the api-extractor approach appears to be a more stable long term solution, since it's built and maintained by Microsoft and +is likely going to grow in popularity as more TypeScript API doc systems exist. +If we had a working example that supported cross plugin links, I would suggest continuing down that road. However, we don't, while we _do_ have a working ts-morph implementation. + +I recommend that we move ahead with ts-morph in the short term, because we have an implementation that offers a much improved experience over the current system, but that we continually +re-evaluate as time goes on and we learn more about the maintenance burden of the current approach, and see what happens with our priorities and the api-extractor library. + +Progress over perfection. + +![image](../images/api_doc_tech_compare.png) + +If we do switch, we can re-use all of the tests that take example TypeScript files and verify the resulting ApiDeclaration shapes. + +# Terminology + +**API** - A plugin's public API consists of every function, class, interface, type, variable, etc, that is exported from it's index.ts file, or returned from it's start or setup +contract. + +**API Declaration** - Each function, class, interface, type, variable, etc, that is part of a plugins public API is a "declaration". This +terminology is motivated by [these docs](https://www.typescriptlang.org/docs/handbook/modules.html#exporting-a-declaration). + +# MVP + +Every plugin will have one or more API reference pages. Every exported declaration will be listed in the page. It is first split by "scope" - client, server and common. Underneath +that, setup and start contracts are at the top, the remaining declarations are grouped by type (classes, functions, interfaces, etc). +Plugins may opt to have their API split into "service" sections (see [proposed manifest file changes](#manifest-file-changes)). If a plugin uses service folders, the API doc system will automatically group declarations that are defined inside the service folder name. This is a simple way to break down very large plugins. The start and setup contract will +always remain with the main plugin name. + +![image](../images/api_docs.png) + +- Cross plugin API links work inside `signature`. +- Github links with source file and line number +- using `serviceFolders` to split large plugins + +## Post MVP + +- Plugin `{@link AnApi}` links work. Will need to decide if we only support per plugin links, or if we should support a way to do this across plugins. +- Ingesting stats like number of public APIs, and number of those missing comments +- Include and expose API references +- Use namespaces to split large plugins + +# Information available for each API declaration + +We have the following pieces of information available from each declaration: + +- Label. The name of the function, class, interface, etc. + +- Description. Any comment that was able to be extracted. Currently it's not possible for this data to be formatted, for example if it has a code example with back tics. This +is dependent on the elastic-docs team moving the infrastructure to NextJS instead of Gatsby, but it will eventually be supported. + +- Tags. Any `@blahblah` tags that were extracted from comments. Known tags, like `beta`, will be show help text in a tooltip when hovered over. + +- Type. This can be thought of as the _kind_ of type (see [TypeKind](#typekind)). It allows us to group each type into a category. It can be a primitive, or a +more complex grouping. Possibilities are: array, string, number, boolean, object, class, interface, function, compound (unions or intersections) + +- Required or optional. (whether or not the type was written with `| undefined` or `?`). This terminology makes the most sense for function +parameters, not as much when thinking about an exported variable that might be undefined. + +- Signature. This is only relevant for some types: functions, objects, type, arrays and compound. Classes and interfaces would be too large. +For primitives, this is equivalent to "type". + +- Children. Only relevant for some types, this would include parameters for functions, class members and functions for classes, properties for +interfaces and objects. This makes the structure recursive. Each child is a nested API component. + +- Return comment. Only relevant for function types. + +![image](../images/api_info.png) + + +### ApiDeclaration type + +```ts +interface ApiDeclaration { + label: string; + type: TypeKind; // string, number, boolean, class, interface, function, type, etc. + description: TextWithLinks; + signature: TextWithLinks; + tags: string[]; // Declarations may be tagged as beta, or deprecated. + children: ApiDeclaration[]; // Recursive - this could be function parameters, class members, or interface/object properties. + returnComment?: TextWithLinks; + lifecycle?: Lifecycle.START | Lifecycle.SETUP; +} + +``` + +# Architecture design + +## Location + +The generated docs will reside inside the kibana repo, inside a top level `api_docs` folder. In the long term, we could investigate having the docs system run a script to generated the mdx files, so we don’t need to store them inside the repo. Every ci run should destroy and re-create this folder so removed plugins don't have lingering documentation files. + +They will be hosted online wherever the new docs system ends up. This can temporarily be accessed at https://elasticdocstest.netlify.app/docs/. + +## Algorithm overview + +The first stage is to collect the list of plugins using the existing `findPlugins` logic. + +For every plugin, the initial list of ts-morph api node declarations are collected from three "scope" files: + - plugin/public/index.ts + - plugin/server/index.ts + - plugin/common/index.ts + +Each ts-morph declaration is then transformed into an [ApiDeclaration](#ApiDeclaration-type) type, which is recursive due to the `children` property. Each +type of declaration is handled slightly differently, mainly in regard to whether or not a signature or return type is added, and how children are added. + +For example: + +```ts +if (node.isClassDeclaration()) { + // No signature or return. + return { + label, + description, + type: TypeKind.ClassKind, + // The class members are captured in the children array. + children: getApiDeclaration(node.getMembers()), + } +} else if (node.isFunctionDeclaration()) { + return { + label, + description, + signature: getSignature(node), + returnComment: getReturnComment(node), + type: TypeKind.FunctionKind, + // The function parameters are captured in the children array. This logic is more specific because + // the comments for a function parameter are captured in the function comment, with "@param" tags. + children: getParameterList(node.getParameters(), getParamTagComments(node)), + } +} if (...) +.... +``` + +The handling of each specific type is what encompasses the vast majority of the logic in the PR. + +The public and server scope have 0-2 special interfaces indicated by "lifecycle". This is determined by using ts-morph to extract the first two generic types +passed to `... extends Plugin` in the class defined inside the plugin's `plugin.ts` file. + +A [PluginApi](#pluginapi) is generated for each plugin, which is used to generate the json and mdx files. One or more json/mdx file pair + per plugin may be created, depending on the value of `serviceFolders` inside the plugin's manifest files. This is because some plugins have such huge APIs that + it is too large to render in a single page. + +![image](../images/api_doc_tech.png) + +## Types + +### TypeKind + +TypeKind is an enum that will identify what "category" or "group" name we can call this particular export. Is it a function, an interface, a class a variable, etc. +This list is likely incomplete, and we'll expand as needed. + +```ts +export enum TypeKind { + ClassKind = 'Class', + FunctionKind = 'Function', + ObjectKind = 'Object', + InterfaceKind = 'Interface', + TypeKind = 'Type', // For things like `export type Foo = ...` + UnknownKind = 'Unknown', // For the special "unknown" typescript type. + AnyKind = 'Any', // For the "any" kind, which should almost never be used in our public API. + UnCategorized = 'UnCategorized', // There are a lot of ts-morph types, if I encounter something not handled, I dump it in here. + StringKind = 'string', + NumberKind = 'number', + BooleanKind = 'boolean', + ArrayKind = 'Array', + CompoundTypeKind = 'CompoundType', // Unions & intersections, to handle things like `string | number`. +} +``` + + +### Text with reference links + +Signatures, descriptions and return comments may all contain links to other API declarations. This information needs to be serializable into json. This serializable type encompasses the information needed to build the DocLink components within these fields. The logic of building +the DocLink components currently resides inside the elastic-docs system. It's unclear if this will change. + +```ts +/** + * This is used for displaying code or comments that may contain reference links. For example, a function + * signature that is `(a: import("src/plugin_b").Bar) => void` will be parsed into the following Array: + * + * ```ts + * [ + * '(a: ', + * { docId: 'pluginB', section: 'Bar', text: 'Bar' }, + * ') => void' + * ] + * ``` + * + * This is then used to render text with nested DocLinks so it looks like this: + * + * `(a: => ) => void` + */ +export type TextWithLinks = Array; + +/** + * The information neccessary to build a DocLink. + */ +export interface Reference { + docId: string; + section: string; + text: string; +} +``` + +### ScopeApi + +Scope API is essentially just grouping an array of ApiDeclarations into different categories that makes building the mdx files from a +single json file easier. + +```ts +export interface ScopeApi { + setup?: ApiDeclaration; + start?: ApiDeclaration; + classes: ApiDeclaration[]; + functions: ApiDeclaration[]; + interfaces: ApiDeclaration[]; + objects: ApiDeclaration[]; + enums: ApiDeclaration[]; + misc: ApiDeclaration[]; + // We may add more here as we sit fit to pull out of `misc`. +} +``` + +With this structure, the mdx files end up looking like: + +``` +### Start + +### Functions + +### Interfaces + +``` + +### PluginApi + +A plugin API is the component that is serialized into the json file. It is broken into public, server and common components. `serviceFolders` is a way for the system to +write separate mdx files depending on where each declaration is defined. This is because certain plugins (and core) +are huge, and can't be rendered in a single page. + + +```ts +export interface PluginApi { + id: string; + serviceFolders?: readonly string[]; + client: ScopeApi; + server: ScopeApi; + common: ScopeApi; +} +``` + +## kibana.json Manifest file changes + +### Using a kibana.json file for core + +For the purpose of API infrastructure, core is treated like any other plugin. This means it has to specify serviceFolders section inside a manifest file to be split into sub folders. There are other ways to tackle this - like a hard coded array just for the core folder, but I kept the logic as similar to the other plugins as possible. + +### New parameters + +**serviceFolders?: string[]** + +Used by the system to group services into sub-pages. Some plugins, like data and core, have such huge APIs they are very slow to contain in a single page, and they are less consummable by solution developers. The addition of an optional list of services folders will cause the system to automatically create a separate page with every API that is defined within that folder. The caveat is that core will need to define a manifest file in order to define its service folders... + +**teamOwner: string** + +Team owner can be determined via github CODEOWNERS file, but we want to encourage single team ownership per plugin. Requiring a team owner string in the manifest file will help with this and will allow the API doc system to manually add a section to every page that has a link to the team owner. Additional ideas are teamSlackChannel or teamEmail for further contact. + +**summary: string** + + +A brief description of the plugin can then be displayed in the automatically generated API documentation. + +# Future features + +## Indexing stats + +Can we index statistics about our API as part of this system? For example, I'm dumping information about which api declarations are missing comments in the console. + +## Longer term approach to "plugin service folders" + +Using sub folders is a short term plan. A long term plan hasn't been established yet, but it should fit in with our folder structure hierarchy goals, along with +any support we have for sharing services among a related set of plugins, that are not exposed as part of the public API. +# Recommendations for writing comments + +## @link comments for the referenced type + +Core has a pattern of writing comments like this: + +```ts + /** {@link IUiSettingsClient} */ + uiSettings: IUiSettingsClient; +``` + +I don't see the value in this. In the IDE, I can click on the IUiSettingsClient type and get directed there, and in the API doc system, the +type will already be clickable. This ends up with a weird looking API: + +![image](../images/repeat_type_links.png) + +The plan is to make @link comments work like links, which means this is unneccessary information. + +I propose we avoid this kind of pattern. + +## Export every referenced type + +The docs system handles broken link warnings but to avoid breaking the ci, I suggest we turn this off initially. However, this will mean +we may miss situations where we are referencing a type that is not actually exported. This will cause a broken link in the docs +system + +For example if your index.ts file has: +```ts +export type foo: string | AnInterface; +``` + +and does not also export `AnInterface`, this will be a broken link in the docs system. + +Until we have better CI tools to catch these mistakes, developers will need to export every referenced type. + +## Avoid `Pick` pattern + +Connected to the above, if you use `Pick`, there are two problems. One is that it's difficult for a developer to see the functionality +available to them at a glance, since they would have to keep flipping from the interface definition to the properties that have been picked. + +The second potential problem is that you will have to export the referenced type, and in some situations, it's an internal type that isn't exported. + +![image](../images/api_doc_pick.png) + +# Open questions + +## Required attribute + +`isRequired` is an optional parameter that can be used to display a badge next to the API. +We can mark function parameters that do not use `?` or `| undefined` as required. Open questions: + +1. Are we okay with a badge showing for `required` rather than `optional` when marking a parameter as optional is extra work for a developer, and `required` is the default? + +2. Should we only mark function parameters as `required` or interface/class parameters? Essentially, should any declaration that is not nullable +have the `required` tag? + +## Signatures on primitive types + +1. Should we _always_ include a signature for variables and parameters, even if they are a repeat of the TypeKind? For example: + +![image](../images/repeat_primitive_signature.png) + +2. If no, should we include signatures when the only difference is `| undefined`? For function parameters this information is captured by +the absence of the `required` badge. Is this obvious? What about class members/interface props? + +## Out of scope + +### REST API + +This RFC does not cover REST API documentation, though it worth considering where +REST APIs registered by plugins should go in the docs. The docs team has a proposal for this but it is not inside the `Kibana Developer Docs` mission. + +### Package APIs + +Package APIs are not covered in this RFC. + +# Adoption strategy + +In order to generate useful API documentation, we need to approach this by two sides. + +1. Establish a habit of writing documentation. +2. Establish a habit of reading documentation. + +Currently what often happens is a developer asks another developer a question directly, and it is answered. Every time this happens, ask yourself if +there is a link you can share instead of a direct answer. If there isn't, file an issue for that documentation to be created. When we start responding +to questions with links, solution developers will naturally start to look in the documentation _first_, saving everyone time! + +The APIs WILL need to be well commented or they won't be useful. We can measure the amount of missing comments and set a goal of reducing this number. + +# External documentation system examples + +- [Microsoft .NET](https://docs.microsoft.com/en-us/dotnet/api/microsoft.visualbasic?view=netcore-3.1) +- [Android](https://developer.android.com/reference/androidx/packages) + +# Architecure review + +The primary concern coming out of the architecture review was over the technology choice of ts-morph vs api-extractor, and the potential maintenance +burdern of using ts-morph. For the short term, we've decide tech leads will own this section of code, we'll consider it experimental and + focus on deriving value out of it. Once we are confident of the value, we can focus on stabilizing the implementation details. \ No newline at end of file diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 7fd62d6f02e96b..da35373f57322a 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -39,6 +39,7 @@ export class DocLinksService { base: `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}`, installation: `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}/filebeat-installation-configuration.html`, configuration: `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}/configuring-howto-filebeat.html`, + elasticsearchModule: `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}/filebeat-module-elasticsearch.html`, elasticsearchOutput: `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}/elasticsearch-output.html`, startup: `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}/filebeat-starting.html`, exportedFields: `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}/exported-fields.html`, @@ -53,6 +54,10 @@ export class DocLinksService { }, metricbeat: { base: `${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}`, + configure: `${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/configuring-howto-metricbeat.html`, + httpEndpoint: `${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/http-endpoint.html`, + install: `${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/metricbeat-installation-configuration.html`, + start: `${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/metricbeat-starting.html`, }, heartbeat: { base: `${ELASTIC_WEBSITE_URL}guide/en/beats/heartbeat/${DOC_LINK_VERSION}`, @@ -116,6 +121,7 @@ export class DocLinksService { addData: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/connect-to-elasticsearch.html`, kibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index.html`, elasticsearch: { + mapping: `${ELASTICSEARCH_DOCS}mapping.html`, remoteClusters: `${ELASTICSEARCH_DOCS}modules-remote-clusters.html`, remoteClustersProxy: `${ELASTICSEARCH_DOCS}modules-remote-clusters.html#proxy-mode`, remoteClusersProxySettings: `${ELASTICSEARCH_DOCS}modules-remote-clusters.html#remote-cluster-proxy-settings`, @@ -193,8 +199,11 @@ export class DocLinksService { alertsKibanaDiskThreshold: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-disk-usage-threshold`, alertsKibanaJvmThreshold: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-jvm-memory-threshold`, alertsKibanaMissingData: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-missing-monitoring-data`, + metricbeatBlog: `${ELASTIC_WEBSITE_URL}blog/external-collection-for-elastic-stack-monitoring-is-now-available-via-metricbeat`, monitorElasticsearch: `${ELASTICSEARCH_DOCS}configuring-metricbeat.html`, monitorKibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/monitoring-metricbeat.html`, + monitorLogstash: `${ELASTIC_WEBSITE_URL}guide/en/logstash/${DOC_LINK_VERSION}/monitoring-with-metricbeat.html`, + troubleshootKibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/monitor-troubleshooting.html`, }, security: { apiKeyServiceSettings: `${ELASTICSEARCH_DOCS}security-settings.html#api-key-service-settings`, @@ -257,6 +266,7 @@ export interface DocLinksStart { readonly installation: string; readonly configuration: string; readonly elasticsearchOutput: string; + readonly elasticsearchModule: string; readonly startup: string; readonly exportedFields: string; }; @@ -265,6 +275,10 @@ export interface DocLinksStart { }; readonly metricbeat: { readonly base: string; + readonly configure: string; + readonly httpEndpoint: string; + readonly install: string; + readonly start: string; }; readonly enterpriseSearch: { readonly base: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 37ebbcaa752af1..75ed9aa5f150f6 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -474,6 +474,7 @@ export interface DocLinksStart { readonly installation: string; readonly configuration: string; readonly elasticsearchOutput: string; + readonly elasticsearchModule: string; readonly startup: string; readonly exportedFields: string; }; @@ -482,6 +483,10 @@ export interface DocLinksStart { }; readonly metricbeat: { readonly base: string; + readonly configure: string; + readonly httpEndpoint: string; + readonly install: string; + readonly start: string; }; readonly enterpriseSearch: { readonly base: string; diff --git a/src/core/server/http/logging/get_payload_size.test.ts b/src/core/server/http/logging/get_payload_size.test.ts index a4ab8919e8b6da..dba5c7be30f3b0 100644 --- a/src/core/server/http/logging/get_payload_size.test.ts +++ b/src/core/server/http/logging/get_payload_size.test.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { createGunzip } from 'zlib'; import type { Request } from '@hapi/hapi'; import Boom from '@hapi/boom'; @@ -96,6 +97,18 @@ describe('getPayloadSize', () => { expect(result).toBe(Buffer.byteLength(data)); }); + + test('ignores streams that are not instances of ReadStream', async () => { + const result = getResponsePayloadBytes( + { + variety: 'stream', + source: createGunzip(), + } as Response, + logger + ); + + expect(result).toBe(undefined); + }); }); describe('handles plain responses', () => { @@ -132,6 +145,17 @@ describe('getPayloadSize', () => { ); expect(result).toBe(JSON.stringify(payload).length); }); + + test('returns undefined when source is not a plain object', () => { + const result = getResponsePayloadBytes( + { + variety: 'plain', + source: [1, 2, 3], + } as Response, + logger + ); + expect(result).toBe(undefined); + }); }); describe('handles content-length header', () => { diff --git a/src/core/server/http/logging/get_payload_size.ts b/src/core/server/http/logging/get_payload_size.ts index 6dcaf3653d8420..8e6dea13e1fa17 100644 --- a/src/core/server/http/logging/get_payload_size.ts +++ b/src/core/server/http/logging/get_payload_size.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import type { ReadStream } from 'fs'; +import { isPlainObject } from 'lodash'; +import { ReadStream } from 'fs'; import { isBoom } from '@hapi/boom'; import type { Request } from '@hapi/hapi'; import { Logger } from '../../logging'; @@ -17,8 +18,15 @@ const isBuffer = (src: unknown, res: Response): src is Buffer => { return !isBoom(res) && res.variety === 'buffer' && res.source === src; }; const isFsReadStream = (src: unknown, res: Response): src is ReadStream => { - return !isBoom(res) && res.variety === 'stream' && res.source === src; + return ( + !isBoom(res) && + res.variety === 'stream' && + res.source === src && + res.source instanceof ReadStream + ); }; +const isString = (src: unknown, res: Response): src is string => + !isBoom(res) && res.variety === 'plain' && typeof src === 'string'; /** * Attempts to determine the size (in bytes) of a Hapi response @@ -57,10 +65,12 @@ export function getResponsePayloadBytes(response: Response, log: Logger): number return response.source.bytesRead; } - if (response.variety === 'plain') { - return typeof response.source === 'string' - ? Buffer.byteLength(response.source) - : Buffer.byteLength(JSON.stringify(response.source)); + if (isString(response.source, response)) { + return Buffer.byteLength(response.source); + } + + if (response.variety === 'plain' && isPlainObject(response.source)) { + return Buffer.byteLength(JSON.stringify(response.source)); } } catch (e) { // We intentionally swallow any errors as this information is diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.ts b/src/core/server/saved_objects/migrationsv2/actions/index.ts index fe2ce76446cb99..b22c326061f662 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/index.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/index.ts @@ -150,12 +150,23 @@ export const removeWriteBlock = ( .catch(catchRetryableEsClientErrors); }; -const waitForIndexStatusGreen = ( +/** + * A yellow index status means the index's primary shard is allocated and the + * index is ready for searching/indexing documents, but ES wasn't able to + * allocate the replicas. When migrations proceed with a yellow index it means + * we don't have as much data-redundancy as we could have, but waiting for + * replicas would mean that v2 migrations fail where v1 migrations would have + * succeeded. It doesn't feel like it's Kibana's job to force users to keep + * their clusters green and even if it's green when we migrate it can turn + * yellow at any point in the future. So ultimately data-redundancy is up to + * users to maintain. + */ +const waitForIndexStatusYellow = ( client: ElasticsearchClient, index: string ): TaskEither.TaskEither => () => { return client.cluster - .health({ index, wait_for_status: 'green', timeout: '30s' }) + .health({ index, wait_for_status: 'yellow', timeout: '30s' }) .then(() => { return Either.right({}); }) @@ -259,7 +270,7 @@ export const cloneIndex = ( } else { // Otherwise, wait until the target index has a 'green' status. return pipe( - waitForIndexStatusGreen(client, target), + waitForIndexStatusYellow(client, target), TaskEither.map((value) => { /** When the index status is 'green' we know that all shards were started */ return { acknowledged: true, shardsAcknowledged: true }; @@ -687,7 +698,7 @@ export const createIndex = ( } else { // Otherwise, wait until the target index has a 'green' status. return pipe( - waitForIndexStatusGreen(client, indexName), + waitForIndexStatusYellow(client, indexName), TaskEither.map(() => { /** When the index status is 'green' we know that all shards were started */ return 'create_index_succeeded'; 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 1bb4e57b0ac299..46cfd935f429b3 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 @@ -213,12 +213,8 @@ describe('migration actions', () => { } }); it('resolves right if cloning into a new target index', async () => { + const task = cloneIndex(client, 'existing_index_with_write_block', 'clone_target_1'); expect.assertions(1); - const task = cloneIndex( - client, - 'existing_index_with_write_block', - 'clone_yellow_then_green_index_1' - ); await expect(task()).resolves.toMatchInlineSnapshot(` Object { "_tag": "Right", @@ -229,42 +225,48 @@ describe('migration actions', () => { } `); }); - it('resolves right after waiting for index status to be green if clone target already existed', async () => { + it('resolves right after waiting for index status to be yellow if clone target already existed', async () => { expect.assertions(2); + // Create a yellow index - await client.indices.create({ - index: 'clone_yellow_then_green_index_2', - body: { - mappings: { properties: {} }, - settings: { - // Allocate 1 replica so that this index stays yellow - number_of_replicas: '1', + await client.indices + .create({ + index: 'clone_red_then_yellow_index', + timeout: '5s', + body: { + mappings: { properties: {} }, + settings: { + // Allocate 1 replica so that this index stays yellow + number_of_replicas: '1', + // Disable all shard allocation so that the index status is red + 'index.routing.allocation.enable': 'none', + }, }, - }, - }); + }) + .catch((e) => {}); // Call clone even though the index already exists const cloneIndexPromise = cloneIndex( client, 'existing_index_with_write_block', - 'clone_yellow_then_green_index_2' + 'clone_red_then_yellow_index' )(); - let indexGreen = false; + let indexYellow = false; setTimeout(() => { client.indices.putSettings({ + index: 'clone_red_then_yellow_index', body: { - index: { - number_of_replicas: 0, - }, + // Enable all shard allocation so that the index status goes yellow + 'index.routing.allocation.enable': 'all', }, }); - indexGreen = true; + indexYellow = true; }, 10); await cloneIndexPromise.then((res) => { // Assert that the promise didn't resolve before the index became green - expect(indexGreen).toBe(true); + expect(indexYellow).toBe(true); expect(res).toMatchInlineSnapshot(` Object { "_tag": "Right", @@ -278,7 +280,7 @@ describe('migration actions', () => { }); it('resolves left index_not_found_exception if the source index does not exist', async () => { expect.assertions(1); - const task = cloneIndex(client, 'no_such_index', 'clone_yellow_then_green_index_3'); + const task = cloneIndex(client, 'no_such_index', 'clone_target_3'); await expect(task()).resolves.toMatchInlineSnapshot(` Object { "_tag": "Left", @@ -674,7 +676,6 @@ describe('migration actions', () => { describe('waitForPickupUpdatedMappingsTask', () => { it('rejects if there are failures', async () => { - expect.assertions(1); const res = (await pickupUpdatedMappings( client, 'existing_index_with_write_block' @@ -689,7 +690,6 @@ describe('migration actions', () => { }); }); it('rejects if there is an error', async () => { - expect.assertions(1); const res = (await pickupUpdatedMappings( client, 'no_such_index' @@ -703,7 +703,6 @@ describe('migration actions', () => { `); }); it('resolves right when successful', async () => { - expect.assertions(1); const res = (await pickupUpdatedMappings( client, 'existing_index_with_docs' @@ -722,7 +721,6 @@ describe('migration actions', () => { describe('updateAndPickupMappings', () => { it('resolves right when mappings were updated and picked up', async () => { - expect.assertions(3); // Create an index without any mappings and insert documents into it await createIndex(client, 'existing_index_without_mappings', { dynamic: false as any, @@ -771,7 +769,6 @@ describe('migration actions', () => { describe('updateAliases', () => { describe('remove', () => { it('resolves left index_not_found_exception when the index does not exist', async () => { - expect.assertions(1); const task = updateAliases(client, [ { remove: { @@ -793,7 +790,6 @@ describe('migration actions', () => { }); describe('with must_exist=false', () => { it('resolves left alias_not_found_exception when alias does not exist', async () => { - expect.assertions(1); const task = updateAliases(client, [ { remove: { @@ -815,7 +811,6 @@ describe('migration actions', () => { }); describe('with must_exist=true', () => { it('resolves left alias_not_found_exception when alias does not exist on specified index', async () => { - expect.assertions(1); const task = updateAliases(client, [ { remove: { @@ -835,7 +830,6 @@ describe('migration actions', () => { `); }); it('resolves left alias_not_found_exception when alias does not exist', async () => { - expect.assertions(1); const task = updateAliases(client, [ { remove: { @@ -858,7 +852,6 @@ describe('migration actions', () => { }); describe('remove_index', () => { it('left index_not_found_exception if index does not exist', async () => { - expect.assertions(1); const task = updateAliases(client, [ { remove_index: { @@ -877,7 +870,6 @@ describe('migration actions', () => { `); }); it('left remove_index_not_a_concrete_index when remove_index targets an alias', async () => { - expect.assertions(1); const task = updateAliases(client, [ { remove_index: { @@ -899,44 +891,50 @@ describe('migration actions', () => { describe('createIndex', () => { afterAll(async () => { - await client.indices.delete({ index: 'yellow_then_green_index' }); + await client.indices.delete({ index: 'red_then_yellow_index' }); }); - it('resolves right after waiting for an index status to be green if the index already existed', async () => { + it('resolves right after waiting for an index status to be yellow if the index already existed', async () => { expect.assertions(2); - // Create a yellow index - await client.indices.create( - { - index: 'yellow_then_green_index', - body: { - mappings: { properties: {} }, - settings: { - // Allocate 1 replica so that this index stays yellow - number_of_replicas: '1', + // Create a red index + await client.indices + .create( + { + index: 'red_then_yellow_index', + timeout: '5s', + body: { + mappings: { properties: {} }, + settings: { + // Allocate 1 replica so that this index stays yellow + number_of_replicas: '1', + // Disable all shard allocation so that the index status is red + 'index.routing.allocation.enable': 'none', + }, }, }, - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ); + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + .catch((e) => { + /** ignore */ + }); // Call createIndex even though the index already exists - const createIndexPromise = createIndex(client, 'yellow_then_green_index', undefined as any)(); - let indexGreen = false; + const createIndexPromise = createIndex(client, 'red_then_yellow_index', undefined as any)(); + let indexYellow = false; setTimeout(() => { client.indices.putSettings({ - index: 'yellow_then_green_index', + index: 'red_then_yellow_index', body: { - index: { - number_of_replicas: 0, - }, + // Disable all shard allocation so that the index status is red + 'index.routing.allocation.enable': 'all', }, }); - indexGreen = true; + indexYellow = true; }, 10); await createIndexPromise.then((res) => { // Assert that the promise didn't resolve before the index became green - expect(indexGreen).toBe(true); + expect(indexYellow).toBe(true); expect(res).toMatchInlineSnapshot(` Object { "_tag": "Right", @@ -946,7 +944,6 @@ describe('migration actions', () => { }); }); it('rejects when there is an unexpected error creating the index', async () => { - expect.assertions(1); // Creating an index with the same name as an existing alias to induce // failure await expect( @@ -957,7 +954,6 @@ describe('migration actions', () => { describe('bulkOverwriteTransformedDocuments', () => { it('resolves right when documents do not yet exist in the index', async () => { - expect.assertions(1); const newDocs = ([ { _source: { title: 'doc 5' } }, { _source: { title: 'doc 6' } }, @@ -972,7 +968,6 @@ describe('migration actions', () => { `); }); it('resolves right even if there were some version_conflict_engine_exception', async () => { - expect.assertions(1); const existingDocs = ((await searchForOutdatedDocuments( client, 'existing_index_with_docs', @@ -991,7 +986,6 @@ describe('migration actions', () => { `); }); it('rejects if there are errors', async () => { - expect.assertions(1); const newDocs = ([ { _source: { title: 'doc 5' } }, { _source: { title: 'doc 6' } }, diff --git a/src/core/server/saved_objects/migrationsv2/model.test.ts b/src/core/server/saved_objects/migrationsv2/model.test.ts index 895db80983fc17..5531f847f8bb41 100644 --- a/src/core/server/saved_objects/migrationsv2/model.test.ts +++ b/src/core/server/saved_objects/migrationsv2/model.test.ts @@ -182,6 +182,21 @@ describe('migrations v2 model', () => { versionAlias: '.kibana_7.11.0', versionIndex: '.kibana_7.11.0_001', }; + const mappingsWithUnknownType = { + properties: { + disabled_saved_object_type: { + properties: { + value: { type: 'keyword' }, + }, + }, + }, + _meta: { + migrationMappingPropertyHashes: { + disabled_saved_object_type: '7997cf5a56cc02bdc9c93361bde732b0', + }, + }, + }; + test('INIT -> OUTDATED_DOCUMENTS_SEARCH if .kibana is already pointing to the target index', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana_7.11.0_001': { @@ -189,38 +204,27 @@ describe('migrations v2 model', () => { '.kibana': {}, '.kibana_7.11.0': {}, }, - mappings: { - properties: { - disabled_saved_object_type: { - properties: { - value: { type: 'keyword' }, - }, - }, - }, - _meta: { - migrationMappingPropertyHashes: { - disabled_saved_object_type: '7997cf5a56cc02bdc9c93361bde732b0', - }, - }, - }, + mappings: mappingsWithUnknownType, settings: {}, }, }); const newState = model(initState, res); expect(newState.controlState).toEqual('OUTDATED_DOCUMENTS_SEARCH'); + // This snapshot asserts that we merge the + // migrationMappingPropertyHashes of the existing index, but we leave + // the mappings for the disabled_saved_object_type untouched. There + // might be another Kibana instance that knows about this type and + // needs these mappings in place. expect(newState.targetIndexMappings).toMatchInlineSnapshot(` Object { "_meta": Object { "migrationMappingPropertyHashes": Object { + "disabled_saved_object_type": "7997cf5a56cc02bdc9c93361bde732b0", "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", }, }, "properties": Object { - "disabled_saved_object_type": Object { - "dynamic": false, - "properties": Object {}, - }, "new_saved_object_type": Object { "properties": Object { "value": Object { @@ -271,7 +275,7 @@ describe('migrations v2 model', () => { '.kibana': {}, '.kibana_7.12.0': {}, }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + mappings: mappingsWithUnknownType, settings: {}, }, '.kibana_7.11.0_001': { @@ -288,12 +292,37 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('.kibana_7.invalid.0_001'), targetIndex: '.kibana_7.11.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); }); test('INIT -> SET_SOURCE_WRITE_BLOCK when migrating from a v2 migrations index (>= 7.11.0)', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana_7.11.0_001': { aliases: { '.kibana': {}, '.kibana_7.11.0': {} }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + mappings: mappingsWithUnknownType, settings: {}, }, '.kibana_3': { @@ -319,6 +348,31 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('.kibana_7.11.0_001'), targetIndex: '.kibana_7.12.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -328,7 +382,7 @@ describe('migrations v2 model', () => { aliases: { '.kibana': {}, }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + mappings: mappingsWithUnknownType, settings: {}, }, }); @@ -339,6 +393,31 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('.kibana_3'), targetIndex: '.kibana_7.11.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -346,7 +425,7 @@ describe('migrations v2 model', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana': { aliases: {}, - mappings: { properties: {}, _meta: {} }, + mappings: mappingsWithUnknownType, settings: {}, }, }); @@ -357,6 +436,31 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('.kibana_pre6.5.0_001'), targetIndex: '.kibana_7.11.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -366,7 +470,7 @@ describe('migrations v2 model', () => { aliases: { 'my-saved-objects': {}, }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + mappings: mappingsWithUnknownType, settings: {}, }, }); @@ -386,6 +490,31 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('my-saved-objects_3'), targetIndex: 'my-saved-objects_7.11.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -395,7 +524,7 @@ describe('migrations v2 model', () => { aliases: { 'my-saved-objects': {}, }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + mappings: mappingsWithUnknownType, settings: {}, }, }); @@ -416,6 +545,31 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('my-saved-objects_7.11.0'), targetIndex: 'my-saved-objects_7.12.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); diff --git a/src/core/server/saved_objects/migrationsv2/model.ts b/src/core/server/saved_objects/migrationsv2/model.ts index c9a3aa25db4c15..6f915df9dd9588 100644 --- a/src/core/server/saved_objects/migrationsv2/model.ts +++ b/src/core/server/saved_objects/migrationsv2/model.ts @@ -60,13 +60,13 @@ function throwBadResponse(state: State, res: any): never { * Merge the _meta.migrationMappingPropertyHashes mappings of an index with * the given target mappings. * - * @remarks Mapping updates are commutative (deeply merged) by Elasticsearch, - * except for the _meta key. The source index we're migrating from might - * contain documents created by a plugin that is disabled in the Kibana - * instance performing this migration. We merge the - * _meta.migrationMappingPropertyHashes mappings from the source index into - * the targetMappings to ensure that any `migrationPropertyHashes` for - * disabled plugins aren't lost. + * @remarks When another instance already completed a migration, the existing + * target index might contain documents and mappings created by a plugin that + * is disabled in the current Kibana instance performing this migration. + * Mapping updates are commutative (deeply merged) by Elasticsearch, except + * for the `_meta` key. By merging the `_meta.migrationMappingPropertyHashes` + * mappings from the existing target index index into the targetMappings we + * ensure that any `migrationPropertyHashes` for disabled plugins aren't lost. * * Right now we don't use these `migrationPropertyHashes` but it could be used * in the future to detect if mappings were changed. If mappings weren't @@ -209,7 +209,7 @@ export const model = (currentState: State, resW: ResponseType): // index sourceIndex: Option.none, targetIndex: `${stateP.indexPrefix}_${stateP.kibanaVersion}_001`, - targetIndexMappings: disableUnknownTypeMappingFields( + targetIndexMappings: mergeMigrationMappingPropertyHashes( stateP.targetIndexMappings, indices[aliases[stateP.currentAlias]].mappings ), @@ -242,7 +242,7 @@ export const model = (currentState: State, resW: ResponseType): controlState: 'SET_SOURCE_WRITE_BLOCK', sourceIndex: Option.some(source) as Option.Some, targetIndex: target, - targetIndexMappings: mergeMigrationMappingPropertyHashes( + targetIndexMappings: disableUnknownTypeMappingFields( stateP.targetIndexMappings, indices[source].mappings ), @@ -279,7 +279,7 @@ export const model = (currentState: State, resW: ResponseType): controlState: 'LEGACY_SET_WRITE_BLOCK', sourceIndex: Option.some(legacyReindexTarget) as Option.Some, targetIndex: target, - targetIndexMappings: mergeMigrationMappingPropertyHashes( + targetIndexMappings: disableUnknownTypeMappingFields( stateP.targetIndexMappings, indices[stateP.legacyIndex].mappings ), diff --git a/src/core/server/saved_objects/routes/bulk_create.ts b/src/core/server/saved_objects/routes/bulk_create.ts index 7574f26979ab1e..344a0d151cfb9e 100644 --- a/src/core/server/saved_objects/routes/bulk_create.ts +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -44,7 +45,7 @@ export const registerBulkCreateRoute = (router: IRouter, { coreUsageData }: Rout ), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { overwrite } = req.query; const usageStatsClient = coreUsageData.getClient(); diff --git a/src/core/server/saved_objects/routes/bulk_get.ts b/src/core/server/saved_objects/routes/bulk_get.ts index 2484daf2ea8754..3838e4d3b3c8e7 100644 --- a/src/core/server/saved_objects/routes/bulk_get.ts +++ b/src/core/server/saved_objects/routes/bulk_get.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -28,7 +29,7 @@ export const registerBulkGetRoute = (router: IRouter, { coreUsageData }: RouteDe ), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const usageStatsClient = coreUsageData.getClient(); usageStatsClient.incrementSavedObjectsBulkGet({ request: req }).catch(() => {}); diff --git a/src/core/server/saved_objects/routes/bulk_update.ts b/src/core/server/saved_objects/routes/bulk_update.ts index 1a717f330d4c25..de47ab9c596114 100644 --- a/src/core/server/saved_objects/routes/bulk_update.ts +++ b/src/core/server/saved_objects/routes/bulk_update.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -39,7 +40,7 @@ export const registerBulkUpdateRoute = (router: IRouter, { coreUsageData }: Rout ), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const usageStatsClient = coreUsageData.getClient(); usageStatsClient.incrementSavedObjectsBulkUpdate({ request: req }).catch(() => {}); diff --git a/src/core/server/saved_objects/routes/create.ts b/src/core/server/saved_objects/routes/create.ts index db68b2f87d5772..2fa7acfb6cab62 100644 --- a/src/core/server/saved_objects/routes/create.ts +++ b/src/core/server/saved_objects/routes/create.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -43,7 +44,7 @@ export const registerCreateRoute = (router: IRouter, { coreUsageData }: RouteDep }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { type, id } = req.params; const { overwrite } = req.query; const { diff --git a/src/core/server/saved_objects/routes/delete.ts b/src/core/server/saved_objects/routes/delete.ts index dbbb0faf35c31f..609ce2692c7770 100644 --- a/src/core/server/saved_objects/routes/delete.ts +++ b/src/core/server/saved_objects/routes/delete.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -28,7 +29,7 @@ export const registerDeleteRoute = (router: IRouter, { coreUsageData }: RouteDep }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { type, id } = req.params; const { force } = req.query; diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index 76e422d24732e4..fa5517303f18f2 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -18,7 +18,7 @@ import { SavedObjectsExportByObjectOptions, SavedObjectsExportError, } from '../export'; -import { validateTypes, validateObjects } from './utils'; +import { validateTypes, validateObjects, catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { config: SavedObjectConfig; @@ -163,7 +163,7 @@ export const registerExportRoute = ( }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const cleaned = cleanOptions(req.body); const supportedTypes = context.core.savedObjects.typeRegistry .getImportableAndExportableTypes() diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts index b9ad6ce15df2be..6ba23747cf3745 100644 --- a/src/core/server/saved_objects/routes/find.ts +++ b/src/core/server/saved_objects/routes/find.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -49,7 +50,7 @@ export const registerFindRoute = (router: IRouter, { coreUsageData }: RouteDepen }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const query = req.query; const namespaces = diff --git a/src/core/server/saved_objects/routes/get.ts b/src/core/server/saved_objects/routes/get.ts index 121cb82155b6ec..f28822d95d8142 100644 --- a/src/core/server/saved_objects/routes/get.ts +++ b/src/core/server/saved_objects/routes/get.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -25,7 +26,7 @@ export const registerGetRoute = (router: IRouter, { coreUsageData }: RouteDepend }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { type, id } = req.params; const usageStatsClient = coreUsageData.getClient(); diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index 81220f897f36ba..e84c638d3ec999 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -13,7 +13,7 @@ import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; import { SavedObjectConfig } from '../saved_objects_config'; import { SavedObjectsImportError } from '../import'; -import { createSavedObjectsStreamFromNdJson } from './utils'; +import { catchAndReturnBoomErrors, createSavedObjectsStreamFromNdJson } from './utils'; interface RouteDependencies { config: SavedObjectConfig; @@ -61,7 +61,7 @@ export const registerImportRoute = ( }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { overwrite, createNewCopies } = req.query; const usageStatsClient = coreUsageData.getClient(); diff --git a/src/core/server/saved_objects/routes/migrate.ts b/src/core/server/saved_objects/routes/migrate.ts index 19c6e3d99d6c21..404074124c92b7 100644 --- a/src/core/server/saved_objects/routes/migrate.ts +++ b/src/core/server/saved_objects/routes/migrate.ts @@ -8,6 +8,7 @@ import { IRouter } from '../../http'; import { IKibanaMigrator } from '../migrations'; +import { catchAndReturnBoomErrors } from './utils'; export const registerMigrateRoute = ( router: IRouter, @@ -21,7 +22,7 @@ export const registerMigrateRoute = ( tags: ['access:migrateSavedObjects'], }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const migrator = await migratorPromise; await migrator.runMigrations({ rerun: true }); return res.ok({ diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts index 682b583f6a791f..2a664328d4df29 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -13,8 +13,7 @@ import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; import { SavedObjectConfig } from '../saved_objects_config'; import { SavedObjectsImportError } from '../import'; -import { createSavedObjectsStreamFromNdJson } from './utils'; - +import { catchAndReturnBoomErrors, createSavedObjectsStreamFromNdJson } from './utils'; interface RouteDependencies { config: SavedObjectConfig; coreUsageData: CoreUsageDataSetup; @@ -69,7 +68,7 @@ export const registerResolveImportErrorsRoute = ( }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { createNewCopies } = req.query; const usageStatsClient = coreUsageData.getClient(); diff --git a/src/core/server/saved_objects/routes/update.ts b/src/core/server/saved_objects/routes/update.ts index 857973c5ae0068..cb605dac567772 100644 --- a/src/core/server/saved_objects/routes/update.ts +++ b/src/core/server/saved_objects/routes/update.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -38,7 +39,7 @@ export const registerUpdateRoute = (router: IRouter, { coreUsageData }: RouteDep }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { type, id } = req.params; const { attributes, version, references } = req.body; const options = { version, references }; diff --git a/src/core/server/saved_objects/routes/utils.test.ts b/src/core/server/saved_objects/routes/utils.test.ts index a24a4a1b51f6a5..623d2dcc71faca 100644 --- a/src/core/server/saved_objects/routes/utils.test.ts +++ b/src/core/server/saved_objects/routes/utils.test.ts @@ -9,6 +9,15 @@ import { createSavedObjectsStreamFromNdJson, validateTypes, validateObjects } from './utils'; import { Readable } from 'stream'; import { createPromiseFromStreams, createConcatStream } from '@kbn/utils'; +import { catchAndReturnBoomErrors } from './utils'; +import Boom from '@hapi/boom'; +import { + KibanaRequest, + RequestHandler, + RequestHandlerContext, + KibanaResponseFactory, + kibanaResponseFactory, +} from '../../'; async function readStreamToCompletion(stream: Readable) { return createPromiseFromStreams([stream, createConcatStream([])]); @@ -143,3 +152,69 @@ describe('validateObjects', () => { ).toBeUndefined(); }); }); + +describe('catchAndReturnBoomErrors', () => { + let context: RequestHandlerContext; + let request: KibanaRequest; + let response: KibanaResponseFactory; + + const createHandler = (handler: () => any): RequestHandler => () => { + return handler(); + }; + + beforeEach(() => { + context = {} as any; + request = {} as any; + response = kibanaResponseFactory; + }); + + it('should pass-though call parameters to the handler', async () => { + const handler = jest.fn(); + const wrapped = catchAndReturnBoomErrors(handler); + await wrapped(context, request, response); + expect(handler).toHaveBeenCalledWith(context, request, response); + }); + + it('should pass-though result from the handler', async () => { + const handler = createHandler(() => { + return 'handler-response'; + }); + const wrapped = catchAndReturnBoomErrors(handler); + const result = await wrapped(context, request, response); + expect(result).toBe('handler-response'); + }); + + it('should intercept and convert thrown Boom errors', async () => { + const handler = createHandler(() => { + throw Boom.notFound('not there'); + }); + const wrapped = catchAndReturnBoomErrors(handler); + const result = await wrapped(context, request, response); + expect(result.status).toBe(404); + expect(result.payload).toEqual({ + error: 'Not Found', + message: 'not there', + statusCode: 404, + }); + }); + + it('should re-throw non-Boom errors', async () => { + const handler = createHandler(() => { + throw new Error('something went bad'); + }); + const wrapped = catchAndReturnBoomErrors(handler); + await expect(wrapped(context, request, response)).rejects.toMatchInlineSnapshot( + `[Error: something went bad]` + ); + }); + + it('should re-throw Boom internal/500 errors', async () => { + const handler = createHandler(() => { + throw Boom.internal(); + }); + const wrapped = catchAndReturnBoomErrors(handler); + await expect(wrapped(context, request, response)).rejects.toMatchInlineSnapshot( + `[Error: Internal Server Error]` + ); + }); +}); diff --git a/src/core/server/saved_objects/routes/utils.ts b/src/core/server/saved_objects/routes/utils.ts index fc784ac80ed8d2..e933badfe80feb 100644 --- a/src/core/server/saved_objects/routes/utils.ts +++ b/src/core/server/saved_objects/routes/utils.ts @@ -7,7 +7,11 @@ */ import { Readable } from 'stream'; -import { SavedObject, SavedObjectsExportResultDetails } from 'src/core/server'; +import { + RequestHandlerWrapper, + SavedObject, + SavedObjectsExportResultDetails, +} from 'src/core/server'; import { createSplitStream, createMapStream, @@ -16,6 +20,7 @@ import { createListStream, createConcatStream, } from '@kbn/utils'; +import Boom from '@hapi/boom'; export async function createSavedObjectsStreamFromNdJson(ndJsonStream: Readable) { const savedObjects = await createPromiseFromStreams([ @@ -52,3 +57,30 @@ export function validateObjects( .join(', ')}`; } } + +/** + * Catches errors thrown by saved object route handlers and returns an error + * with the payload and statusCode of the boom error. + * + * This is very close to the core `router.handleLegacyErrors` except that it + * throws internal errors (statusCode: 500) so that the internal error's + * message get logged by Core. + * + * TODO: Remove once https://github.com/elastic/kibana/issues/65291 is fixed. + */ +export const catchAndReturnBoomErrors: RequestHandlerWrapper = (handler) => { + return async (context, request, response) => { + try { + return await handler(context, request, response); + } catch (e) { + if (Boom.isBoom(e) && e.output.statusCode !== 500) { + return response.customError({ + body: e.output.payload, + statusCode: e.output.statusCode, + headers: e.output.headers as { [key: string]: string }, + }); + } + throw e; + } + }; +}; diff --git a/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts b/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts index 717fd5fc5ab929..32f12193306e72 100644 --- a/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts +++ b/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts @@ -109,6 +109,27 @@ describe('savedObjectsClient/decorateEsError', () => { expect(SavedObjectsErrorHelpers.isNotFoundError(genericError)).toBe(true); }); + it('if saved objects index does not exist makes NotFound a SavedObjectsClient/generalError', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 404, + body: { + error: { + reason: + 'no such index [.kibana_8.0.0] and [require_alias] request flag is [true] and [.kibana_8.0.0] is not an alias', + }, + }, + }) + ); + expect(SavedObjectsErrorHelpers.isGeneralError(error)).toBe(false); + const genericError = decorateEsError(error); + expect(genericError.message).toEqual( + `Saved object index alias [.kibana_8.0.0] not found: Response Error` + ); + expect(genericError.output.statusCode).toBe(500); + expect(SavedObjectsErrorHelpers.isGeneralError(error)).toBe(true); + }); + it('makes BadRequest a SavedObjectsClient/BadRequest error', () => { const error = new esErrors.ResponseError( elasticsearchClientMock.createApiResponse({ statusCode: 400 }) diff --git a/src/core/server/saved_objects/service/lib/decorate_es_error.ts b/src/core/server/saved_objects/service/lib/decorate_es_error.ts index 59a9210ff51306..e1aa1ab2f956da 100644 --- a/src/core/server/saved_objects/service/lib/decorate_es_error.ts +++ b/src/core/server/saved_objects/service/lib/decorate_es_error.ts @@ -63,6 +63,12 @@ export function decorateEsError(error: EsErrors) { } if (responseErrors.isNotFound(error.statusCode)) { + const match = error?.meta?.body?.error?.reason?.match( + /no such index \[(.+)\] and \[require_alias\] request flag is \[true\] and \[.+\] is not an alias/ + ); + if (match?.length > 0) { + return SavedObjectsErrorHelpers.decorateIndexAliasNotFoundError(error, match[1]); + } return SavedObjectsErrorHelpers.createGenericNotFoundError(); } diff --git a/src/core/server/saved_objects/service/lib/errors.ts b/src/core/server/saved_objects/service/lib/errors.ts index 2495679a2f8c2d..581145c7c09d1b 100644 --- a/src/core/server/saved_objects/service/lib/errors.ts +++ b/src/core/server/saved_objects/service/lib/errors.ts @@ -135,6 +135,19 @@ export class SavedObjectsErrorHelpers { return decorate(Boom.notFound(), CODE_NOT_FOUND, 404); } + public static createIndexAliasNotFoundError(alias: string) { + return SavedObjectsErrorHelpers.decorateIndexAliasNotFoundError(Boom.internal(), alias); + } + + public static decorateIndexAliasNotFoundError(error: Error, alias: string) { + return decorate( + error, + CODE_GENERAL_ERROR, + 500, + `Saved object index alias [${alias}] not found` + ); + } + public static isNotFoundError(error: Error | DecoratedError) { return isSavedObjectsClientError(error) && error[code] === CODE_NOT_FOUND; } @@ -185,4 +198,8 @@ export class SavedObjectsErrorHelpers { public static decorateGeneralError(error: Error, reason?: string) { return decorate(error, CODE_GENERAL_ERROR, 500, reason); } + + public static isGeneralError(error: Error | DecoratedError) { + return isSavedObjectsClientError(error) && error[code] === CODE_GENERAL_ERROR; + } } diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 0a1c18c01ad82d..aac508fb5b909c 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -18,6 +18,7 @@ import { DocumentMigrator } from '../../migrations/core/document_migrator'; import { mockKibanaMigrator } from '../../migrations/kibana/kibana_migrator.mock'; import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import { esKuery } from '../../es_query'; +import { errors as EsErrors } from '@elastic/elasticsearch'; const { nodeTypes } = esKuery; jest.mock('./search_dsl/search_dsl', () => ({ getSearchDsl: jest.fn() })); @@ -4341,8 +4342,14 @@ describe('SavedObjectsRepository', () => { }); it(`throws when ES is unable to find the document during update`, async () => { + const notFoundError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 404, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); client.update.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + elasticsearchClientMock.createErrorTransportRequestPromise(notFoundError) ); await expectNotFoundError(type, id); expect(client.update).toHaveBeenCalledTimes(1); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index a662a374b063ef..fcd72aa4326a20 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -299,6 +299,7 @@ export class SavedObjectsRepository { refresh, body: raw._source, ...(overwrite && version ? decodeRequestVersion(version) : {}), + require_alias: true, }; const { body } = @@ -469,6 +470,7 @@ export class SavedObjectsRepository { const bulkResponse = bulkCreateParams.length ? await this.client.bulk({ refresh, + require_alias: true, body: bulkCreateParams, }) : undefined; @@ -1117,8 +1119,8 @@ export class SavedObjectsRepository { ...(Array.isArray(references) && { references }), }; - const { body, statusCode } = await this.client.update( - { + const { body } = await this.client + .update({ id: this._serializer.generateRawId(namespace, type, id), index: this.getIndexForType(type), ...getExpectedVersionProperties(version, preflightResult), @@ -1128,14 +1130,15 @@ export class SavedObjectsRepository { doc, }, _source_includes: ['namespace', 'namespaces', 'originId'], - }, - { ignore: [404] } - ); - - if (statusCode === 404) { - // see "404s from missing index" above - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } + require_alias: true, + }) + .catch((err) => { + if (SavedObjectsErrorHelpers.isNotFoundError(err)) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + throw err; + }); const { originId } = body.get._source; let namespaces = []; @@ -1496,6 +1499,7 @@ export class SavedObjectsRepository { refresh, body: bulkUpdateParams, _source_includes: ['originId'], + require_alias: true, }) : undefined; @@ -1712,6 +1716,7 @@ export class SavedObjectsRepository { id: raw._id, index: this.getIndexForType(type), refresh, + require_alias: true, _source: 'true', body: { script: { @@ -1933,12 +1938,18 @@ export class SavedObjectsRepository { } } -function getBulkOperationError(error: { type: string; reason?: string }, type: string, id: string) { +function getBulkOperationError( + error: { type: string; reason?: string; index?: string }, + type: string, + id: string +) { switch (error.type) { case 'version_conflict_engine_exception': return errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)); case 'document_missing_exception': return errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)); + case 'index_not_found_exception': + return errorContent(SavedObjectsErrorHelpers.createIndexAliasNotFoundError(error.index!)); default: return { message: error.reason || JSON.stringify(error), diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 40a12290be31b8..f3191c5625f8d9 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2335,6 +2335,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static createGenericNotFoundError(type?: string | null, id?: string | null): DecoratedError; // (undocumented) + static createIndexAliasNotFoundError(alias: string): DecoratedError; + // (undocumented) static createInvalidVersionError(versionInput?: string): DecoratedError; // (undocumented) static createTooManyRequestsError(type: string, id: string): DecoratedError; @@ -2353,6 +2355,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static decorateGeneralError(error: Error, reason?: string): DecoratedError; // (undocumented) + static decorateIndexAliasNotFoundError(error: Error, alias: string): DecoratedError; + // (undocumented) static decorateNotAuthorizedError(error: Error, reason?: string): DecoratedError; // (undocumented) static decorateRequestEntityTooLargeError(error: Error, reason?: string): DecoratedError; @@ -2369,6 +2373,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static isForbiddenError(error: Error | DecoratedError): boolean; // (undocumented) + static isGeneralError(error: Error | DecoratedError): boolean; + // (undocumented) static isInvalidVersionError(error: Error | DecoratedError): boolean; // (undocumented) static isNotAuthorizedError(error: Error | DecoratedError): boolean; diff --git a/src/core/server/ui_settings/integration_tests/doc_exists.ts b/src/core/server/ui_settings/integration_tests/doc_exists.ts index b02f2ec9c76105..86a9a24fab6de6 100644 --- a/src/core/server/ui_settings/integration_tests/doc_exists.ts +++ b/src/core/server/ui_settings/integration_tests/doc_exists.ts @@ -8,7 +8,7 @@ import { getServices, chance } from './lib'; -export function docExistsSuite() { +export const docExistsSuite = (savedObjectsIndex: string) => () => { async function setup(options: any = {}) { const { initialSettings } = options; @@ -16,7 +16,7 @@ export function docExistsSuite() { // delete the kibana index to ensure we start fresh await callCluster('deleteByQuery', { - index: kbnServer.config.get('kibana.index'), + index: savedObjectsIndex, body: { conflicts: 'proceed', query: { match_all: {} }, @@ -212,4 +212,4 @@ export function docExistsSuite() { }); }); }); -} +}; diff --git a/src/core/server/ui_settings/integration_tests/doc_missing.ts b/src/core/server/ui_settings/integration_tests/doc_missing.ts index ef3b3928e0d9cd..9fa3e4c1cfe78a 100644 --- a/src/core/server/ui_settings/integration_tests/doc_missing.ts +++ b/src/core/server/ui_settings/integration_tests/doc_missing.ts @@ -8,7 +8,7 @@ import { getServices, chance } from './lib'; -export function docMissingSuite() { +export const docMissingSuite = (savedObjectsIndex: string) => () => { // ensure the kibana index has no documents beforeEach(async () => { const { kbnServer, callCluster } = getServices(); @@ -22,7 +22,7 @@ export function docMissingSuite() { // delete all docs from kibana index to ensure savedConfig is not found await callCluster('deleteByQuery', { - index: kbnServer.config.get('kibana.index'), + index: savedObjectsIndex, body: { query: { match_all: {} }, }, @@ -136,4 +136,4 @@ export function docMissingSuite() { }); }); }); -} +}; diff --git a/src/core/server/ui_settings/integration_tests/doc_missing_and_index_read_only.ts b/src/core/server/ui_settings/integration_tests/doc_missing_and_index_read_only.ts index f3a02cfe176e92..78fdab7eb8c5d3 100644 --- a/src/core/server/ui_settings/integration_tests/doc_missing_and_index_read_only.ts +++ b/src/core/server/ui_settings/integration_tests/doc_missing_and_index_read_only.ts @@ -8,7 +8,7 @@ import { getServices, chance } from './lib'; -export function docMissingAndIndexReadOnlySuite() { +export const docMissingAndIndexReadOnlySuite = (savedObjectsIndex: string) => () => { // ensure the kibana index has no documents beforeEach(async () => { const { kbnServer, callCluster } = getServices(); @@ -22,7 +22,7 @@ export function docMissingAndIndexReadOnlySuite() { // delete all docs from kibana index to ensure savedConfig is not found await callCluster('deleteByQuery', { - index: kbnServer.config.get('kibana.index'), + index: savedObjectsIndex, body: { query: { match_all: {} }, }, @@ -30,7 +30,7 @@ export function docMissingAndIndexReadOnlySuite() { // set the index to read only await callCluster('indices.putSettings', { - index: kbnServer.config.get('kibana.index'), + index: savedObjectsIndex, body: { index: { blocks: { @@ -42,11 +42,11 @@ export function docMissingAndIndexReadOnlySuite() { }); afterEach(async () => { - const { kbnServer, callCluster } = getServices(); + const { callCluster } = getServices(); // disable the read only block await callCluster('indices.putSettings', { - index: kbnServer.config.get('kibana.index'), + index: savedObjectsIndex, body: { index: { blocks: { @@ -142,4 +142,4 @@ export function docMissingAndIndexReadOnlySuite() { }); }); }); -} +}; diff --git a/src/core/server/ui_settings/integration_tests/index.test.ts b/src/core/server/ui_settings/integration_tests/index.test.ts index 184c75d88f3b8c..6e6c357e6cccc6 100644 --- a/src/core/server/ui_settings/integration_tests/index.test.ts +++ b/src/core/server/ui_settings/integration_tests/index.test.ts @@ -6,20 +6,25 @@ * Side Public License, v 1. */ +import { Env } from '@kbn/config'; +import { REPO_ROOT } from '@kbn/dev-utils'; +import { getEnvOptions } from '@kbn/config/target/mocks'; import { startServers, stopServers } from './lib'; - import { docExistsSuite } from './doc_exists'; import { docMissingSuite } from './doc_missing'; import { docMissingAndIndexReadOnlySuite } from './doc_missing_and_index_read_only'; +const kibanaVersion = Env.createDefault(REPO_ROOT, getEnvOptions()).packageInfo.version; +const savedObjectIndex = `.kibana_${kibanaVersion}_001`; + describe('uiSettings/routes', function () { jest.setTimeout(10000); beforeAll(startServers); /* eslint-disable jest/valid-describe */ - describe('doc missing', docMissingSuite); - describe('doc missing and index readonly', docMissingAndIndexReadOnlySuite); - describe('doc exists', docExistsSuite); + describe('doc missing', docMissingSuite(savedObjectIndex)); + describe('doc missing and index readonly', docMissingAndIndexReadOnlySuite(savedObjectIndex)); + describe('doc exists', docExistsSuite(savedObjectIndex)); /* eslint-enable jest/valid-describe */ afterAll(stopServers); }); diff --git a/src/core/server/ui_settings/integration_tests/lib/servers.ts b/src/core/server/ui_settings/integration_tests/lib/servers.ts index 1bea45da51af90..87176bed5de114 100644 --- a/src/core/server/ui_settings/integration_tests/lib/servers.ts +++ b/src/core/server/ui_settings/integration_tests/lib/servers.ts @@ -37,9 +37,6 @@ export async function startServers() { adjustTimeout: (t) => jest.setTimeout(t), settings: { kbn: { - migrations: { - enableV2: false, - }, uiSettings: { overrides: { foo: 'bar', diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index cf5589fecdf438..011ba67a055121 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -40,7 +40,7 @@ const DEFAULTS_SETTINGS = { }, logging: { silent: true }, plugins: {}, - migrations: { skip: true }, + migrations: { skip: false }, }; const DEFAULT_SETTINGS_WITH_CORE_PLUGINS = { diff --git a/src/dev/bazel_workspace_status.sh b/src/dev/bazel_workspace_status.sh new file mode 100755 index 00000000000000..efaca4bb98849b --- /dev/null +++ b/src/dev/bazel_workspace_status.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +# Inspired on https://github.com/buildbuddy-io/buildbuddy/blob/master/workspace_status.sh +# This script will be run bazel when building process starts to +# generate key-value information that represents the status of the +# workspace. The output should be like +# +# KEY1 VALUE1 +# KEY2 VALUE2 +# +# If the script exits with non-zero code, it's considered as a failure +# and the output will be discarded. + +# Git repo +repo_url=$(git config --get remote.origin.url) +if [[ $? != 0 ]]; +then + exit 1 +fi +echo "REPO_URL ${repo_url}" + +# Commit SHA +commit_sha=$(git rev-parse HEAD) +if [[ $? != 0 ]]; +then + exit 1 +fi +echo "COMMIT_SHA ${commit_sha}" + +# Git branch +repo_url=$(git rev-parse --abbrev-ref HEAD) +if [[ $? != 0 ]]; +then + exit 1 +fi +echo "GIT_BRANCH ${repo_url}" + +# Tree status +git diff-index --quiet HEAD -- +if [[ $? == 0 ]]; +then + tree_status="Clean" +else + tree_status="Modified" +fi +echo "GIT_TREE_STATUS ${tree_status}" + +# Host +if [ "$CI" = "true" ]; then + host=$(hostname | sed 's|\(.*\)-.*|\1|') + cores=$(grep ^cpu\\scores /proc/cpuinfo | uniq | awk '{print $4}' ) + if [[ $? != 0 ]]; + then + exit 1 + fi + echo "HOST ${host}-${cores}" +fi diff --git a/src/dev/ci_setup/.bazelrc-ci b/src/dev/ci_setup/.bazelrc-ci index 5b345d3c9e2074..ef6fab3a30590a 100644 --- a/src/dev/ci_setup/.bazelrc-ci +++ b/src/dev/ci_setup/.bazelrc-ci @@ -5,6 +5,12 @@ # Import and load bazelrc common settings for ci env try-import %workspace%/src/dev/ci_setup/.bazelrc-ci.common -# Remote cache settings for ci env -# build --google_default_credentials -# build --remote_upload_local_results=true +# BuildBuddy settings +## Remote settings including cache +build --bes_results_url=https://app.buildbuddy.io/invocation/ +build --bes_backend=grpcs://cloud.buildbuddy.io +build --remote_cache=grpcs://cloud.buildbuddy.io +build --remote_timeout=3600 + +## Metadata settings +build --build_metadata=ROLE=CI diff --git a/src/dev/ci_setup/.bazelrc-ci.common b/src/dev/ci_setup/.bazelrc-ci.common index 3f58e4e03a1785..9d00ee5639741f 100644 --- a/src/dev/ci_setup/.bazelrc-ci.common +++ b/src/dev/ci_setup/.bazelrc-ci.common @@ -4,8 +4,5 @@ # Don't be spammy in the logs build --noshow_progress -# Print all the options that apply to the build. -build --announce_rc - # More details on failures build --verbose_failures=true diff --git a/src/dev/ci_setup/load_env_keys.sh b/src/dev/ci_setup/load_env_keys.sh index 62d29db232eae9..5f7a6c26bab21b 100644 --- a/src/dev/ci_setup/load_env_keys.sh +++ b/src/dev/ci_setup/load_env_keys.sh @@ -34,6 +34,9 @@ else PERCY_TOKEN=$(retry 5 vault read -field=value secret/kibana-issues/dev/percy) export PERCY_TOKEN + KIBANA_BUILDBUDDY_CI_API_KEY=$(retry 5 vault read -field=value secret/kibana-issues/dev/kibana-buildbuddy-ci-api-key) + export KIBANA_BUILDBUDDY_CI_API_KEY + # remove vault related secrets unset VAULT_ROLE_ID VAULT_SECRET_ID VAULT_TOKEN VAULT_ADDR fi diff --git a/src/dev/ci_setup/setup.sh b/src/dev/ci_setup/setup.sh index e5e21e312b0ddb..0b24f0b22b81a9 100755 --- a/src/dev/ci_setup/setup.sh +++ b/src/dev/ci_setup/setup.sh @@ -10,6 +10,17 @@ echo " -- PARENT_DIR='$PARENT_DIR'" echo " -- KIBANA_PKG_BRANCH='$KIBANA_PKG_BRANCH'" echo " -- TEST_ES_SNAPSHOT_VERSION='$TEST_ES_SNAPSHOT_VERSION'" +### +### copy .bazelrc-ci into $HOME/.bazelrc +### +cp "src/dev/ci_setup/.bazelrc-ci" "$HOME/.bazelrc"; + +### +### append auth token to buildbuddy into "$HOME/.bazelrc"; +### +echo "# Appended by src/dev/ci_setup/setup.sh" >> "$HOME/.bazelrc" +echo "build --remote_header=x-buildbuddy-api-key=$KIBANA_BUILDBUDDY_CI_API_KEY" >> "$HOME/.bazelrc" + ### ### install dependencies ### @@ -65,8 +76,3 @@ if [ "$GIT_CHANGES" ]; then echo -e "$GIT_CHANGES\n" exit 1 fi - -### -### copy .bazelrc-ci into $HOME/.bazelrc -### -cp "src/dev/ci_setup/.bazelrc-ci" "$HOME/.bazelrc"; diff --git a/src/dev/ci_setup/setup_env.sh b/src/dev/ci_setup/setup_env.sh index 5dac270239c4af..0b835d4b9fa947 100644 --- a/src/dev/ci_setup/setup_env.sh +++ b/src/dev/ci_setup/setup_env.sh @@ -175,4 +175,15 @@ if [[ -d "$ES_DIR" && -f "$ES_JAVA_PROP_PATH" ]]; then export JAVA_HOME=$HOME/.java/$ES_BUILD_JAVA fi +### +### copy .bazelrc-ci into $HOME/.bazelrc +### +cp -f "$KIBANA_DIR/src/dev/ci_setup/.bazelrc-ci" "$HOME/.bazelrc"; + +### +### append auth token to buildbuddy into "$HOME/.bazelrc"; +### +echo "# Appended by $KIBANA_DIR/src/dev/ci_setup/setup.sh" >> "$HOME/.bazelrc" +echo "build --remote_header=x-buildbuddy-api-key=$KIBANA_BUILDBUDDY_CI_API_KEY" >> "$HOME/.bazelrc" + export CI_ENV_SETUP=true diff --git a/src/plugins/dashboard/.storybook/main.js b/src/plugins/dashboard/.storybook/main.js index 86b48c32f103e3..8dc3c5d1518f4d 100644 --- a/src/plugins/dashboard/.storybook/main.js +++ b/src/plugins/dashboard/.storybook/main.js @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ module.exports = require('@kbn/storybook').defaultConfig; diff --git a/src/plugins/dashboard/.storybook/storyshots.test.tsx b/src/plugins/dashboard/.storybook/storyshots.test.tsx index a75a9d178f0dd3..80e8aa795ed400 100644 --- a/src/plugins/dashboard/.storybook/storyshots.test.tsx +++ b/src/plugins/dashboard/.storybook/storyshots.test.tsx @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import fs from 'fs'; diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_container.test.tsx b/src/plugins/dashboard/public/application/hooks/use_dashboard_container.test.tsx new file mode 100644 index 00000000000000..d14b4056a64c67 --- /dev/null +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_container.test.tsx @@ -0,0 +1,173 @@ +/* + * 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 { useDashboardContainer } from './use_dashboard_container'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { KibanaContextProvider } from '../../../../kibana_react/public'; +import React from 'react'; +import { DashboardStateManager } from '../dashboard_state_manager'; +import { getSavedDashboardMock } from '../test_helpers'; +import { createKbnUrlStateStorage, defer } from '../../../../kibana_utils/public'; +import { createBrowserHistory } from 'history'; +import { dataPluginMock } from '../../../../data/public/mocks'; +import { embeddablePluginMock } from '../../../../embeddable/public/mocks'; +import { DashboardCapabilities } from '../types'; +import { EmbeddableFactory } from '../../../../embeddable/public'; +import { HelloWorldEmbeddable } from '../../../../embeddable/public/tests/fixtures'; +import { DashboardContainer } from '../embeddable'; + +const savedDashboard = getSavedDashboardMock(); + +// TS is *very* picky with type guards / predicates. can't just use jest.fn() +function mockHasTaggingCapabilities(obj: any): obj is any { + return false; +} + +const history = createBrowserHistory(); +const createDashboardState = () => + new DashboardStateManager({ + savedDashboard, + hideWriteControls: false, + allowByValueEmbeddables: false, + kibanaVersion: '7.0.0', + kbnUrlStateStorage: createKbnUrlStateStorage(), + history: createBrowserHistory(), + hasTaggingCapabilities: mockHasTaggingCapabilities, + }); + +const defaultCapabilities: DashboardCapabilities = { + show: false, + createNew: false, + saveQuery: false, + createShortUrl: false, + hideWriteControls: true, + mapsCapabilities: { save: false }, + visualizeCapabilities: { save: false }, + storeSearchSession: true, +}; + +const services = { + dashboardCapabilities: defaultCapabilities, + data: dataPluginMock.createStartContract(), + embeddable: embeddablePluginMock.createStartContract(), + scopedHistory: history, +}; + +const setupEmbeddableFactory = () => { + const embeddable = new HelloWorldEmbeddable({ id: 'id' }); + const deferEmbeddableCreate = defer(); + services.embeddable.getEmbeddableFactory.mockImplementation( + () => + (({ + create: () => deferEmbeddableCreate.promise, + } as unknown) as EmbeddableFactory) + ); + const destroySpy = jest.spyOn(embeddable, 'destroy'); + + return { + destroySpy, + embeddable, + createEmbeddable: () => { + act(() => { + deferEmbeddableCreate.resolve(embeddable); + }); + }, + }; +}; + +test('container is destroyed on unmount', async () => { + const { createEmbeddable, destroySpy, embeddable } = setupEmbeddableFactory(); + + const state = createDashboardState(); + const { result, unmount, waitForNextUpdate } = renderHook( + () => useDashboardContainer(state, history, false), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + expect(result.current).toBeNull(); // null on initial render + + createEmbeddable(); + + await waitForNextUpdate(); + + expect(embeddable).toBe(result.current); + expect(destroySpy).not.toBeCalled(); + + unmount(); + + expect(destroySpy).toBeCalled(); +}); + +test('old container is destroyed on new dashboardStateManager', async () => { + const embeddableFactoryOld = setupEmbeddableFactory(); + + const { result, waitForNextUpdate, rerender } = renderHook< + DashboardStateManager, + DashboardContainer | null + >((dashboardState) => useDashboardContainer(dashboardState, history, false), { + wrapper: ({ children }) => ( + {children} + ), + initialProps: createDashboardState(), + }); + + expect(result.current).toBeNull(); // null on initial render + + embeddableFactoryOld.createEmbeddable(); + + await waitForNextUpdate(); + + expect(embeddableFactoryOld.embeddable).toBe(result.current); + expect(embeddableFactoryOld.destroySpy).not.toBeCalled(); + + const embeddableFactoryNew = setupEmbeddableFactory(); + rerender(createDashboardState()); + + embeddableFactoryNew.createEmbeddable(); + + await waitForNextUpdate(); + + expect(embeddableFactoryNew.embeddable).toBe(result.current); + + expect(embeddableFactoryNew.destroySpy).not.toBeCalled(); + expect(embeddableFactoryOld.destroySpy).toBeCalled(); +}); + +test('destroyed if rerendered before resolved', async () => { + const embeddableFactoryOld = setupEmbeddableFactory(); + + const { result, waitForNextUpdate, rerender } = renderHook< + DashboardStateManager, + DashboardContainer | null + >((dashboardState) => useDashboardContainer(dashboardState, history, false), { + wrapper: ({ children }) => ( + {children} + ), + initialProps: createDashboardState(), + }); + + expect(result.current).toBeNull(); // null on initial render + + const embeddableFactoryNew = setupEmbeddableFactory(); + rerender(createDashboardState()); + embeddableFactoryNew.createEmbeddable(); + await waitForNextUpdate(); + expect(embeddableFactoryNew.embeddable).toBe(result.current); + expect(embeddableFactoryNew.destroySpy).not.toBeCalled(); + + embeddableFactoryOld.createEmbeddable(); + + await act(() => Promise.resolve()); // Can't use waitFor from hooks, because there is no hook update + expect(embeddableFactoryNew.embeddable).toBe(result.current); + expect(embeddableFactoryNew.destroySpy).not.toBeCalled(); + expect(embeddableFactoryOld.destroySpy).toBeCalled(); +}); diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts index a3a31ee52836fd..b27322b6bec534 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts @@ -7,7 +7,6 @@ */ import { useEffect, useState } from 'react'; -import _ from 'lodash'; import { History } from 'history'; import { useKibana } from '../../services/kibana_react'; @@ -15,6 +14,7 @@ import { ContainerOutput, EmbeddableFactoryNotFoundError, EmbeddableInput, + ErrorEmbeddable, isErrorEmbeddable, ViewMode, } from '../../services/embeddable'; @@ -70,8 +70,10 @@ export const useDashboardContainer = ( const incomingEmbeddable = embeddable.getStateTransfer().getIncomingEmbeddablePackage(true); + let canceled = false; + let pendingContainer: DashboardContainer | ErrorEmbeddable | null | undefined; (async function createContainer() { - const newContainer = await dashboardFactory.create( + pendingContainer = await dashboardFactory.create( getDashboardContainerInput({ dashboardCapabilities, dashboardStateManager, @@ -82,12 +84,27 @@ export const useDashboardContainer = ( }) ); - if (!newContainer || isErrorEmbeddable(newContainer)) { + // already new container is being created + // no longer interested in the pending one + if (canceled) { + try { + pendingContainer?.destroy(); + pendingContainer = null; + } catch (e) { + // destroy could throw if something has already destroyed the container + // eslint-disable-next-line no-console + console.warn(e); + } + + return; + } + + if (!pendingContainer || isErrorEmbeddable(pendingContainer)) { return; } // inject switch view mode callback for the empty screen to use - newContainer.switchViewMode = (newViewMode: ViewMode) => + pendingContainer.switchViewMode = (newViewMode: ViewMode) => dashboardStateManager.switchViewMode(newViewMode); // If the incoming embeddable is newly created, or doesn't exist in the current panels list, @@ -96,17 +113,28 @@ export const useDashboardContainer = ( incomingEmbeddable && (!incomingEmbeddable?.embeddableId || (incomingEmbeddable.embeddableId && - !newContainer.getInput().panels[incomingEmbeddable.embeddableId])) + !pendingContainer.getInput().panels[incomingEmbeddable.embeddableId])) ) { dashboardStateManager.switchViewMode(ViewMode.EDIT); - newContainer.addNewEmbeddable( + pendingContainer.addNewEmbeddable( incomingEmbeddable.type, incomingEmbeddable.input ); } - setDashboardContainer(newContainer); + setDashboardContainer(pendingContainer); })(); - return () => setDashboardContainer(null); + return () => { + canceled = true; + try { + pendingContainer?.destroy(); + } catch (e) { + // destroy could throw if something has already destroyed the container + // eslint-disable-next-line no-console + console.warn(e); + } + + setDashboardContainer(null); + }; }, [ dashboardCapabilities, dashboardStateManager, diff --git a/src/plugins/data/server/index_patterns/fetcher/index_patterns_fetcher.test.ts b/src/plugins/data/server/index_patterns/fetcher/index_patterns_fetcher.test.ts new file mode 100644 index 00000000000000..ffdd47e5cdf490 --- /dev/null +++ b/src/plugins/data/server/index_patterns/fetcher/index_patterns_fetcher.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { IndexPatternsFetcher } from '.'; +import { ElasticsearchClient } from 'kibana/server'; +import * as indexNotFoundException from '../../../common/search/test_data/index_not_found_exception.json'; + +describe('Index Pattern Fetcher - server', () => { + let indexPatterns: IndexPatternsFetcher; + let esClient: ElasticsearchClient; + const emptyResponse = { + body: { + count: 0, + }, + }; + const response = { + body: { + count: 1115, + }, + }; + const patternList = ['a', 'b', 'c']; + beforeEach(() => { + esClient = ({ + count: jest.fn().mockResolvedValueOnce(emptyResponse).mockResolvedValue(response), + } as unknown) as ElasticsearchClient; + indexPatterns = new IndexPatternsFetcher(esClient); + }); + + it('Removes pattern without matching indices', async () => { + const result = await indexPatterns.validatePatternListActive(patternList); + expect(result).toEqual(['b', 'c']); + }); + + it('Returns all patterns when all match indices', async () => { + esClient = ({ + count: jest.fn().mockResolvedValue(response), + } as unknown) as ElasticsearchClient; + indexPatterns = new IndexPatternsFetcher(esClient); + const result = await indexPatterns.validatePatternListActive(patternList); + expect(result).toEqual(patternList); + }); + it('Removes pattern when "index_not_found_exception" error is thrown', async () => { + class ServerError extends Error { + public body?: Record; + constructor( + message: string, + public readonly statusCode: number, + errBody?: Record + ) { + super(message); + this.body = errBody; + } + } + + esClient = ({ + count: jest + .fn() + .mockResolvedValueOnce(response) + .mockRejectedValue( + new ServerError('index_not_found_exception', 404, indexNotFoundException) + ), + } as unknown) as ElasticsearchClient; + indexPatterns = new IndexPatternsFetcher(esClient); + const result = await indexPatterns.validatePatternListActive(patternList); + expect(result).toEqual([patternList[0]]); + }); +}); diff --git a/src/plugins/data/server/index_patterns/fetcher/index_patterns_fetcher.ts b/src/plugins/data/server/index_patterns/fetcher/index_patterns_fetcher.ts index cc8bfe28bbc9a7..3acdde33f599e0 100644 --- a/src/plugins/data/server/index_patterns/fetcher/index_patterns_fetcher.ts +++ b/src/plugins/data/server/index_patterns/fetcher/index_patterns_fetcher.ts @@ -58,9 +58,16 @@ export class IndexPatternsFetcher { rollupIndex?: string; }): Promise { const { pattern, metaFields, fieldCapsOptions, type, rollupIndex } = options; + const patternList = Array.isArray(pattern) ? pattern : pattern.split(','); + let patternListActive: string[] = patternList; + // if only one pattern, don't bother with validation. We let getFieldCapabilities fail if the single pattern is bad regardless + if (patternList.length > 1) { + patternListActive = await this.validatePatternListActive(patternList); + } const fieldCapsResponse = await getFieldCapabilities( this.elasticsearchClient, - pattern, + // if none of the patterns are active, pass the original list to get an error + patternListActive.length > 0 ? patternListActive : patternList, metaFields, { allow_no_indices: fieldCapsOptions @@ -68,6 +75,7 @@ export class IndexPatternsFetcher { : this.allowNoIndices, } ); + if (type === 'rollup' && rollupIndex) { const rollupFields: FieldDescriptor[] = []; const rollupIndexCapabilities = getCapabilitiesForRollupIndices( @@ -118,4 +126,34 @@ export class IndexPatternsFetcher { } return await getFieldCapabilities(this.elasticsearchClient, indices, metaFields); } + + /** + * Returns an index pattern list of only those index pattern strings in the given list that return indices + * + * @param patternList string[] + * @return {Promise} + */ + async validatePatternListActive(patternList: string[]) { + const result = await Promise.all( + patternList + .map((pattern) => + this.elasticsearchClient.count({ + index: pattern, + }) + ) + .map((p) => + p.catch((e) => { + if (e.body.error.type === 'index_not_found_exception') { + return { body: { count: 0 } }; + } + throw e; + }) + ) + ); + return result.reduce( + (acc: string[], { body: { count } }, patternListIndex) => + count > 0 ? [...acc, patternList[patternListIndex]] : acc, + [] + ); + } } diff --git a/src/plugins/data/server/search/mocks.ts b/src/plugins/data/server/search/mocks.ts index e32e3326dede06..248487f216a569 100644 --- a/src/plugins/data/server/search/mocks.ts +++ b/src/plugins/data/server/search/mocks.ts @@ -40,5 +40,6 @@ export function createSearchRequestHandlerContext() { updateSession: jest.fn(), extendSession: jest.fn(), cancelSession: jest.fn(), + deleteSession: jest.fn(), }; } diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index 91d9bd6e0d2849..ce0771a1e9df89 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -307,9 +307,8 @@ export class SearchService implements Plugin { return strategy.extend(id, keepAlive, options, deps); }; - private cancelSession = async (deps: SearchStrategyDependencies, sessionId: string) => { + private cancelSessionSearches = async (deps: SearchStrategyDependencies, sessionId: string) => { const searchIdMapping = await deps.searchSessionsClient.getSearchIdMapping(sessionId); - const response = await deps.searchSessionsClient.cancel(sessionId); for (const [searchId, strategyName] of searchIdMapping.entries()) { const searchOptions = { @@ -319,10 +318,19 @@ export class SearchService implements Plugin { }; this.cancel(deps, searchId, searchOptions); } + }; + private cancelSession = async (deps: SearchStrategyDependencies, sessionId: string) => { + const response = await deps.searchSessionsClient.cancel(sessionId); + this.cancelSessionSearches(deps, sessionId); return response; }; + private deleteSession = async (deps: SearchStrategyDependencies, sessionId: string) => { + this.cancelSessionSearches(deps, sessionId); + return deps.searchSessionsClient.delete(sessionId); + }; + private extendSession = async ( deps: SearchStrategyDependencies, sessionId: string, @@ -372,6 +380,7 @@ export class SearchService implements Plugin { updateSession: searchSessionsClient.update, extendSession: this.extendSession.bind(this, deps), cancelSession: this.cancelSession.bind(this, deps), + deleteSession: this.deleteSession.bind(this, deps), }; }; }; diff --git a/src/plugins/data/server/search/session/mocks.ts b/src/plugins/data/server/search/session/mocks.ts index 5e940412d9abd4..c173e1a1290eae 100644 --- a/src/plugins/data/server/search/session/mocks.ts +++ b/src/plugins/data/server/search/session/mocks.ts @@ -21,5 +21,6 @@ export function createSearchSessionsClientMock(): jest.Mocked< update: jest.fn(), cancel: jest.fn(), extend: jest.fn(), + delete: jest.fn(), }; } diff --git a/src/plugins/data/server/search/session/session_service.ts b/src/plugins/data/server/search/session/session_service.ts index 2ca580f50db0a5..2ed44b4e57d941 100644 --- a/src/plugins/data/server/search/session/session_service.ts +++ b/src/plugins/data/server/search/session/session_service.ts @@ -41,6 +41,9 @@ export class SearchSessionService implements ISearchSessionService { cancel: async () => { throw new Error('cancel not implemented in OSS search session service'); }, + delete: async () => { + throw new Error('delete not implemented in OSS search session service'); + }, }); } } diff --git a/src/plugins/data/server/search/session/types.ts b/src/plugins/data/server/search/session/types.ts index 16079b51f4bff2..816716360415d1 100644 --- a/src/plugins/data/server/search/session/types.ts +++ b/src/plugins/data/server/search/session/types.ts @@ -29,6 +29,7 @@ export interface IScopedSearchSessionsClient { find: (options: Omit) => Promise>; update: (sessionId: string, attributes: Partial) => Promise>; cancel: (sessionId: string) => Promise<{}>; + delete: (sessionId: string) => Promise<{}>; extend: (sessionId: string, expires: Date) => Promise>; } diff --git a/src/plugins/data/server/search/types.ts b/src/plugins/data/server/search/types.ts index 854f5ed94eb482..e8548257c01679 100644 --- a/src/plugins/data/server/search/types.ts +++ b/src/plugins/data/server/search/types.ts @@ -92,6 +92,7 @@ export interface IScopedSearchClient extends ISearchClient { findSessions: IScopedSearchSessionsClient['find']; updateSession: IScopedSearchSessionsClient['update']; cancelSession: IScopedSearchSessionsClient['cancel']; + deleteSession: IScopedSearchSessionsClient['delete']; extendSession: IScopedSearchSessionsClient['extend']; } diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index beda5b3cea97e1..3b1440f211bfed 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -885,6 +885,7 @@ export class IndexPatternsFetcher { type?: string; rollupIndex?: string; }): Promise; + validatePatternListActive(patternList: string[]): Promise; } // Warning: (ae-forgotten-export) The symbol "IndexPatternsServiceStart" needs to be exported by the entry point index.d.ts @@ -1254,6 +1255,7 @@ export class SearchSessionService implements ISearchSessionService { update: () => Promise; extend: () => Promise; cancel: () => Promise; + delete: () => Promise; }; } @@ -1430,7 +1432,7 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // src/plugins/data/server/index.ts:266:1 - (ae-forgotten-export) The symbol "calcAutoIntervalLessThan" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index_patterns/index_patterns_service.ts:59:14 - (ae-forgotten-export) The symbol "IndexPatternsService" needs to be exported by the entry point index.d.ts // src/plugins/data/server/plugin.ts:79:74 - (ae-forgotten-export) The symbol "DataEnhancements" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/search/types.ts:113:5 - (ae-forgotten-export) The symbol "ISearchStartSearchSource" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/search/types.ts:114:5 - (ae-forgotten-export) The symbol "ISearchStartSearchSource" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 13ff8b14d9b432..b22bb6dc713422 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -56,7 +56,6 @@ import { SORT_DEFAULT_ORDER_SETTING, } from '../../../common'; import { loadIndexPattern, resolveIndexPattern } from '../helpers/resolve_index_pattern'; -import { getTopNavLinks } from '../components/top_nav/get_top_nav_links'; import { updateSearchSource } from '../helpers/update_search_source'; import { calcFieldCounts } from '../helpers/calc_field_counts'; import { getDefaultSort } from './doc_table/lib/get_default_sort'; @@ -198,7 +197,7 @@ function discoverController($route, $scope, Promise) { session: data.search.session, }); - const state = getState({ + const stateContainer = getState({ getStateDefaults, storeInSessionStorage: config.get('state:storeInSessionStorage'), history, @@ -213,7 +212,7 @@ function discoverController($route, $scope, Promise) { replaceUrlAppState, kbnUrlStateStorage, getPreviousAppState, - } = state; + } = stateContainer; if (appStateContainer.getState().index !== $scope.indexPattern.id) { //used index pattern is different than the given by url/state which is invalid @@ -323,10 +322,24 @@ function discoverController($route, $scope, Promise) { ) ); - const inspectorAdapters = { - requests: new RequestAdapter(), + $scope.opts = { + // number of records to fetch, then paginate through + sampleSize: config.get(SAMPLE_SIZE_SETTING), + timefield: getTimeField(), + savedSearch: savedSearch, + indexPatternList: $route.current.locals.savedObjects.ip.list, + config: config, + setHeaderActionMenu: getHeaderActionMenuMounter(), + filterManager, + setAppState, + data, + stateContainer, }; + const inspectorAdapters = ($scope.opts.inspectorAdapters = { + requests: new RequestAdapter(), + }); + $scope.timefilterUpdateHandler = (ranges) => { timefilter.setTime({ from: moment(ranges.from).toISOString(), @@ -358,7 +371,7 @@ function discoverController($route, $scope, Promise) { unlistenHistoryBasePath(); }); - const getFieldCounts = async () => { + $scope.opts.getFieldCounts = async () => { // the field counts aren't set until we have the data back, // so we wait for the fetch to be done before proceeding if ($scope.fetchStatus === fetchStatuses.COMPLETE) { @@ -374,20 +387,11 @@ function discoverController($route, $scope, Promise) { }); }); }; - - $scope.topNavMenu = getTopNavLinks({ - getFieldCounts, - indexPattern: $scope.indexPattern, - inspectorAdapters, - navigateTo: (path) => { - $scope.$evalAsync(() => { - history.push(path); - }); - }, - savedSearch, - services, - state, - }); + $scope.opts.navigateTo = (path) => { + $scope.$evalAsync(() => { + history.push(path); + }); + }; $scope.searchSource .setField('index', $scope.indexPattern) @@ -446,19 +450,6 @@ function discoverController($route, $scope, Promise) { $scope.state.index = $scope.indexPattern.id; $scope.state.sort = getSortArray($scope.state.sort, $scope.indexPattern); - $scope.opts = { - // number of records to fetch, then paginate through - sampleSize: config.get(SAMPLE_SIZE_SETTING), - timefield: getTimeField(), - savedSearch: savedSearch, - indexPatternList: $route.current.locals.savedObjects.ip.list, - config: config, - setHeaderActionMenu: getHeaderActionMenuMounter(), - filterManager, - setAppState, - data, - }; - const shouldSearchOnPageLoad = () => { // A saved search is created on every page load, so we check the ID to see if we're loading a // previously saved search or if it is just transient diff --git a/src/plugins/discover/public/application/components/discover.test.tsx b/src/plugins/discover/public/application/components/discover.test.tsx index 720b79f53a5519..bb0014f4278a11 100644 --- a/src/plugins/discover/public/application/components/discover.test.tsx +++ b/src/plugins/discover/public/application/components/discover.test.tsx @@ -9,11 +9,8 @@ import React from 'react'; import { shallowWithIntl } from '@kbn/test/jest'; import { Discover } from './discover'; -import { inspectorPluginMock } from '../../../../inspector/public/mocks'; import { esHits } from '../../__mocks__/es_hits'; import { indexPatternMock } from '../../__mocks__/index_pattern'; -import { getTopNavLinks } from './top_nav/get_top_nav_links'; -import { DiscoverServices } from '../../build_services'; import { GetStateReturn } from '../angular/discover_state'; import { savedSearchMock } from '../../__mocks__/saved_search'; import { createSearchSourceMock } from '../../../../data/common/search/search_source/mocks'; @@ -25,6 +22,8 @@ import { SavedObject } from '../../../../../core/types'; import { navigationPluginMock } from '../../../../navigation/public/mocks'; import { indexPatternWithTimefieldMock } from '../../__mocks__/index_pattern_with_timefield'; import { calcFieldCounts } from '../helpers/calc_field_counts'; +import { DiscoverProps } from './types'; +import { RequestAdapter } from '../../../../inspector/common'; const mockNavigation = navigationPluginMock.createStartContract(); @@ -45,17 +44,9 @@ jest.mock('../../kibana_services', () => { }; }); -function getProps(indexPattern: IndexPattern) { +function getProps(indexPattern: IndexPattern): DiscoverProps { const searchSourceMock = createSearchSourceMock({}); const state = ({} as unknown) as GetStateReturn; - const services = ({ - capabilities: { - discover: { - save: true, - }, - }, - uiSettings: mockUiSettings, - } as unknown) as DiscoverServices; return { fetch: jest.fn(), @@ -76,32 +67,25 @@ function getProps(indexPattern: IndexPattern) { opts: { config: mockUiSettings, data: dataPluginMock.createStartContract(), - fixedScroll: jest.fn(), filterManager: createFilterManagerMock(), + getFieldCounts: jest.fn(), indexPatternList: (indexPattern as unknown) as Array>, + inspectorAdapters: { requests: {} as RequestAdapter }, + navigateTo: jest.fn(), sampleSize: 10, savedSearch: savedSearchMock, + setAppState: jest.fn(), setHeaderActionMenu: jest.fn(), + stateContainer: state, timefield: indexPattern.timeFieldName || '', - setAppState: jest.fn(), }, resetQuery: jest.fn(), resultState: 'ready', rows: esHits, searchSource: searchSourceMock, setIndexPattern: jest.fn(), - showSaveQuery: true, state: { columns: [] }, timefilterUpdateHandler: jest.fn(), - topNavMenu: getTopNavLinks({ - getFieldCounts: jest.fn(), - indexPattern, - inspectorAdapters: inspectorPluginMock, - navigateTo: jest.fn(), - savedSearch: savedSearchMock, - services, - state, - }), updateQuery: jest.fn(), updateSavedQueryId: jest.fn(), }; diff --git a/src/plugins/discover/public/application/components/discover.tsx b/src/plugins/discover/public/application/components/discover.tsx index e6c4524f81f564..baee0623f0b5ae 100644 --- a/src/plugins/discover/public/application/components/discover.tsx +++ b/src/plugins/discover/public/application/components/discover.tsx @@ -41,6 +41,8 @@ import { getDisplayedColumns } from '../helpers/columns'; import { SortPairArr } from '../angular/doc_table/lib/get_sort'; import { DiscoverGrid, DiscoverGridProps } from './discover_grid/discover_grid'; import { SEARCH_FIELDS_FROM_SOURCE } from '../../../common'; +import { ElasticSearchHit } from '../doc_views/doc_views_types'; +import { getTopNavLinks } from './top_nav/get_top_nav_links'; const DocTableLegacyMemoized = React.memo((props: DocTableLegacyProps) => ( @@ -77,11 +79,11 @@ export function Discover({ state, timefilterUpdateHandler, timeRange, - topNavMenu, updateQuery, updateSavedQueryId, unmappedFieldsConfig, }: DiscoverProps) { + const [expandedDoc, setExpandedDoc] = useState(undefined); const scrollableDesktop = useRef(null); const collapseIcon = useRef(null); const isMobile = () => { @@ -91,7 +93,24 @@ export function Discover({ const [toggleOn, toggleChart] = useState(true); const [isSidebarClosed, setIsSidebarClosed] = useState(false); - const services = getServices(); + const services = useMemo(() => getServices(), []); + const topNavMenu = useMemo( + () => + getTopNavLinks({ + getFieldCounts: opts.getFieldCounts, + indexPattern, + inspectorAdapters: opts.inspectorAdapters, + navigateTo: opts.navigateTo, + savedSearch: opts.savedSearch, + services, + state: opts.stateContainer, + onOpenInspector: () => { + // prevent overlapping + setExpandedDoc(undefined); + }, + }), + [indexPattern, opts, services] + ); const { TopNavMenu } = services.navigation.ui; const { trackUiMetric } = services; const { savedSearch, indexPatternList, config } = opts; @@ -318,12 +337,14 @@ export function Discover({ void; /** * Grid display settings persisted in Elasticsearch (e.g. column width) */ @@ -121,6 +129,7 @@ export const DiscoverGrid = ({ ariaLabelledBy, columns, indexPattern, + expandedDoc, onAddColumn, onFilter, onRemoveColumn, @@ -132,11 +141,11 @@ export const DiscoverGrid = ({ searchDescription, searchTitle, services, + setExpandedDoc, settings, showTimeCol, sort, }: DiscoverGridProps) => { - const [expanded, setExpanded] = useState(undefined); const defaultColumns = columns.includes('_source'); /** @@ -233,8 +242,8 @@ export const DiscoverGrid = ({ return ( )} - {expanded && ( + {expandedDoc && ( setExpanded(undefined)} + onClose={() => setExpandedDoc(undefined)} services={services} /> )} diff --git a/src/plugins/discover/public/application/components/table/table.tsx b/src/plugins/discover/public/application/components/table/table.tsx index 6ead2aff674524..684a7d4fd467c3 100644 --- a/src/plugins/discover/public/application/components/table/table.tsx +++ b/src/plugins/discover/public/application/components/table/table.tsx @@ -101,9 +101,8 @@ export function DocViewTable({ ? 'nested' : indexPattern.fields.getByName(field)?.type; return ( - + { indexPattern: indexPatternMock, inspectorAdapters: inspectorPluginMock, navigateTo: jest.fn(), + onOpenInspector: jest.fn(), savedSearch: savedSearchMock, services, state, diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts index 0b23c31ac03c40..513508c478aa95 100644 --- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts @@ -28,6 +28,7 @@ export const getTopNavLinks = ({ savedSearch, services, state, + onOpenInspector, }: { getFieldCounts: () => Promise>; indexPattern: IndexPattern; @@ -36,6 +37,7 @@ export const getTopNavLinks = ({ savedSearch: SavedSearch; services: DiscoverServices; state: GetStateReturn; + onOpenInspector: () => void; }) => { const newSearch = { id: 'new', @@ -123,6 +125,7 @@ export const getTopNavLinks = ({ }), testId: 'openInspectorButton', run: () => { + onOpenInspector(); services.inspector.open(inspectorAdapters, { title: savedSearch.title, }); diff --git a/src/plugins/discover/public/application/components/types.ts b/src/plugins/discover/public/application/components/types.ts index abc8086e72712b..b73f7391bf22a4 100644 --- a/src/plugins/discover/public/application/components/types.ts +++ b/src/plugins/discover/public/application/components/types.ts @@ -21,8 +21,8 @@ import { TimeRange, } from '../../../../data/public'; import { SavedSearch } from '../../saved_searches'; -import { AppState } from '../angular/discover_state'; -import { TopNavMenuData } from '../../../../navigation/public'; +import { AppState, GetStateReturn } from '../angular/discover_state'; +import { RequestAdapter } from '../../../../inspector/common'; export interface DiscoverProps { /** @@ -100,6 +100,22 @@ export interface DiscoverProps { * Client of uiSettings */ config: IUiSettingsClient; + /** + * returns field statistics based on the loaded data sample + */ + getFieldCounts: () => Promise>; + /** + * Use angular router for navigation + */ + navigateTo: () => void; + /** + * Functions to get/mutate state + */ + stateContainer: GetStateReturn; + /** + * Inspect, for analyzing requests and responses + */ + inspectorAdapters: { requests: RequestAdapter }; /** * Data plugin */ @@ -165,10 +181,6 @@ export interface DiscoverProps { * Currently selected time range */ timeRange?: { from: string; to: string }; - /** - * Menu data of top navigation (New, save ...) - */ - topNavMenu: TopNavMenuData[]; /** * Function to update the actual query */ diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable.ts b/src/plugins/discover/public/application/embeddable/search_embeddable.ts index d04d482c7aade5..658734aa46cb02 100644 --- a/src/plugins/discover/public/application/embeddable/search_embeddable.ts +++ b/src/plugins/discover/public/application/embeddable/search_embeddable.ts @@ -308,9 +308,9 @@ export class SearchEmbeddable ); if (useNewFieldsApi) { searchSource.removeField('fieldsFromSource'); - const fields: Record = { field: '*' }; + const fields: Record = { field: '*' }; if (pre712) { - fields.include_unmapped = true; + fields.include_unmapped = 'true'; } searchSource.setField('fields', [fields]); } else { diff --git a/src/plugins/embeddable/.storybook/main.js b/src/plugins/embeddable/.storybook/main.js index 86b48c32f103e3..8dc3c5d1518f4d 100644 --- a/src/plugins/embeddable/.storybook/main.js +++ b/src/plugins/embeddable/.storybook/main.js @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ module.exports = require('@kbn/storybook').defaultConfig; diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/edit_index_pattern.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/edit_index_pattern.tsx index 4f34bc6aa73b47..4dff5f1e0b5989 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/edit_index_pattern.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/edit_index_pattern.tsx @@ -149,7 +149,8 @@ export const EditIndexPattern = withRouter( chrome.docTitle.change(indexPattern.title); const showTagsSection = Boolean(indexPattern.timeFieldName || (tags && tags.length > 0)); - + const kibana = useKibana(); + const docsUrl = kibana.services.docLinks!.links.elasticsearch.mapping; return (
@@ -182,11 +183,7 @@ export const EditIndexPattern = withRouter( defaultMessage="This page lists every field in the {indexPatternTitle} index and the field's associated core type as recorded by Elasticsearch. To change a field type, use the Elasticsearch" values={{ indexPatternTitle: {indexPattern.title} }} />{' '} - + {mappingAPILink}

diff --git a/src/plugins/kibana_react/public/code_editor/.storybook/main.js b/src/plugins/kibana_react/public/code_editor/.storybook/main.js index 86b48c32f103e3..742239e638b8ac 100644 --- a/src/plugins/kibana_react/public/code_editor/.storybook/main.js +++ b/src/plugins/kibana_react/public/code_editor/.storybook/main.js @@ -1,8 +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. + * 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. */ +// eslint-disable-next-line import/no-commonjs module.exports = require('@kbn/storybook').defaultConfig; diff --git a/src/plugins/maps_legacy/public/components/wms_options.tsx b/src/plugins/maps_legacy/public/components/wms_options.tsx index b30f20d3552627..d4ed5abd896e4c 100644 --- a/src/plugins/maps_legacy/public/components/wms_options.tsx +++ b/src/plugins/maps_legacy/public/components/wms_options.tsx @@ -11,7 +11,6 @@ import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { TmsLayer } from '../index'; -import { Vis } from '../../../visualizations/public'; import { SelectOption, SwitchOption } from '../../../vis_default_editor/public'; import { WmsInternalOptions } from './wms_internal_options'; import { WMSOptions } from '../common/types'; @@ -19,14 +18,13 @@ import { WMSOptions } from '../common/types'; interface Props { stateParams: K; setValue: (title: 'wms', options: WMSOptions) => void; - vis: Vis; + tmsLayers: TmsLayer[]; } const mapLayerForOption = ({ id }: TmsLayer) => ({ text: id, value: id }); -function WmsOptions({ stateParams, setValue, vis }: Props) { +function WmsOptions({ stateParams, setValue, tmsLayers }: Props) { const { wms } = stateParams; - const { tmsLayers } = vis.type.editorConfig.collections; const tmsLayerOptions = useMemo(() => tmsLayers.map(mapLayerForOption), [tmsLayers]); const setWmsOption = (paramName: T, value: WMSOptions[T]) => diff --git a/src/plugins/newsfeed/public/plugin.tsx b/src/plugins/newsfeed/public/plugin.tsx index 94a113a2786c2d..a788b3c4d0b59f 100644 --- a/src/plugins/newsfeed/public/plugin.tsx +++ b/src/plugins/newsfeed/public/plugin.tsx @@ -51,7 +51,10 @@ export class NewsfeedPublicPlugin return { createNewsFeed$: (endpoint: NewsfeedApiEndpoint) => { const config = Object.assign({}, this.config, { - service: { pathTemplate: `/${endpoint}/v{VERSION}.json` }, + service: { + ...this.config.service, + pathTemplate: `/${endpoint}/v{VERSION}.json`, + }, }); return this.fetchNewsfeed(core, config); }, diff --git a/src/plugins/region_map/public/components/region_map_options.tsx b/src/plugins/region_map/public/components/region_map_options.tsx index 5b5b71c9e9f4ed..2bf13e46f70de7 100644 --- a/src/plugins/region_map/public/components/region_map_options.tsx +++ b/src/plugins/region_map/public/components/region_map_options.tsx @@ -11,10 +11,12 @@ import { EuiIcon, EuiLink, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elast import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; +import { truncatedColorSchemas } from '../../../charts/public'; import { FileLayerField, VectorLayer, IServiceSettings } from '../../../maps_legacy/public'; import { SelectOption, SwitchOption, NumberInputOption } from '../../../vis_default_editor/public'; import { WmsOptions } from '../../../maps_legacy/public'; import { RegionMapVisParams } from '../region_map_types'; +import { getTmsLayers, getVectorLayers } from '../kibana_services'; const mapLayerForOption = ({ layerId, name }: VectorLayer) => ({ text: name, @@ -26,14 +28,16 @@ const mapFieldForOption = ({ description, name }: FileLayerField) => ({ value: name, }); +const tmsLayers = getTmsLayers(); +const vectorLayers = getVectorLayers(); +const vectorLayerOptions = vectorLayers.map(mapLayerForOption); + export type RegionMapOptionsProps = { getServiceSettings: () => Promise; } & VisEditorOptionsProps; function RegionMapOptions(props: RegionMapOptionsProps) { - const { getServiceSettings, stateParams, vis, setValue } = props; - const { vectorLayers } = vis.type.editorConfig.collections; - const vectorLayerOptions = useMemo(() => vectorLayers.map(mapLayerForOption), [vectorLayers]); + const { getServiceSettings, stateParams, setValue } = props; const fieldOptions = useMemo( () => ((stateParams.selectedLayer && stateParams.selectedLayer.fields) || []).map( @@ -61,7 +65,7 @@ function RegionMapOptions(props: RegionMapOptionsProps) { setEmsHotLink(newLayer); } }, - [vectorLayers, setEmsHotLink, setValue] + [setEmsHotLink, setValue] ); const setField = useCallback( @@ -178,7 +182,7 @@ function RegionMapOptions(props: RegionMapOptionsProps) { label={i18n.translate('regionMap.visParams.colorSchemaLabel', { defaultMessage: 'Color schema', })} - options={vis.type.editorConfig.collections.colorSchemas} + options={truncatedColorSchemas} paramName="colorSchema" value={stateParams.colorSchema} setValue={setValue} @@ -197,7 +201,7 @@ function RegionMapOptions(props: RegionMapOptionsProps) { - + ); } diff --git a/src/plugins/region_map/public/kibana_services.ts b/src/plugins/region_map/public/kibana_services.ts index 60465e2e0c2512..77bc472e3b140c 100644 --- a/src/plugins/region_map/public/kibana_services.ts +++ b/src/plugins/region_map/public/kibana_services.ts @@ -12,6 +12,7 @@ import { createGetterSetter } from '../../kibana_utils/public'; import { DataPublicPluginStart } from '../../data/public'; import { KibanaLegacyStart } from '../../kibana_legacy/public'; import { SharePluginStart } from '../../share/public'; +import { VectorLayer, TmsLayer } from '../../maps_legacy/public'; export const [getCoreService, setCoreService] = createGetterSetter('Core'); @@ -32,3 +33,7 @@ export const [getShareService, setShareService] = createGetterSetter( 'KibanaLegacy' ); + +export const [getTmsLayers, setTmsLayers] = createGetterSetter('TmsLayers'); + +export const [getVectorLayers, setVectorLayers] = createGetterSetter('VectorLayers'); diff --git a/src/plugins/region_map/public/region_map_type.ts b/src/plugins/region_map/public/region_map_type.ts index 0e8df51b17c793..35f4cffca18d43 100644 --- a/src/plugins/region_map/public/region_map_type.ts +++ b/src/plugins/region_map/public/region_map_type.ts @@ -9,7 +9,6 @@ import { i18n } from '@kbn/i18n'; import { VisTypeDefinition } from '../../visualizations/public'; -import { truncatedColorSchemas } from '../../charts/public'; import { ORIGIN } from '../../maps_legacy/public'; import { getDeprecationMessage } from './get_deprecation_message'; @@ -18,6 +17,7 @@ import { createRegionMapOptions } from './components'; import { toExpressionAst } from './to_ast'; import { RegionMapVisParams } from './region_map_types'; import { mapToLayerWithId } from './util'; +import { setTmsLayers, setVectorLayers } from './kibana_services'; export function createRegionMapTypeDefinition({ uiSettings, @@ -50,11 +50,6 @@ provided base maps, or add your own. Darker colors represent higher values.', }, editorConfig: { optionsTemplate: createRegionMapOptions(getServiceSettings), - collections: { - colorSchemas: truncatedColorSchemas, - vectorLayers: [], - tmsLayers: [], - }, schemas: [ { group: 'metrics', @@ -95,7 +90,9 @@ provided base maps, or add your own. Darker colors represent higher values.', setup: async (vis) => { const serviceSettings = await getServiceSettings(); const tmsLayers = await serviceSettings.getTMSServices(); - vis.type.editorConfig.collections.tmsLayers = tmsLayers; + setTmsLayers(tmsLayers); + setVectorLayers([]); + if (!vis.params.wms.selectedTmsLayer && tmsLayers.length) { vis.params.wms.selectedTmsLayer = tmsLayers[0]; } @@ -122,9 +119,10 @@ provided base maps, or add your own. Darker colors represent higher values.', } }); - vis.type.editorConfig.collections.vectorLayers = [...vectorLayers, ...newLayers]; + const allVectorLayers = [...vectorLayers, ...newLayers]; + setVectorLayers(allVectorLayers); - [selectedLayer] = vis.type.editorConfig.collections.vectorLayers; + [selectedLayer] = allVectorLayers; selectedJoinField = selectedLayer ? selectedLayer.fields[0] : undefined; if (selectedLayer && !vis.params.selectedLayer && selectedLayer.isEMS) { diff --git a/src/plugins/tile_map/public/components/collections.ts b/src/plugins/tile_map/public/components/collections.ts new file mode 100644 index 00000000000000..f75d83c4a055f9 --- /dev/null +++ b/src/plugins/tile_map/public/components/collections.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 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 { i18n } from '@kbn/i18n'; +import { MapTypes } from '../utils/map_types'; + +export const collections = { + mapTypes: [ + { + value: MapTypes.ScaledCircleMarkers, + text: i18n.translate('tileMap.mapTypes.scaledCircleMarkersText', { + defaultMessage: 'Scaled circle markers', + }), + }, + { + value: MapTypes.ShadedCircleMarkers, + text: i18n.translate('tileMap.mapTypes.shadedCircleMarkersText', { + defaultMessage: 'Shaded circle markers', + }), + }, + { + value: MapTypes.ShadedGeohashGrid, + text: i18n.translate('tileMap.mapTypes.shadedGeohashGridText', { + defaultMessage: 'Shaded geohash grid', + }), + }, + { + value: MapTypes.Heatmap, + text: i18n.translate('tileMap.mapTypes.heatmapText', { + defaultMessage: 'Heatmap', + }), + }, + ], + legendPositions: [ + { + value: 'bottomleft', + text: i18n.translate('tileMap.legendPositions.bottomLeftText', { + defaultMessage: 'Bottom left', + }), + }, + { + value: 'bottomright', + text: i18n.translate('tileMap.legendPositions.bottomRightText', { + defaultMessage: 'Bottom right', + }), + }, + { + value: 'topleft', + text: i18n.translate('tileMap.legendPositions.topLeftText', { + defaultMessage: 'Top left', + }), + }, + { + value: 'topright', + text: i18n.translate('tileMap.legendPositions.topRightText', { + defaultMessage: 'Top right', + }), + }, + ], +}; diff --git a/src/plugins/tile_map/public/components/tile_map_options.tsx b/src/plugins/tile_map/public/components/tile_map_options.tsx index 9164a4b0d6300d..dbe28f0e2c2dde 100644 --- a/src/plugins/tile_map/public/components/tile_map_options.tsx +++ b/src/plugins/tile_map/public/components/tile_map_options.tsx @@ -17,20 +17,25 @@ import { SwitchOption, RangeOption, } from '../../../vis_default_editor/public'; +import { truncatedColorSchemas } from '../../../charts/public'; import { WmsOptions } from '../../../maps_legacy/public'; import { TileMapVisParams } from '../types'; import { MapTypes } from '../utils/map_types'; +import { getTmsLayers } from '../services'; +import { collections } from './collections'; export type TileMapOptionsProps = VisEditorOptionsProps; +const tmsLayers = getTmsLayers(); + function TileMapOptions(props: TileMapOptionsProps) { const { stateParams, setValue, vis } = props; useEffect(() => { if (!stateParams.mapType) { - setValue('mapType', vis.type.editorConfig.collections.mapTypes[0]); + setValue('mapType', collections.mapTypes[0].value); } - }, [setValue, stateParams.mapType, vis.type.editorConfig.collections.mapTypes]); + }, [setValue, stateParams.mapType]); return ( <> @@ -39,7 +44,7 @@ function TileMapOptions(props: TileMapOptionsProps) { label={i18n.translate('tileMap.visParams.mapTypeLabel', { defaultMessage: 'Map type', })} - options={vis.type.editorConfig.collections.mapTypes} + options={collections.mapTypes} paramName="mapType" value={stateParams.mapType} setValue={setValue} @@ -62,14 +67,14 @@ function TileMapOptions(props: TileMapOptionsProps) { label={i18n.translate('tileMap.visParams.colorSchemaLabel', { defaultMessage: 'Color schema', })} - options={vis.type.editorConfig.collections.colorSchemas} + options={truncatedColorSchemas} paramName="colorSchema" value={stateParams.colorSchema} setValue={setValue} /> )} - + - + ); } diff --git a/src/plugins/tile_map/public/services.ts b/src/plugins/tile_map/public/services.ts index 3e6dbb69c9403a..af23daf24f7f58 100644 --- a/src/plugins/tile_map/public/services.ts +++ b/src/plugins/tile_map/public/services.ts @@ -11,6 +11,7 @@ import { createGetterSetter } from '../../kibana_utils/public'; import { DataPublicPluginStart } from '../../data/public'; import { KibanaLegacyStart } from '../../kibana_legacy/public'; import { SharePluginStart } from '../../share/public'; +import { TmsLayer } from '../../maps_legacy/public'; export const [getCoreService, setCoreService] = createGetterSetter('Core'); @@ -27,3 +28,5 @@ export const [getShareService, setShareService] = createGetterSetter( 'KibanaLegacy' ); + +export const [getTmsLayers, setTmsLayers] = createGetterSetter('TmsLayers'); diff --git a/src/plugins/tile_map/public/tile_map_type.ts b/src/plugins/tile_map/public/tile_map_type.ts index dc2cd418c28e28..5e71351f1bd563 100644 --- a/src/plugins/tile_map/public/tile_map_type.ts +++ b/src/plugins/tile_map/public/tile_map_type.ts @@ -8,7 +8,6 @@ import { i18n } from '@kbn/i18n'; import { VisTypeDefinition } from 'src/plugins/visualizations/public'; -import { truncatedColorSchemas } from '../../charts/public'; // @ts-expect-error import { supportsCssFilters } from './css_filters'; @@ -17,7 +16,7 @@ import { getDeprecationMessage } from './get_deprecation_message'; import { TileMapVisualizationDependencies } from './plugin'; import { toExpressionAst } from './to_ast'; import { TileMapVisParams } from './types'; -import { MapTypes } from './utils/map_types'; +import { setTmsLayers } from './services'; export function createTileMapTypeDefinition( dependencies: TileMapVisualizationDependencies @@ -50,62 +49,6 @@ export function createTileMapTypeDefinition( }, toExpressionAst, editorConfig: { - collections: { - colorSchemas: truncatedColorSchemas, - legendPositions: [ - { - value: 'bottomleft', - text: i18n.translate('tileMap.vis.editorConfig.legendPositions.bottomLeftText', { - defaultMessage: 'Bottom left', - }), - }, - { - value: 'bottomright', - text: i18n.translate('tileMap.vis.editorConfig.legendPositions.bottomRightText', { - defaultMessage: 'Bottom right', - }), - }, - { - value: 'topleft', - text: i18n.translate('tileMap.vis.editorConfig.legendPositions.topLeftText', { - defaultMessage: 'Top left', - }), - }, - { - value: 'topright', - text: i18n.translate('tileMap.vis.editorConfig.legendPositions.topRightText', { - defaultMessage: 'Top right', - }), - }, - ], - mapTypes: [ - { - value: MapTypes.ScaledCircleMarkers, - text: i18n.translate('tileMap.vis.editorConfig.mapTypes.scaledCircleMarkersText', { - defaultMessage: 'Scaled circle markers', - }), - }, - { - value: MapTypes.ShadedCircleMarkers, - text: i18n.translate('tileMap.vis.editorConfig.mapTypes.shadedCircleMarkersText', { - defaultMessage: 'Shaded circle markers', - }), - }, - { - value: MapTypes.ShadedGeohashGrid, - text: i18n.translate('tileMap.vis.editorConfig.mapTypes.shadedGeohashGridText', { - defaultMessage: 'Shaded geohash grid', - }), - }, - { - value: MapTypes.Heatmap, - text: i18n.translate('tileMap.vis.editorConfig.mapTypes.heatmapText', { - defaultMessage: 'Heatmap', - }), - }, - ], - tmsLayers: [], - }, optionsTemplate: TileMapOptionsLazy, schemas: [ { @@ -141,7 +84,7 @@ export function createTileMapTypeDefinition( return vis; } - vis.type.editorConfig.collections.tmsLayers = tmsLayers; + setTmsLayers(tmsLayers); if (!vis.params.wms.selectedTmsLayer && tmsLayers.length) { vis.params.wms.selectedTmsLayer = tmsLayers[0]; } diff --git a/src/plugins/vis_default_editor/public/components/options/basic_options.tsx b/src/plugins/vis_default_editor/public/components/options/basic_options.tsx index 5d19b6dab4b82d..5cec0743b94fd6 100644 --- a/src/plugins/vis_default_editor/public/components/options/basic_options.tsx +++ b/src/plugins/vis_default_editor/public/components/options/basic_options.tsx @@ -19,18 +19,23 @@ interface BasicOptionsParams { legendPosition: string; } +type LegendPositions = Array<{ + value: string; + text: string; +}>; + function BasicOptions({ stateParams, setValue, - vis, -}: VisEditorOptionsProps) { + legendPositions, +}: VisEditorOptionsProps & { legendPositions: LegendPositions }) { return ( <> ) { const setMetricValue: ( @@ -137,14 +157,14 @@ function MetricVisOptions({ isDisabled={stateParams.metric.colorsRange.length === 1} isFullWidth={true} legend={metricColorModeLabel} - options={vis.type.editorConfig.collections.metricColorMode} + options={metricColorMode} onChange={setColorMode} /> => }, }, editorConfig: { - collections: { - metricColorMode: [ - { - id: ColorMode.None, - label: i18n.translate('visTypeMetric.colorModes.noneOptionLabel', { - defaultMessage: 'None', - }), - }, - { - id: ColorMode.Labels, - label: i18n.translate('visTypeMetric.colorModes.labelsOptionLabel', { - defaultMessage: 'Labels', - }), - }, - { - id: ColorMode.Background, - label: i18n.translate('visTypeMetric.colorModes.backgroundOptionLabel', { - defaultMessage: 'Background', - }), - }, - ], - colorSchemas, - }, optionsTemplate: MetricVisOptions, schemas: [ { diff --git a/src/plugins/vis_type_tagcloud/public/components/collections.ts b/src/plugins/vis_type_tagcloud/public/components/collections.ts new file mode 100644 index 00000000000000..d5dd3c7f2d252d --- /dev/null +++ b/src/plugins/vis_type_tagcloud/public/components/collections.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { i18n } from '@kbn/i18n'; +import { TagCloudVisParams } from '../types'; + +interface Scales { + text: string; + value: TagCloudVisParams['scale']; +} + +interface Orientation { + text: string; + value: TagCloudVisParams['orientation']; +} + +interface Collections { + scales: Scales[]; + orientations: Orientation[]; +} + +export const collections: Collections = { + scales: [ + { + text: i18n.translate('visTypeTagCloud.scales.linearText', { + defaultMessage: 'Linear', + }), + value: 'linear', + }, + { + text: i18n.translate('visTypeTagCloud.scales.logText', { + defaultMessage: 'Log', + }), + value: 'log', + }, + { + text: i18n.translate('visTypeTagCloud.scales.squareRootText', { + defaultMessage: 'Square root', + }), + value: 'square root', + }, + ], + orientations: [ + { + text: i18n.translate('visTypeTagCloud.orientations.singleText', { + defaultMessage: 'Single', + }), + value: 'single', + }, + { + text: i18n.translate('visTypeTagCloud.orientations.rightAngledText', { + defaultMessage: 'Right angled', + }), + value: 'right angled', + }, + { + text: i18n.translate('visTypeTagCloud.orientations.multipleText', { + defaultMessage: 'Multiple', + }), + value: 'multiple', + }, + ], +}; diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_options.tsx b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_options.tsx index 549cbc8bfec84f..d5e005a6386806 100644 --- a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_options.tsx +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_options.tsx @@ -13,8 +13,9 @@ import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; import { SelectOption, SwitchOption } from '../../../vis_default_editor/public'; import { ValidatedDualRange } from '../../../kibana_react/public'; import { TagCloudVisParams } from '../types'; +import { collections } from './collections'; -function TagCloudOptions({ stateParams, setValue, vis }: VisEditorOptionsProps) { +function TagCloudOptions({ stateParams, setValue }: VisEditorOptionsProps) { const handleFontSizeChange = ([minFontSize, maxFontSize]: [string | number, string | number]) => { setValue('minFontSize', Number(minFontSize)); setValue('maxFontSize', Number(maxFontSize)); @@ -29,7 +30,7 @@ function TagCloudOptions({ stateParams, setValue, vis }: VisEditorOptionsProps(paramName: T, value: ColorSchemaParams[T]) => { @@ -91,7 +90,7 @@ function RangesPanel({ ) { - const { stateParams, vis, uiState, setValue, setValidity, setTouched } = props; + const { stateParams, uiState, setValue, setValidity, setTouched } = props; const [valueAxis] = stateParams.valueAxes; const isColorsNumberInvalid = stateParams.colorsNumber < 2 || stateParams.colorsNumber > 10; const [isColorRangesValid, setIsColorRangesValid] = useState(false); @@ -65,7 +68,7 @@ function HeatmapOptions(props: VisEditorOptionsProps) { - + ) { ) { label={i18n.translate('visTypeVislib.controls.heatmapOptions.colorScaleLabel', { defaultMessage: 'Color scale', })} - options={vis.type.editorConfig.collections.scales} + options={heatmapCollections.scales} paramName="type" value={valueAxis.scale.type} setValue={setValueAxisScale} diff --git a/src/plugins/vis_type_vislib/public/editor/components/pie.tsx b/src/plugins/vis_type_vislib/public/editor/components/pie.tsx index 9acadd4252a95b..6c84bc744676a9 100644 --- a/src/plugins/vis_type_vislib/public/editor/components/pie.tsx +++ b/src/plugins/vis_type_vislib/public/editor/components/pie.tsx @@ -14,10 +14,12 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; import { BasicOptions, SwitchOption } from '../../../../vis_default_editor/public'; -import { TruncateLabelsOption } from '../../../../vis_type_xy/public'; +import { TruncateLabelsOption, getPositions } from '../../../../vis_type_xy/public'; import { PieVisParams } from '../../pie'; +const legendPositions = getPositions(); + function PieOptions(props: VisEditorOptionsProps) { const { stateParams, setValue } = props; const setLabels = ( @@ -45,7 +47,7 @@ function PieOptions(props: VisEditorOptionsProps) { value={stateParams.isDonut} setValue={setValue} /> - + diff --git a/src/plugins/vis_type_vislib/public/gauge.ts b/src/plugins/vis_type_vislib/public/gauge.ts index cd4c03e5a84d14..315c4388a5cd3d 100644 --- a/src/plugins/vis_type_vislib/public/gauge.ts +++ b/src/plugins/vis_type_vislib/public/gauge.ts @@ -14,7 +14,6 @@ import { AggGroupNames } from '../../data/public'; import { VisTypeDefinition, VIS_EVENT_TO_TRIGGER } from '../../visualizations/public'; import { Alignment, GaugeType, VislibChartType } from './types'; -import { getGaugeCollections } from './editor'; import { toExpressionAst } from './to_ast'; import { GaugeOptions } from './editor/components'; @@ -102,7 +101,6 @@ export const gaugeVisTypeDefinition: VisTypeDefinition = { }, }, editorConfig: { - collections: getGaugeCollections(), optionsTemplate: GaugeOptions, schemas: [ { diff --git a/src/plugins/vis_type_vislib/public/goal.ts b/src/plugins/vis_type_vislib/public/goal.ts index a31ba48704d509..aaeae4f675f3fd 100644 --- a/src/plugins/vis_type_vislib/public/goal.ts +++ b/src/plugins/vis_type_vislib/public/goal.ts @@ -12,7 +12,7 @@ import { AggGroupNames } from '../../data/public'; import { ColorMode, ColorSchemas } from '../../charts/public'; import { VisTypeDefinition } from '../../visualizations/public'; -import { getGaugeCollections, GaugeOptions } from './editor'; +import { GaugeOptions } from './editor'; import { toExpressionAst } from './to_ast'; import { GaugeType } from './types'; import { GaugeVisParams } from './gauge'; @@ -66,7 +66,6 @@ export const goalVisTypeDefinition: VisTypeDefinition = { }, }, editorConfig: { - collections: getGaugeCollections(), optionsTemplate: GaugeOptions, schemas: [ { diff --git a/src/plugins/vis_type_vislib/public/heatmap.ts b/src/plugins/vis_type_vislib/public/heatmap.ts index ca6dda547571c3..f804a78cbe4538 100644 --- a/src/plugins/vis_type_vislib/public/heatmap.ts +++ b/src/plugins/vis_type_vislib/public/heatmap.ts @@ -15,7 +15,7 @@ import { ColorSchemas, ColorSchemaParams } from '../../charts/public'; import { VIS_EVENT_TO_TRIGGER, VisTypeDefinition } from '../../visualizations/public'; import { ValueAxis, ScaleType, AxisType } from '../../vis_type_xy/public'; -import { HeatmapOptions, getHeatmapCollections } from './editor'; +import { HeatmapOptions } from './editor'; import { TimeMarker } from './vislib/visualizations/time_marker'; import { CommonVislibParams, VislibChartType } from './types'; import { toExpressionAst } from './to_ast'; @@ -75,7 +75,6 @@ export const heatmapVisTypeDefinition: VisTypeDefinition = { }, }, editorConfig: { - collections: getHeatmapCollections(), optionsTemplate: HeatmapOptions, schemas: [ { diff --git a/src/plugins/vis_type_vislib/public/pie.ts b/src/plugins/vis_type_vislib/public/pie.ts index e00fae7c32f066..d1d8d2a5279feb 100644 --- a/src/plugins/vis_type_vislib/public/pie.ts +++ b/src/plugins/vis_type_vislib/public/pie.ts @@ -11,7 +11,6 @@ import { Position } from '@elastic/charts'; import { AggGroupNames } from '../../data/public'; import { VisTypeDefinition, VIS_EVENT_TO_TRIGGER } from '../../visualizations/public'; -import { getPositions } from '../../vis_type_xy/public'; import { CommonVislibParams } from './types'; import { PieOptions } from './editor'; @@ -53,9 +52,6 @@ export const pieVisTypeDefinition: VisTypeDefinition = { }, }, editorConfig: { - collections: { - legendPositions: getPositions(), - }, optionsTemplate: PieOptions, schemas: [ { diff --git a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/__snapshots__/chart_options.test.tsx.snap b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/__snapshots__/chart_options.test.tsx.snap index e9cd2b737b879c..56f35ae0211732 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/__snapshots__/chart_options.test.tsx.snap +++ b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/__snapshots__/chart_options.test.tsx.snap @@ -31,6 +31,22 @@ exports[`ChartOptions component should init with the default set of props 1`] = `; diff --git a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/__snapshots__/value_axes_panel.test.tsx.snap b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/__snapshots__/value_axes_panel.test.tsx.snap index 594511010b7453..abcbf1a4fd7d93 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/__snapshots__/value_axes_panel.test.tsx.snap +++ b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/__snapshots__/value_axes_panel.test.tsx.snap @@ -150,80 +150,6 @@ exports[`ValueAxesPanel component should init with the default set of props 1`] "type": "value", } } - vis={ - Object { - "type": Object { - "editorConfig": Object { - "collections": Object { - "axisModes": Array [ - Object { - "text": "Normal", - "value": "normal", - }, - Object { - "text": "Percentage", - "value": "percentage", - }, - Object { - "text": "Wiggle", - "value": "wiggle", - }, - Object { - "text": "Silhouette", - "value": "silhouette", - }, - ], - "interpolationModes": Array [ - Object { - "text": "Straight", - "value": "linear", - }, - Object { - "text": "Smoothed", - "value": "cardinal", - }, - Object { - "text": "Stepped", - "value": "step-after", - }, - ], - "positions": Array [ - Object { - "text": "Top", - "value": "top", - }, - Object { - "text": "Left", - "value": "left", - }, - Object { - "text": "Right", - "value": "right", - }, - Object { - "text": "Bottom", - "value": "bottom", - }, - ], - "scaleTypes": Array [ - Object { - "text": "Linear", - "value": "linear", - }, - Object { - "text": "Log", - "value": "log", - }, - Object { - "text": "Square root", - "value": "square root", - }, - ], - }, - }, - }, - } - } /> diff --git a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/category_axis_panel.test.tsx b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/category_axis_panel.test.tsx index 17a504a25b05f6..066f053d4e1865 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/category_axis_panel.test.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/category_axis_panel.test.tsx @@ -11,7 +11,7 @@ import { shallow } from 'enzyme'; import { CategoryAxisPanel, CategoryAxisPanelProps } from './category_axis_panel'; import { CategoryAxis } from '../../../../types'; import { LabelOptions } from './label_options'; -import { categoryAxis, vis } from './mocks'; +import { categoryAxis } from './mocks'; import { Position } from '@elastic/charts'; describe('CategoryAxisPanel component', () => { @@ -27,7 +27,6 @@ describe('CategoryAxisPanel component', () => { defaultProps = { axis, - vis, onPositionChanged, setCategoryAxis, }; diff --git a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/category_axis_panel.tsx b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/category_axis_panel.tsx index 6c261137d9eb61..5ba35717e46f38 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/category_axis_panel.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/category_axis_panel.tsx @@ -13,25 +13,21 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui'; import { Position } from '@elastic/charts'; -import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; import { SelectOption, SwitchOption } from '../../../../../../vis_default_editor/public'; import { LabelOptions, SetAxisLabel } from './label_options'; import { CategoryAxis } from '../../../../types'; +import { getPositions } from '../../../collections'; + +const positions = getPositions(); export interface CategoryAxisPanelProps { axis: CategoryAxis; onPositionChanged: (position: Position) => void; setCategoryAxis: (value: CategoryAxis) => void; - vis: VisEditorOptionsProps['vis']; } -function CategoryAxisPanel({ - axis, - onPositionChanged, - vis, - setCategoryAxis, -}: CategoryAxisPanelProps) { +function CategoryAxisPanel({ axis, onPositionChanged, setCategoryAxis }: CategoryAxisPanelProps) { const setAxis = useCallback( (paramName: T, value: CategoryAxis[T]) => { const updatedAxis = { @@ -78,7 +74,7 @@ function CategoryAxisPanel({ label={i18n.translate('visTypeXy.controls.pointSeries.categoryAxis.positionLabel', { defaultMessage: 'Position', })} - options={vis.type.editorConfig.collections.positions} + options={positions} paramName="position" value={axis.position} setValue={setPosition} diff --git a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/chart_options.test.tsx b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/chart_options.test.tsx index 1e274dce7c2a8e..caf14e57fef7e3 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/chart_options.test.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/chart_options.test.tsx @@ -12,7 +12,7 @@ import { shallow } from 'enzyme'; import { ChartOptions, ChartOptionsParams } from './chart_options'; import { SeriesParam, ChartMode } from '../../../../types'; import { LineOptions } from './line_options'; -import { valueAxis, seriesParam, vis } from './mocks'; +import { valueAxis, seriesParam } from './mocks'; import { ChartType } from '../../../../../common'; describe('ChartOptions component', () => { @@ -29,7 +29,6 @@ describe('ChartOptions component', () => { defaultProps = { index: 0, chart, - vis, valueAxes: [valueAxis], setParamByIndex, changeValueAxis, diff --git a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/chart_options.tsx b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/chart_options.tsx index 76604383db8c52..6f0b4fc5c9d229 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/chart_options.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/chart_options.tsx @@ -11,13 +11,15 @@ import React, { useMemo, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { Vis } from '../../../../../../visualizations/public'; import { SelectOption } from '../../../../../../vis_default_editor/public'; import { SeriesParam, ValueAxis } from '../../../../types'; import { LineOptions } from './line_options'; import { SetParamByIndex, ChangeValueAxis } from '.'; import { ChartType } from '../../../../../common'; +import { getConfigCollections } from '../../../collections'; + +const collections = getConfigCollections(); export type SetChart = (paramName: T, value: SeriesParam[T]) => void; @@ -27,14 +29,12 @@ export interface ChartOptionsParams { changeValueAxis: ChangeValueAxis; setParamByIndex: SetParamByIndex; valueAxes: ValueAxis[]; - vis: Vis; } function ChartOptions({ chart, index, valueAxes, - vis, changeValueAxis, setParamByIndex, }: ChartOptionsParams) { @@ -90,7 +90,7 @@ function ChartOptions({ label={i18n.translate('visTypeXy.controls.pointSeries.series.chartTypeLabel', { defaultMessage: 'Chart type', })} - options={vis.type.editorConfig.collections.chartTypes} + options={collections.chartTypes} paramName="type" value={chart.type} setValue={setChart} @@ -102,7 +102,7 @@ function ChartOptions({ label={i18n.translate('visTypeXy.controls.pointSeries.series.modeLabel', { defaultMessage: 'Mode', })} - options={vis.type.editorConfig.collections.chartModes} + options={collections.chartModes} paramName="mode" value={chart.mode} setValue={setChart} @@ -118,7 +118,7 @@ function ChartOptions({ label={i18n.translate('visTypeXy.controls.pointSeries.series.lineModeLabel', { defaultMessage: 'Line mode', })} - options={vis.type.editorConfig.collections.interpolationModes} + options={collections.interpolationModes} paramName="interpolate" value={chart.interpolate} setValue={setChart} @@ -126,7 +126,7 @@ function ChartOptions({ )} - {chart.type === ChartType.Line && } + {chart.type === ChartType.Line && } ); } diff --git a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/index.tsx b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/index.tsx index c295d909863dc1..d25845f02e7a73 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/index.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/index.tsx @@ -326,14 +326,12 @@ function MetricsAxisOptions(props: ValidationVisOptionsProps) { setMultipleValidity={props.setMultipleValidity} seriesParams={stateParams.seriesParams} valueAxes={stateParams.valueAxes} - vis={vis} /> ) : null; diff --git a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/line_options.test.tsx b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/line_options.test.tsx index c8a5e6f17b1ed9..5497c46c1dd344 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/line_options.test.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/line_options.test.tsx @@ -12,7 +12,7 @@ import { shallow } from 'enzyme'; import { NumberInputOption } from '../../../../../../vis_default_editor/public'; import { LineOptions, LineOptionsParams } from './line_options'; -import { seriesParam, vis } from './mocks'; +import { seriesParam } from './mocks'; const LINE_WIDTH = 'lineWidth'; const DRAW_LINES = 'drawLinesBetweenPoints'; @@ -26,7 +26,6 @@ describe('LineOptions component', () => { defaultProps = { chart: { ...seriesParam }, - vis, setChart, }; }); diff --git a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/line_options.tsx b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/line_options.tsx index b101ed1553a24e..140f190c77181e 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/line_options.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/line_options.tsx @@ -11,7 +11,6 @@ import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { Vis } from '../../../../../../visualizations/public'; import { NumberInputOption, SelectOption, @@ -20,14 +19,16 @@ import { import { SeriesParam } from '../../../../types'; import { SetChart } from './chart_options'; +import { getInterpolationModes } from '../../../collections'; + +const interpolationModes = getInterpolationModes(); export interface LineOptionsParams { chart: SeriesParam; - vis: Vis; setChart: SetChart; } -function LineOptions({ chart, vis, setChart }: LineOptionsParams) { +function LineOptions({ chart, setChart }: LineOptionsParams) { const setLineWidth = useCallback( (paramName: 'lineWidth', value: number | '') => { setChart(paramName, value === '' ? undefined : value); @@ -57,7 +58,7 @@ function LineOptions({ chart, vis, setChart }: LineOptionsParams) { label={i18n.translate('visTypeXy.controls.pointSeries.series.lineModeLabel', { defaultMessage: 'Line mode', })} - options={vis.type.editorConfig.collections.interpolationModes} + options={interpolationModes} paramName="interpolate" value={chart.interpolate} setValue={setChart} diff --git a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/mocks.ts b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/mocks.ts index 33e2af174753e3..7451f6dea9039b 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/mocks.ts +++ b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/mocks.ts @@ -20,12 +20,6 @@ import { AxisType, CategoryAxis, } from '../../../../types'; -import { - getScaleTypes, - getAxisModes, - getPositions, - getInterpolationModes, -} from '../../../collections'; import { ChartType } from '../../../../../common'; const defaultValueAxisId = 'ValueAxis-1'; @@ -85,16 +79,9 @@ const seriesParam: SeriesParam = { valueAxis: defaultValueAxisId, }; -const positions = getPositions(); -const axisModes = getAxisModes(); -const scaleTypes = getScaleTypes(); -const interpolationModes = getInterpolationModes(); - const vis = ({ type: { - editorConfig: { - collections: { scaleTypes, axisModes, positions, interpolationModes }, - }, + editorConfig: {}, }, } as any) as Vis; diff --git a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/value_axes_panel.test.tsx b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/value_axes_panel.test.tsx index 13dab168e586cd..3e1a44993235b9 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/value_axes_panel.test.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/value_axes_panel.test.tsx @@ -14,7 +14,7 @@ import { Position } from '@elastic/charts'; import { ValueAxis, SeriesParam } from '../../../../types'; import { ValueAxesPanel, ValueAxesPanelProps } from './value_axes_panel'; -import { valueAxis, seriesParam, vis } from './mocks'; +import { valueAxis, seriesParam } from './mocks'; describe('ValueAxesPanel component', () => { let setParamByIndex: jest.Mock; @@ -53,7 +53,6 @@ describe('ValueAxesPanel component', () => { defaultProps = { seriesParams: [seriesParamCount, seriesParamAverage], valueAxes: [axisLeft, axisRight], - vis, setParamByIndex, onValueAxisPositionChanged, addValueAxis, diff --git a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/value_axes_panel.tsx b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/value_axes_panel.tsx index 5f874e0489370b..02bdb7b1852880 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/value_axes_panel.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/value_axes_panel.tsx @@ -20,8 +20,6 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Vis } from '../../../../../../visualizations/public'; - import { SeriesParam, ValueAxis } from '../../../../types'; import { ValueAxisOptions } from './value_axis_options'; import { SetParamByIndex } from '.'; @@ -33,7 +31,6 @@ export interface ValueAxesPanelProps { setParamByIndex: SetParamByIndex; seriesParams: SeriesParam[]; valueAxes: ValueAxis[]; - vis: Vis; setMultipleValidity: (paramName: string, isValid: boolean) => void; } @@ -152,7 +149,6 @@ function ValueAxesPanel(props: ValueAxesPanelProps) { onValueAxisPositionChanged={props.onValueAxisPositionChanged} setParamByIndex={props.setParamByIndex} setMultipleValidity={props.setMultipleValidity} - vis={props.vis} /> diff --git a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/value_axis_options.test.tsx b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/value_axis_options.test.tsx index b843e7b5ab0640..f2d689126166f3 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/value_axis_options.test.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/value_axis_options.test.tsx @@ -16,7 +16,7 @@ import { TextInputOption } from '../../../../../../vis_default_editor/public'; import { ValueAxis, ScaleType } from '../../../../types'; import { LabelOptions } from './label_options'; import { ValueAxisOptions, ValueAxisOptionsParams } from './value_axis_options'; -import { valueAxis, vis } from './mocks'; +import { valueAxis } from './mocks'; const POSITION = 'position'; @@ -37,7 +37,6 @@ describe('ValueAxisOptions component', () => { axis, index: 0, valueAxis, - vis, setParamByIndex, onValueAxisPositionChanged, setMultipleValidity, diff --git a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/value_axis_options.tsx b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/value_axis_options.tsx index d9e0302cbe516e..1a38be83b9fc5e 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/value_axis_options.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/metrics_axes/value_axis_options.tsx @@ -10,7 +10,6 @@ import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiAccordion, EuiHorizontalRule } from '@elastic/eui'; -import { Vis } from '../../../../../../visualizations/public'; import { SelectOption, SwitchOption, @@ -21,6 +20,9 @@ import { ValueAxis } from '../../../../types'; import { LabelOptions, SetAxisLabel } from './label_options'; import { CustomExtentsOptions } from './custom_extents_options'; import { SetParamByIndex } from '.'; +import { getConfigCollections } from '../../../collections'; + +const collections = getConfigCollections(); export type SetScale = ( paramName: T, @@ -33,7 +35,6 @@ export interface ValueAxisOptionsParams { onValueAxisPositionChanged: (index: number, value: ValueAxis['position']) => void; setParamByIndex: SetParamByIndex; valueAxis: ValueAxis; - vis: Vis; setMultipleValidity: (paramName: string, isValid: boolean) => void; } @@ -41,7 +42,6 @@ export function ValueAxisOptions({ axis, index, valueAxis, - vis, onValueAxisPositionChanged, setParamByIndex, setMultipleValidity, @@ -101,7 +101,7 @@ export function ValueAxisOptions({ label={i18n.translate('visTypeXy.controls.pointSeries.valueAxes.positionLabel', { defaultMessage: 'Position', })} - options={vis.type.editorConfig.collections.positions} + options={collections.positions} paramName="position" value={axis.position} setValue={onPositionChanged} @@ -112,7 +112,7 @@ export function ValueAxisOptions({ label={i18n.translate('visTypeXy.controls.pointSeries.valueAxes.modeLabel', { defaultMessage: 'Mode', })} - options={vis.type.editorConfig.collections.axisModes} + options={collections.axisModes} paramName="mode" value={axis.scale.mode} setValue={setValueAxisScale} @@ -123,7 +123,7 @@ export function ValueAxisOptions({ label={i18n.translate('visTypeXy.controls.pointSeries.valueAxes.scaleTypeLabel', { defaultMessage: 'Scale type', })} - options={vis.type.editorConfig.collections.scaleTypes} + options={collections.scaleTypes} paramName="type" value={axis.scale.type} setValue={setValueAxisScale} diff --git a/src/plugins/vis_type_xy/public/editor/components/options/point_series/elastic_charts_options.tsx b/src/plugins/vis_type_xy/public/editor/components/options/point_series/elastic_charts_options.tsx index ecfbdf5b60528a..5398980e268d48 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/point_series/elastic_charts_options.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/point_series/elastic_charts_options.tsx @@ -22,11 +22,14 @@ import { ChartType } from '../../../../../common'; import { VisParams } from '../../../../types'; import { ValidationVisOptionsProps } from '../../common'; import { getPalettesService, getTrackUiMetric } from '../../../../services'; +import { getFittingFunctions } from '../../../collections'; + +const fittingFunctions = getFittingFunctions(); export function ElasticChartsOptions(props: ValidationVisOptionsProps) { const trackUiMetric = getTrackUiMetric(); const [palettesRegistry, setPalettesRegistry] = useState(null); - const { stateParams, setValue, vis, aggs } = props; + const { stateParams, setValue, aggs } = props; const hasLineChart = stateParams.seriesParams.some( ({ type, data: { id: paramId } }) => @@ -69,7 +72,7 @@ export function ElasticChartsOptions(props: ValidationVisOptionsProps label={i18n.translate('visTypeXy.editors.elasticChartsOptions.missingValuesLabel', { defaultMessage: 'Fill missing values', })} - options={vis.type.editorConfig.collections.fittingFunctions} + options={fittingFunctions} paramName="fittingFunction" value={stateParams.fittingFunction} setValue={(paramName, value) => { diff --git a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.tsx b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.tsx index 27e940e62489ad..343976651d21e1 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.tsx @@ -20,6 +20,9 @@ import { ThresholdPanel } from './threshold_panel'; import { ChartType } from '../../../../../common'; import { ValidationVisOptionsProps } from '../../common'; import { ElasticChartsOptions } from './elastic_charts_options'; +import { getPositions } from '../../../collections'; + +const legendPositions = getPositions(); export function PointSeriesOptions( props: ValidationVisOptionsProps< @@ -54,7 +57,7 @@ export function PointSeriesOptions( - + {vis.data.aggs!.aggs.some( (agg) => agg.schema === 'segment' && agg.type.name === BUCKET_TYPES.DATE_HISTOGRAM diff --git a/src/plugins/vis_type_xy/public/editor/components/options/point_series/threshold_panel.tsx b/src/plugins/vis_type_xy/public/editor/components/options/point_series/threshold_panel.tsx index 943280b1373fb7..dadbe4dd1fc768 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/point_series/threshold_panel.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/point_series/threshold_panel.tsx @@ -19,12 +19,14 @@ import { } from '../../../../../../vis_default_editor/public'; import { ValidationVisOptionsProps } from '../../common'; import { VisParams } from '../../../../types'; +import { getThresholdLineStyles } from '../../../collections'; + +const thresholdLineStyles = getThresholdLineStyles(); function ThresholdPanel({ stateParams, setValue, setMultipleValidity, - vis, }: ValidationVisOptionsProps) { const setThresholdLine = useCallback( ( @@ -94,7 +96,7 @@ function ThresholdPanel({ label={i18n.translate('visTypeXy.editors.pointSeries.thresholdLine.styleLabel', { defaultMessage: 'Line style', })} - options={vis.type.editorConfig.collections.thresholdLineStyles} + options={thresholdLineStyles} paramName="style" value={stateParams.thresholdLine.style} setValue={setThresholdLine} diff --git a/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts b/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts index b45c30b46c79eb..c425eb71117e85 100644 --- a/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts @@ -1417,128 +1417,6 @@ export const sampleAreaVis = { }, }, editorConfig: { - collections: { - legendPositions: [ - { - text: 'Top', - value: 'top', - }, - { - text: 'Left', - value: 'left', - }, - { - text: 'Right', - value: 'right', - }, - { - text: 'Bottom', - value: 'bottom', - }, - ], - positions: [ - { - text: 'Top', - value: 'top', - }, - { - text: 'Left', - value: 'left', - }, - { - text: 'Right', - value: 'right', - }, - { - text: 'Bottom', - value: 'bottom', - }, - ], - chartTypes: [ - { - text: 'Line', - value: 'line', - }, - { - text: 'Area', - value: 'area', - }, - { - text: 'Bar', - value: 'histogram', - }, - ], - axisModes: [ - { - text: 'Normal', - value: 'normal', - }, - { - text: 'Percentage', - value: 'percentage', - }, - { - text: 'Wiggle', - value: 'wiggle', - }, - { - text: 'Silhouette', - value: 'silhouette', - }, - ], - scaleTypes: [ - { - text: 'Linear', - value: 'linear', - }, - { - text: 'Log', - value: 'log', - }, - { - text: 'Square root', - value: 'square root', - }, - ], - chartModes: [ - { - text: 'Normal', - value: 'normal', - }, - { - text: 'Stacked', - value: 'stacked', - }, - ], - interpolationModes: [ - { - text: 'Straight', - value: 'linear', - }, - { - text: 'Smoothed', - value: 'cardinal', - }, - { - text: 'Stepped', - value: 'step-after', - }, - ], - thresholdLineStyles: [ - { - value: 'full', - text: 'Full', - }, - { - value: 'dashed', - text: 'Dashed', - }, - { - value: 'dot-dashed', - text: 'Dot-dashed', - }, - ], - }, optionTabs: [ { name: 'advanced', diff --git a/src/plugins/vis_type_xy/public/vis_types/area.ts b/src/plugins/vis_type_xy/public/vis_types/area.ts index a118afb12d2496..a61c25bbc075a4 100644 --- a/src/plugins/vis_type_xy/public/vis_types/area.ts +++ b/src/plugins/vis_type_xy/public/vis_types/area.ts @@ -26,7 +26,6 @@ import { } from '../types'; import { toExpressionAst } from '../to_ast'; import { ChartType } from '../../common'; -import { getConfigCollections } from '../editor/collections'; import { getOptionTabs } from '../editor/common_config'; export const getAreaVisTypeDefinition = ( @@ -126,7 +125,6 @@ export const getAreaVisTypeDefinition = ( }, }, editorConfig: { - collections: getConfigCollections(), optionTabs: getOptionTabs(showElasticChartsOptions), schemas: [ { diff --git a/src/plugins/vis_type_xy/public/vis_types/histogram.ts b/src/plugins/vis_type_xy/public/vis_types/histogram.ts index 72d34f70b1a13f..2c2a83b48802d3 100644 --- a/src/plugins/vis_type_xy/public/vis_types/histogram.ts +++ b/src/plugins/vis_type_xy/public/vis_types/histogram.ts @@ -25,7 +25,6 @@ import { } from '../types'; import { toExpressionAst } from '../to_ast'; import { ChartType } from '../../common'; -import { getConfigCollections } from '../editor/collections'; import { getOptionTabs } from '../editor/common_config'; import { defaultCountLabel, LabelRotation } from '../../../charts/public'; @@ -129,7 +128,6 @@ export const getHistogramVisTypeDefinition = ( }, }, editorConfig: { - collections: getConfigCollections(), optionTabs: getOptionTabs(showElasticChartsOptions), schemas: [ { diff --git a/src/plugins/vis_type_xy/public/vis_types/horizontal_bar.ts b/src/plugins/vis_type_xy/public/vis_types/horizontal_bar.ts index 751803c07aa8de..75c4ddd75d0b3b 100644 --- a/src/plugins/vis_type_xy/public/vis_types/horizontal_bar.ts +++ b/src/plugins/vis_type_xy/public/vis_types/horizontal_bar.ts @@ -25,7 +25,6 @@ import { } from '../types'; import { toExpressionAst } from '../to_ast'; import { ChartType } from '../../common'; -import { getConfigCollections } from '../editor/collections'; import { getOptionTabs } from '../editor/common_config'; import { defaultCountLabel, LabelRotation } from '../../../charts/public'; @@ -128,7 +127,6 @@ export const getHorizontalBarVisTypeDefinition = ( }, }, editorConfig: { - collections: getConfigCollections(), optionTabs: getOptionTabs(showElasticChartsOptions), schemas: [ { diff --git a/src/plugins/vis_type_xy/public/vis_types/line.ts b/src/plugins/vis_type_xy/public/vis_types/line.ts index 75e4ebe09e3f76..87165a20592e59 100644 --- a/src/plugins/vis_type_xy/public/vis_types/line.ts +++ b/src/plugins/vis_type_xy/public/vis_types/line.ts @@ -26,7 +26,6 @@ import { } from '../types'; import { toExpressionAst } from '../to_ast'; import { ChartType } from '../../common'; -import { getConfigCollections } from '../editor/collections'; import { getOptionTabs } from '../editor/common_config'; export const getLineVisTypeDefinition = ( @@ -126,7 +125,6 @@ export const getLineVisTypeDefinition = ( }, }, editorConfig: { - collections: getConfigCollections(), optionTabs: getOptionTabs(showElasticChartsOptions), schemas: [ { diff --git a/src/plugins/visualizations/public/types.ts b/src/plugins/visualizations/public/types.ts index 8dceee8e0010ad..6241f9ee4ae122 100644 --- a/src/plugins/visualizations/public/types.ts +++ b/src/plugins/visualizations/public/types.ts @@ -20,7 +20,6 @@ import { PersistedState } from './persisted_state'; import { VisParams } from '../common'; export { Vis, SerializedVis, VisParams }; - export interface SavedVisState { title: string; type: string; diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx index c13c5d9accef33..c772554344cb26 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.tsx +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -40,6 +40,7 @@ export const VisualizeListing = () => { savedObjectsTagging, uiSettings, visualizeCapabilities, + dashboardCapabilities, kbnUrlStateStorage, }, } = useKibana(); @@ -172,11 +173,12 @@ export const VisualizeListing = () => { return ( <> - {dashboard.dashboardFeatureFlagConfig.allowByValueEmbeddables && ( -
- -
- )} + {dashboard.dashboardFeatureFlagConfig.allowByValueEmbeddables && + dashboardCapabilities.createNew && ( +
+ +
+ )} diff --git a/src/plugins/visualize/public/application/types.ts b/src/plugins/visualize/public/application/types.ts index d20553ee73e9c7..67c3d22d954265 100644 --- a/src/plugins/visualize/public/application/types.ts +++ b/src/plugins/visualize/public/application/types.ts @@ -83,7 +83,8 @@ export interface VisualizeServices extends CoreStart { navigation: NavigationStart; toastNotifications: ToastsStart; share?: SharePluginStart; - visualizeCapabilities: any; + visualizeCapabilities: Record>; + dashboardCapabilities: Record>; visualizations: VisualizationsStart; savedObjectsPublic: SavedObjectsStart; savedVisualizations: VisualizationsStart['savedVisualizationsLoader']; diff --git a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx index 3fd6fd15e36671..9ea42e8b565597 100644 --- a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx @@ -250,7 +250,7 @@ export const getTopNavConfig = ( share.toggleShareContextMenu({ anchorElement, allowEmbed: true, - allowShortUrl: visualizeCapabilities.createShortUrl, + allowShortUrl: Boolean(visualizeCapabilities.createShortUrl), shareableUrl: unhashUrl(window.location.href), objectId: savedVis?.id, objectType: 'visualization', diff --git a/src/plugins/visualize/public/application/visualize_constants.ts b/src/plugins/visualize/public/application/visualize_constants.ts index c74cabdb9fe824..7dbf5be77b74d7 100644 --- a/src/plugins/visualize/public/application/visualize_constants.ts +++ b/src/plugins/visualize/public/application/visualize_constants.ts @@ -9,6 +9,7 @@ export const APP_NAME = 'visualize'; export const VisualizeConstants = { + VISUALIZE_BASE_PATH: '/app/visualize', LANDING_PAGE_PATH: '/', WIZARD_STEP_1_PAGE_PATH: '/new', WIZARD_STEP_2_PAGE_PATH: '/new/configure', diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index d93601ccd673ef..1cad0ca7ca3968 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -78,6 +78,7 @@ export class VisualizePlugin private appStateUpdater = new BehaviorSubject(() => ({})); private stopUrlTracking: (() => void) | undefined = undefined; private currentHistory: ScopedHistory | undefined = undefined; + private isLinkedToOriginatingApp: (() => boolean) | undefined = undefined; private readonly visEditorsRegistry = createVisEditorsRegistry(); @@ -94,7 +95,7 @@ export class VisualizePlugin setActiveUrl, restorePreviousUrl, } = createKbnUrlTracker({ - baseUrl: core.http.basePath.prepend('/app/visualize'), + baseUrl: core.http.basePath.prepend(VisualizeConstants.VISUALIZE_BASE_PATH), defaultSubUrl: '#/', storageKey: `lastUrl:${core.http.basePath.get()}:visualize`, navLinkUpdater$: this.appStateUpdater, @@ -114,6 +115,15 @@ export class VisualizePlugin }, ], getHistory: () => this.currentHistory!, + onBeforeNavLinkSaved: (urlToSave: string) => { + if ( + !urlToSave.includes(`${VisualizeConstants.EDIT_PATH}/`) && + this.isLinkedToOriginatingApp?.() + ) { + return core.http.basePath.prepend(VisualizeConstants.VISUALIZE_BASE_PATH); + } + return urlToSave; + }, }); this.stopUrlTracking = () => { stopUrlTracker(); @@ -134,6 +144,13 @@ export class VisualizePlugin const [coreStart, pluginsStart] = await core.getStartServices(); this.currentHistory = params.history; + // allows the urlTracker to only save URLs that are not linked to an originatingApp + this.isLinkedToOriginatingApp = () => { + return Boolean( + pluginsStart.embeddable.getStateTransfer().getIncomingEditorState()?.originatingApp + ); + }; + // make sure the index pattern list is up to date pluginsStart.data.indexPatterns.clearCache(); // make sure a default index pattern exists @@ -172,6 +189,7 @@ export class VisualizePlugin share: pluginsStart.share, toastNotifications: coreStart.notifications.toasts, visualizeCapabilities: coreStart.application.capabilities.visualize, + dashboardCapabilities: coreStart.application.capabilities.dashboard, visualizations: pluginsStart.visualizations, embeddable: pluginsStart.embeddable, stateTransferService: pluginsStart.embeddable.getStateTransfer(), diff --git a/test/accessibility/apps/kibana_overview.ts b/test/accessibility/apps/kibana_overview.ts index a6ecd491f169f4..8481e2bf334aa6 100644 --- a/test/accessibility/apps/kibana_overview.ts +++ b/test/accessibility/apps/kibana_overview.ts @@ -16,7 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); before(async () => { - await esArchiver.load('empty_kibana'); + await esArchiver.emptyKibanaIndex(); await PageObjects.common.navigateToApp('kibanaOverview'); }); @@ -25,7 +25,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { useActualUrl: true, }); await PageObjects.home.removeSampleDataSet('flights'); - await esArchiver.unload('empty_kibana'); }); it('Getting started view', async () => { diff --git a/test/accessibility/services/a11y/analyze_with_axe.js b/test/accessibility/services/a11y/analyze_with_axe.js index 301d03ec17fb14..3d1e257235f559 100644 --- a/test/accessibility/services/a11y/analyze_with_axe.js +++ b/test/accessibility/services/a11y/analyze_with_axe.js @@ -30,6 +30,10 @@ export function analyzeWithAxe(context, options, callback) { id: 'aria-roles', selector: '[data-test-subj="comboBoxSearchInput"] *', }, + { + id: 'aria-required-parent', + selector: '[class=*"euiDataGridRowCell"][role="gridcell"] ', + }, ], }); return window.axe.run(context, options); diff --git a/test/api_integration/apis/home/sample_data.ts b/test/api_integration/apis/home/sample_data.ts index 64ef11167b3338..b889b59fdaf329 100644 --- a/test/api_integration/apis/home/sample_data.ts +++ b/test/api_integration/apis/home/sample_data.ts @@ -11,11 +11,15 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); const es = getService('es'); const MILLISECOND_IN_WEEK = 1000 * 60 * 60 * 24 * 7; describe('sample data apis', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + }); describe('list', () => { it('should return list of sample data sets with installed status', async () => { const resp = await supertest.get(`/api/sample_data`).set('kbn-xsrf', 'kibana').expect(200); diff --git a/test/api_integration/apis/index_patterns/fields_for_wildcard_route/response.js b/test/api_integration/apis/index_patterns/fields_for_wildcard_route/response.js index e84052e58dac48..87c5aa535ccd98 100644 --- a/test/api_integration/apis/index_patterns/fields_for_wildcard_route/response.js +++ b/test/api_integration/apis/index_patterns/fields_for_wildcard_route/response.js @@ -17,6 +17,55 @@ export default function ({ getService }) { expect(resp.body.fields).to.eql(sortBy(resp.body.fields, 'name')); }; + const testFields = [ + { + type: 'boolean', + esTypes: ['boolean'], + searchable: true, + aggregatable: true, + name: 'bar', + readFromDocValues: true, + }, + { + type: 'string', + esTypes: ['text'], + searchable: true, + aggregatable: false, + name: 'baz', + readFromDocValues: false, + }, + { + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + name: 'baz.keyword', + readFromDocValues: true, + subType: { multi: { parent: 'baz' } }, + }, + { + type: 'number', + esTypes: ['long'], + searchable: true, + aggregatable: true, + name: 'foo', + readFromDocValues: true, + }, + { + aggregatable: true, + esTypes: ['keyword'], + name: 'nestedField.child', + readFromDocValues: true, + searchable: true, + subType: { + nested: { + path: 'nestedField', + }, + }, + type: 'string', + }, + ]; + describe('fields_for_wildcard_route response', () => { before(() => esArchiver.load('index_patterns/basic_index')); after(() => esArchiver.unload('index_patterns/basic_index')); @@ -26,54 +75,7 @@ export default function ({ getService }) { .get('/api/index_patterns/_fields_for_wildcard') .query({ pattern: 'basic_index' }) .expect(200, { - fields: [ - { - type: 'boolean', - esTypes: ['boolean'], - searchable: true, - aggregatable: true, - name: 'bar', - readFromDocValues: true, - }, - { - type: 'string', - esTypes: ['text'], - searchable: true, - aggregatable: false, - name: 'baz', - readFromDocValues: false, - }, - { - type: 'string', - esTypes: ['keyword'], - searchable: true, - aggregatable: true, - name: 'baz.keyword', - readFromDocValues: true, - subType: { multi: { parent: 'baz' } }, - }, - { - type: 'number', - esTypes: ['long'], - searchable: true, - aggregatable: true, - name: 'foo', - readFromDocValues: true, - }, - { - aggregatable: true, - esTypes: ['keyword'], - name: 'nestedField.child', - readFromDocValues: true, - searchable: true, - subType: { - nested: { - path: 'nestedField', - }, - }, - type: 'string', - }, - ], + fields: testFields, }) .then(ensureFieldsAreSorted); }); @@ -162,11 +164,19 @@ export default function ({ getService }) { .then(ensureFieldsAreSorted); }); - it('returns 404 when the pattern does not exist', async () => { + it('returns fields when one pattern exists and the other does not', async () => { + await supertest + .get('/api/index_patterns/_fields_for_wildcard') + .query({ pattern: 'bad_index,basic_index' }) + .expect(200, { + fields: testFields, + }); + }); + it('returns 404 when no patterns exist', async () => { await supertest .get('/api/index_patterns/_fields_for_wildcard') .query({ - pattern: '[non-existing-pattern]its-invalid-*', + pattern: 'bad_index', }) .expect(404); }); diff --git a/test/api_integration/apis/saved_objects/bulk_create.ts b/test/api_integration/apis/saved_objects/bulk_create.ts index 6239b930434aff..57b7ff0935f587 100644 --- a/test/api_integration/apis/saved_objects/bulk_create.ts +++ b/test/api_integration/apis/saved_objects/bulk_create.ts @@ -97,10 +97,11 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await esDeleteAllIndices('.kibana') + await esDeleteAllIndices('.kibana*') ); - it('should return 200 with individual responses', async () => + it('should return 200 with errors', async () => { + await new Promise((resolve) => setTimeout(resolve, 2000)); await supertest .post('/api/saved_objects/_bulk_create') .send(BULK_REQUESTS) @@ -109,38 +110,27 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body).to.eql({ saved_objects: [ { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - updated_at: resp.body.saved_objects[0].updated_at, - version: resp.body.saved_objects[0].version, - attributes: { - title: 'An existing visualization', - }, - references: [], - namespaces: ['default'], - migrationVersion: { - visualization: resp.body.saved_objects[0].migrationVersion.visualization, + id: BULK_REQUESTS[0].id, + type: BULK_REQUESTS[0].type, + error: { + error: 'Internal Server Error', + message: 'An internal server error occurred', + statusCode: 500, }, - coreMigrationVersion: KIBANA_VERSION, // updated from 1.2.3 to the latest kibana version }, { - type: 'dashboard', - id: 'a01b2f57-fcfd-4864-b735-09e28f0d815e', - updated_at: resp.body.saved_objects[1].updated_at, - version: resp.body.saved_objects[1].version, - attributes: { - title: 'A great new dashboard', - }, - references: [], - namespaces: ['default'], - migrationVersion: { - dashboard: resp.body.saved_objects[1].migrationVersion.dashboard, + id: BULK_REQUESTS[1].id, + type: BULK_REQUESTS[1].type, + error: { + error: 'Internal Server Error', + message: 'An internal server error occurred', + statusCode: 500, }, - coreMigrationVersion: KIBANA_VERSION, }, ], }); - })); + }); + }); }); }); } diff --git a/test/api_integration/apis/saved_objects/bulk_get.ts b/test/api_integration/apis/saved_objects/bulk_get.ts index e9514d7d55457a..77f84dee25ded1 100644 --- a/test/api_integration/apis/saved_objects/bulk_get.ts +++ b/test/api_integration/apis/saved_objects/bulk_get.ts @@ -108,7 +108,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await esDeleteAllIndices('.kibana') + await esDeleteAllIndices('.kibana*') ); it('should return 200 with individual responses', async () => diff --git a/test/api_integration/apis/saved_objects/bulk_update.ts b/test/api_integration/apis/saved_objects/bulk_update.ts index d9e3c278695910..a5f5262196346e 100644 --- a/test/api_integration/apis/saved_objects/bulk_update.ts +++ b/test/api_integration/apis/saved_objects/bulk_update.ts @@ -235,10 +235,10 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await esDeleteAllIndices('.kibana') + await esDeleteAllIndices('.kibana*') ); - it('should return generic 404', async () => { + it('should return 200 with errors', async () => { const response = await supertest .put(`/api/saved_objects/_bulk_update`) .send([ @@ -267,9 +267,9 @@ export default function ({ getService }: FtrProviderContext) { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', type: 'visualization', error: { - statusCode: 404, - error: 'Not Found', - message: 'Saved object [visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab] not found', + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', }, }); @@ -277,9 +277,9 @@ export default function ({ getService }: FtrProviderContext) { id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', type: 'dashboard', error: { - statusCode: 404, - error: 'Not Found', - message: 'Saved object [dashboard/be3733a0-9efe-11e7-acb3-3dab96693fab] not found', + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', }, }); }); diff --git a/test/api_integration/apis/saved_objects/create.ts b/test/api_integration/apis/saved_objects/create.ts index 355e5df1f18958..de31b621a64803 100644 --- a/test/api_integration/apis/saved_objects/create.ts +++ b/test/api_integration/apis/saved_objects/create.ts @@ -83,10 +83,10 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await esDeleteAllIndices('.kibana') + await esDeleteAllIndices('.kibana*') ); - it('should return 200 and create kibana index', async () => { + it('should return 500 and not auto-create saved objects index', async () => { await supertest .post(`/api/saved_objects/visualization`) .send({ @@ -94,50 +94,16 @@ export default function ({ getService }: FtrProviderContext) { title: 'My favorite vis', }, }) - .expect(200) + .expect(500) .then((resp) => { - // loose uuid validation - expect(resp.body) - .to.have.property('id') - .match(/^[0-9a-f-]{36}$/); - - // loose ISO8601 UTC time with milliseconds validation - expect(resp.body) - .to.have.property('updated_at') - .match(/^[\d-]{10}T[\d:\.]{12}Z$/); - expect(resp.body).to.eql({ - id: resp.body.id, - type: 'visualization', - migrationVersion: resp.body.migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - updated_at: resp.body.updated_at, - version: resp.body.version, - attributes: { - title: 'My favorite vis', - }, - references: [], - namespaces: ['default'], + error: 'Internal Server Error', + message: 'An internal server error occurred.', + statusCode: 500, }); - expect(resp.body.migrationVersion).to.be.ok(); }); - expect((await es.indices.exists({ index: '.kibana' })).body).to.be(true); - }); - - it('result should have the latest coreMigrationVersion', async () => { - await supertest - .post(`/api/saved_objects/visualization`) - .send({ - attributes: { - title: 'My favorite vis', - }, - coreMigrationVersion: '1.2.3', - }) - .expect(200) - .then((resp) => { - expect(resp.body.coreMigrationVersion).to.eql(KIBANA_VERSION); - }); + expect((await es.indices.exists({ index: '.kibana' })).body).to.be(false); }); }); }); diff --git a/test/api_integration/apis/saved_objects/delete.ts b/test/api_integration/apis/saved_objects/delete.ts index 5247bc74131d43..0dfece825d3a10 100644 --- a/test/api_integration/apis/saved_objects/delete.ts +++ b/test/api_integration/apis/saved_objects/delete.ts @@ -44,7 +44,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await esDeleteAllIndices('.kibana') + await esDeleteAllIndices('.kibana*') ); it('returns generic 404 when kibana index is missing', async () => diff --git a/test/api_integration/apis/saved_objects/export.ts b/test/api_integration/apis/saved_objects/export.ts index 32a72f374cbe17..5206d51054745c 100644 --- a/test/api_integration/apis/saved_objects/export.ts +++ b/test/api_integration/apis/saved_objects/export.ts @@ -534,7 +534,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await esDeleteAllIndices('.kibana') + await esDeleteAllIndices('.kibana*') ); it('should return empty response', async () => { diff --git a/test/api_integration/apis/saved_objects/find.ts b/test/api_integration/apis/saved_objects/find.ts index be31e0faf1e467..66c2a083c79e51 100644 --- a/test/api_integration/apis/saved_objects/find.ts +++ b/test/api_integration/apis/saved_objects/find.ts @@ -40,7 +40,7 @@ export default function ({ getService }: FtrProviderContext) { { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzIsMV0=', + version: 'WzE4LDJd', attributes: { title: 'Count of requests', }, @@ -137,7 +137,7 @@ export default function ({ getService }: FtrProviderContext) { { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzIsMV0=', + version: 'WzE4LDJd', attributes: { title: 'Count of requests', }, @@ -174,7 +174,7 @@ export default function ({ getService }: FtrProviderContext) { { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzIsMV0=', + version: 'WzE4LDJd', attributes: { title: 'Count of requests', }, @@ -209,7 +209,7 @@ export default function ({ getService }: FtrProviderContext) { score: 0, type: 'visualization', updated_at: '2017-09-21T18:51:23.794Z', - version: 'WzYsMV0=', + version: 'WzIyLDJd', }, ], }); @@ -256,7 +256,7 @@ export default function ({ getService }: FtrProviderContext) { migrationVersion: resp.body.saved_objects[0].migrationVersion, coreMigrationVersion: KIBANA_VERSION, updated_at: '2017-09-21T18:51:23.794Z', - version: 'WzIsMV0=', + version: 'WzE4LDJd', }, ], }); @@ -426,11 +426,11 @@ export default function ({ getService }: FtrProviderContext) { })); }); - describe.skip('without kibana index', () => { + describe('without kibana index', () => { before( async () => // just in case the kibana server has recreated it - await esDeleteAllIndices('.kibana') + await esDeleteAllIndices('.kibana*') ); it('should return 200 with empty response', async () => diff --git a/test/api_integration/apis/saved_objects/get.ts b/test/api_integration/apis/saved_objects/get.ts index f912a2efcf0d9c..84ab6e36956d5d 100644 --- a/test/api_integration/apis/saved_objects/get.ts +++ b/test/api_integration/apis/saved_objects/get.ts @@ -78,7 +78,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await esDeleteAllIndices('.kibana') + await esDeleteAllIndices('.kibana*') ); it('should return basic 404 without mentioning index', async () => diff --git a/test/api_integration/apis/saved_objects/resolve_import_errors.ts b/test/api_integration/apis/saved_objects/resolve_import_errors.ts index 4fcce29905beb1..b203a2c7b7071b 100644 --- a/test/api_integration/apis/saved_objects/resolve_import_errors.ts +++ b/test/api_integration/apis/saved_objects/resolve_import_errors.ts @@ -13,6 +13,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); + const esDeleteAllIndices = getService('esDeleteAllIndices'); describe('resolve_import_errors', () => { // mock success results including metadata @@ -34,7 +35,11 @@ export default function ({ getService }: FtrProviderContext) { describe('without kibana index', () => { // Cleanup data that got created in import - after(() => esArchiver.unload('saved_objects/basic')); + before( + async () => + // just in case the kibana server has recreated it + await esDeleteAllIndices('.kibana*') + ); it('should return 200 and import nothing when empty parameters are passed in', async () => { await supertest @@ -51,7 +56,7 @@ export default function ({ getService }: FtrProviderContext) { }); }); - it('should return 200 and import everything when overwrite parameters contains all objects', async () => { + it('should return 200 with internal server errors', async () => { await supertest .post('/api/saved_objects/_resolve_import_errors') .field( @@ -78,12 +83,42 @@ export default function ({ getService }: FtrProviderContext) { .expect(200) .then((resp) => { expect(resp.body).to.eql({ - success: true, - successCount: 3, - successResults: [ - { ...indexPattern, overwrite: true }, - { ...visualization, overwrite: true }, - { ...dashboard, overwrite: true }, + successCount: 0, + success: false, + errors: [ + { + ...indexPattern, + ...{ title: indexPattern.meta.title }, + overwrite: true, + error: { + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', + type: 'unknown', + }, + }, + { + ...visualization, + ...{ title: visualization.meta.title }, + overwrite: true, + error: { + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', + type: 'unknown', + }, + }, + { + ...dashboard, + ...{ title: dashboard.meta.title }, + overwrite: true, + error: { + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', + type: 'unknown', + }, + }, ], warnings: [], }); diff --git a/test/api_integration/apis/saved_objects/update.ts b/test/api_integration/apis/saved_objects/update.ts index ce14e9cea7b13f..631046a0564a36 100644 --- a/test/api_integration/apis/saved_objects/update.ts +++ b/test/api_integration/apis/saved_objects/update.ts @@ -121,10 +121,10 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await esDeleteAllIndices('.kibana') + await esDeleteAllIndices('.kibana*') ); - it('should return generic 404', async () => + it('should return 500', async () => await supertest .put(`/api/saved_objects/visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab`) .send({ @@ -132,13 +132,12 @@ export default function ({ getService }: FtrProviderContext) { title: 'My second favorite vis', }, }) - .expect(404) + .expect(500) .then((resp) => { expect(resp.body).eql({ - statusCode: 404, - error: 'Not Found', - message: - 'Saved object [visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab] not found', + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred.', }); })); }); diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index 87de59a94fd249..6ab2352ebb05f6 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -42,7 +42,7 @@ export default function ({ getService }: FtrProviderContext) { { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzIsMV0=', + version: 'WzE4LDJd', attributes: { title: 'Count of requests', }, @@ -184,7 +184,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await esDeleteAllIndices('.kibana') + await esDeleteAllIndices('.kibana*') ); it('should return 200 with empty response', async () => diff --git a/test/api_integration/apis/saved_objects_management/get.ts b/test/api_integration/apis/saved_objects_management/get.ts index 69c85428d06247..4dfd06a61eecf5 100644 --- a/test/api_integration/apis/saved_objects_management/get.ts +++ b/test/api_integration/apis/saved_objects_management/get.ts @@ -45,7 +45,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await esDeleteAllIndices('.kibana') + await esDeleteAllIndices('.kibana*') ); it('should return 404 for object that no longer exists', async () => diff --git a/test/api_integration/apis/search/search.ts b/test/api_integration/apis/search/search.ts index 2b61ed7586384d..bc092dd3889bb8 100644 --- a/test/api_integration/apis/search/search.ts +++ b/test/api_integration/apis/search/search.ts @@ -17,6 +17,7 @@ export default function ({ getService }: FtrProviderContext) { describe('search', () => { before(async () => { + await esArchiver.emptyKibanaIndex(); await esArchiver.loadIfNeeded('../../../functional/fixtures/es_archiver/logstash_functional'); }); diff --git a/test/api_integration/apis/telemetry/opt_in.ts b/test/api_integration/apis/telemetry/opt_in.ts index 2e42fbfc6ac60a..7e0564ac44a43b 100644 --- a/test/api_integration/apis/telemetry/opt_in.ts +++ b/test/api_integration/apis/telemetry/opt_in.ts @@ -14,10 +14,13 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function optInTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const kibanaServer = getService('kibanaServer'); + const esArchiver = getService('esArchiver'); + describe('/api/telemetry/v2/optIn API', () => { let defaultAttributes: TelemetrySavedObjectAttributes; let kibanaVersion: any; before(async () => { + await esArchiver.emptyKibanaIndex(); const kibanaVersionAccessor = kibanaServer.version; kibanaVersion = await kibanaVersionAccessor.get(); defaultAttributes = diff --git a/test/api_integration/apis/telemetry/telemetry_local.ts b/test/api_integration/apis/telemetry/telemetry_local.ts index 23a0d3fb2cd3cc..b424cab9ff45b2 100644 --- a/test/api_integration/apis/telemetry/telemetry_local.ts +++ b/test/api_integration/apis/telemetry/telemetry_local.ts @@ -177,6 +177,7 @@ export default function ({ getService }: FtrProviderContext) { describe('basic behaviour', () => { let savedObjectIds: string[] = []; before('create application usage entries', async () => { + await esArchiver.emptyKibanaIndex(); savedObjectIds = await Promise.all([ createSavedObject(), createSavedObject('appView1'), diff --git a/test/api_integration/apis/ui_counters/ui_counters.ts b/test/api_integration/apis/ui_counters/ui_counters.ts index 881fa33f5fbc03..c287e73e3ace9c 100644 --- a/test/api_integration/apis/ui_counters/ui_counters.ts +++ b/test/api_integration/apis/ui_counters/ui_counters.ts @@ -13,6 +13,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); const es = getService('es'); const createUiCounterEvent = (eventName: string, type: UiCounterMetricType, count = 1) => ({ @@ -22,7 +23,11 @@ export default function ({ getService }: FtrProviderContext) { count, }); - describe('UI Counters API', () => { + // FLAKY: https://github.com/elastic/kibana/issues/85086 + describe.skip('UI Counters API', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + }); const dayDate = moment().format('DDMMYYYY'); it('stores ui counter events in savedObjects', async () => { diff --git a/test/api_integration/apis/ui_metric/ui_metric.ts b/test/api_integration/apis/ui_metric/ui_metric.ts index 1e80487da551a3..99007376e1ea49 100644 --- a/test/api_integration/apis/ui_metric/ui_metric.ts +++ b/test/api_integration/apis/ui_metric/ui_metric.ts @@ -13,6 +13,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); const es = getService('es'); const createStatsMetric = ( @@ -34,6 +35,10 @@ export default function ({ getService }: FtrProviderContext) { }); describe('ui_metric savedObject data', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + }); + it('increments the count field in the document defined by the {app}/{action_type} path', async () => { const reportManager = new ReportManager(); const uiStatsMetric = createStatsMetric('myEvent'); diff --git a/test/common/config.js b/test/common/config.js index 451324d46f62de..9d108f05fd1fca 100644 --- a/test/common/config.js +++ b/test/common/config.js @@ -61,8 +61,6 @@ export default function () { ...(!!process.env.CODE_COVERAGE ? [`--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'coverage')}`] : []), - // Disable v2 migrations in tests for now - '--migrations.enableV2=false', ], }, services, diff --git a/test/common/services/kibana_server/extend_es_archiver.js b/test/common/services/kibana_server/extend_es_archiver.js index c2d01eef267bc8..9a06dd7b749699 100644 --- a/test/common/services/kibana_server/extend_es_archiver.js +++ b/test/common/services/kibana_server/extend_es_archiver.js @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -const ES_ARCHIVER_LOAD_METHODS = ['load', 'loadIfNeeded', 'unload']; +const ES_ARCHIVER_LOAD_METHODS = ['load', 'loadIfNeeded', 'unload', 'emptyKibanaIndex']; const KIBANA_INDEX = '.kibana'; export function extendEsArchiver({ esArchiver, kibanaServer, retry, defaults }) { @@ -25,7 +25,7 @@ export function extendEsArchiver({ esArchiver, kibanaServer, retry, defaults }) const statsKeys = Object.keys(stats); const kibanaKeys = statsKeys.filter( // this also matches stats keys like '.kibana_1' and '.kibana_2,.kibana_1' - (key) => key.includes(KIBANA_INDEX) && (stats[key].created || stats[key].deleted) + (key) => key.includes(KIBANA_INDEX) && stats[key].created ); // if the kibana index was created by the esArchiver then update the uiSettings diff --git a/test/functional/apps/console/_console.ts b/test/functional/apps/console/_console.ts index 6aeb1e2a624ada..05933ebf1ea2a6 100644 --- a/test/functional/apps/console/_console.ts +++ b/test/functional/apps/console/_console.ts @@ -85,7 +85,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.console.dismissTutorial(); expect(await PageObjects.console.hasAutocompleter()).to.be(false); await PageObjects.console.promptAutocomplete(); - retry.waitFor('autocomplete to be visible', () => PageObjects.console.hasAutocompleter()); + await retry.waitFor('autocomplete to be visible', () => + PageObjects.console.hasAutocompleter() + ); }); }); } diff --git a/test/functional/apps/dashboard/edit_visualizations.js b/test/functional/apps/dashboard/edit_visualizations.js index ab8de37122bb7d..0996fbe7cf0d78 100644 --- a/test/functional/apps/dashboard/edit_visualizations.js +++ b/test/functional/apps/dashboard/edit_visualizations.js @@ -12,6 +12,7 @@ export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'common', 'visEditor']); const esArchiver = getService('esArchiver'); const testSubjects = getService('testSubjects'); + const appsMenu = getService('appsMenu'); const kibanaServer = getService('kibanaServer'); const dashboardPanelActions = getService('dashboardPanelActions'); const dashboardVisualizations = getService('dashboardVisualizations'); @@ -25,10 +26,14 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visualize.clickMarkdownWidget(); await PageObjects.visEditor.setMarkdownTxt(originalMarkdownText); await PageObjects.visEditor.clickGo(); - await PageObjects.visualize.saveVisualizationExpectSuccess(title, { - saveAsNew: true, - redirectToOrigin: true, - }); + if (title) { + await PageObjects.visualize.saveVisualizationExpectSuccess(title, { + saveAsNew: true, + redirectToOrigin: true, + }); + } else { + await PageObjects.visualize.saveVisualizationAndReturn(); + } }; const editMarkdownVis = async () => { @@ -86,5 +91,22 @@ export default function ({ getService, getPageObjects }) { const markdownText = await testSubjects.find('markdownBody'); expect(await markdownText.getVisibleText()).to.eql(originalMarkdownText); }); + + it('visualize app menu navigates to the visualize listing page if the last opened visualization was by value', async () => { + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.dashboard.clickNewDashboard(); + + // Create markdown by value. + await createMarkdownVis(); + + // Edit then save and return + await editMarkdownVis(); + await PageObjects.visualize.saveVisualizationAndReturn(); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await appsMenu.clickLink('Visualize'); + await PageObjects.common.clickConfirmOnModal(); + expect(await testSubjects.exists('visualizationLandingPage')).to.be(true); + }); }); } diff --git a/test/functional/apps/dashboard/embeddable_data_grid.ts b/test/functional/apps/dashboard/embeddable_data_grid.ts index a81f8551988432..54fa9f08c5763f 100644 --- a/test/functional/apps/dashboard/embeddable_data_grid.ts +++ b/test/functional/apps/dashboard/embeddable_data_grid.ts @@ -36,10 +36,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('saved search filters', function () { it('are added when a cell filter is clicked', async function () { await dashboardAddPanel.addSavedSearch('Rendering-Test:-saved-search'); - await find.clickByCssSelector(`[role="gridcell"]:nth-child(2)`); + await find.clickByCssSelector(`[role="gridcell"]:nth-child(3)`); + // needs a short delay between becoming visible & being clickable + await PageObjects.common.sleep(250); await find.clickByCssSelector(`[data-test-subj="filterOutButton"]`); await PageObjects.header.waitUntilLoadingHasFinished(); - await find.clickByCssSelector(`[role="gridcell"]:nth-child(2)`); + await find.clickByCssSelector(`[role="gridcell"]:nth-child(3)`); + await PageObjects.common.sleep(250); await find.clickByCssSelector(`[data-test-subj="filterForButton"]`); const filterCount = await filterBar.getFilterCount(); expect(filterCount).to.equal(2); diff --git a/test/functional/apps/discover/_data_grid_context.ts b/test/functional/apps/discover/_data_grid_context.ts index 898efff558702b..8f817dbea35c3d 100644 --- a/test/functional/apps/discover/_data_grid_context.ts +++ b/test/functional/apps/discover/_data_grid_context.ts @@ -27,6 +27,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('discover data grid context tests', () => { before(async () => { + await esArchiver.load('discover'); await esArchiver.loadIfNeeded('logstash_functional'); await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await kibanaServer.uiSettings.update(defaultSettings); diff --git a/test/functional/apps/discover/_data_grid_doc_table.ts b/test/functional/apps/discover/_data_grid_doc_table.ts index 1775b096fecd80..5eeafc4d78f670 100644 --- a/test/functional/apps/discover/_data_grid_doc_table.ts +++ b/test/functional/apps/discover/_data_grid_doc_table.ts @@ -22,8 +22,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }; describe('discover data grid doc table', function describeIndexTests() { - const defaultRowsLimit = 25; - before(async function () { log.debug('load kibana index with default index pattern'); await esArchiver.load('discover'); @@ -38,10 +36,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await kibanaServer.uiSettings.replace({}); }); - it('should show the first 50 rows by default', async function () { + it('should show the first 12 rows by default', async function () { // with the default range the number of hits is ~14000 const rows = await dataGrid.getDocTableRows(); - expect(rows.length).to.be(defaultRowsLimit); + expect(rows.length).to.be(12); }); it('should refresh the table content when changing time window', async function () { diff --git a/test/functional/apps/discover/_data_grid_field_data.ts b/test/functional/apps/discover/_data_grid_field_data.ts index 068ed82a7c6032..e8fcb06d06193b 100644 --- a/test/functional/apps/discover/_data_grid_field_data.ts +++ b/test/functional/apps/discover/_data_grid_field_data.ts @@ -67,9 +67,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dataGrid.clickDocSortAsc(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await retry.try(async function tryingForTime() { - const rowData = await dataGrid.getFields(); - expect(rowData[0][0].startsWith(expectedTimeStamp)).to.be.ok(); + await retry.waitFor('first cell contains expected timestamp', async () => { + const cell = await dataGrid.getCellElement(1, 2); + const text = await cell.getVisibleText(); + return text === expectedTimeStamp; }); }); diff --git a/test/functional/apps/discover/_indexpattern_with_unmapped_fields.ts b/test/functional/apps/discover/_indexpattern_with_unmapped_fields.ts index bad7afacc12457..0990b3fa29f708 100644 --- a/test/functional/apps/discover/_indexpattern_with_unmapped_fields.ts +++ b/test/functional/apps/discover/_indexpattern_with_unmapped_fields.ts @@ -22,8 +22,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before(async () => { await esArchiver.loadIfNeeded('unmapped_fields'); - await kibanaServer.uiSettings.replace({ defaultIndex: 'test-index-unmapped-fields' }); - await kibanaServer.uiSettings.update({ + await kibanaServer.uiSettings.replace({ + defaultIndex: 'test-index-unmapped-fields', 'discover:searchFieldsFromSource': false, }); log.debug('discover'); diff --git a/test/functional/apps/management/_import_objects.ts b/test/functional/apps/management/_import_objects.ts index d13ba0114a598a..e2a056359b48e2 100644 --- a/test/functional/apps/management/_import_objects.ts +++ b/test/functional/apps/management/_import_objects.ts @@ -27,9 +27,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe.skip('import objects', function describeIndexTests() { describe('.ndjson file', () => { beforeEach(async function () { + await esArchiver.load('management'); await kibanaServer.uiSettings.replace({}); await PageObjects.settings.navigateTo(); - await esArchiver.load('management'); await PageObjects.settings.clickKibanaSavedObjects(); }); @@ -213,10 +213,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('.json file', () => { beforeEach(async function () { - // delete .kibana index and then wait for Kibana to re-create it + await esArchiver.load('saved_objects_imports'); await kibanaServer.uiSettings.replace({}); await PageObjects.settings.navigateTo(); - await esArchiver.load('saved_objects_imports'); await PageObjects.settings.clickKibanaSavedObjects(); }); diff --git a/test/functional/apps/management/_index_pattern_filter.js b/test/functional/apps/management/_index_pattern_filter.js index b2d27002f690c5..eeb0b224d5f0ca 100644 --- a/test/functional/apps/management/_index_pattern_filter.js +++ b/test/functional/apps/management/_index_pattern_filter.js @@ -12,10 +12,11 @@ export default function ({ getService, getPageObjects }) { const kibanaServer = getService('kibanaServer'); const retry = getService('retry'); const PageObjects = getPageObjects(['settings']); + const esArchiver = getService('esArchiver'); describe('index pattern filter', function describeIndexTests() { before(async function () { - // delete .kibana index and then wait for Kibana to re-create it + await esArchiver.emptyKibanaIndex(); await kibanaServer.uiSettings.replace({}); await PageObjects.settings.navigateTo(); await PageObjects.settings.clickKibanaIndexPatterns(); diff --git a/test/functional/apps/management/_index_patterns_empty.ts b/test/functional/apps/management/_index_patterns_empty.ts index a58c1298104709..90dd8cdc35c300 100644 --- a/test/functional/apps/management/_index_patterns_empty.ts +++ b/test/functional/apps/management/_index_patterns_empty.ts @@ -19,7 +19,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('index pattern empty view', () => { before(async () => { - await esArchiver.load('empty_kibana'); + await esArchiver.emptyKibanaIndex(); await esArchiver.unload('logstash_functional'); await esArchiver.unload('makelogs'); await kibanaServer.uiSettings.replace({}); @@ -27,7 +27,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); after(async () => { - await esArchiver.unload('empty_kibana'); await esArchiver.loadIfNeeded('makelogs'); // @ts-expect-error await es.transport.request({ diff --git a/test/functional/apps/management/_mgmt_import_saved_objects.js b/test/functional/apps/management/_mgmt_import_saved_objects.js index 27745654f495f9..8697dc49de46a9 100644 --- a/test/functional/apps/management/_mgmt_import_saved_objects.js +++ b/test/functional/apps/management/_mgmt_import_saved_objects.js @@ -18,14 +18,13 @@ export default function ({ getService, getPageObjects }) { describe('mgmt saved objects', function describeIndexTests() { beforeEach(async function () { - await esArchiver.load('empty_kibana'); + await esArchiver.emptyKibanaIndex(); await esArchiver.load('discover'); await PageObjects.settings.navigateTo(); }); afterEach(async function () { await esArchiver.unload('discover'); - await esArchiver.load('empty_kibana'); }); it('should import saved objects mgmt', async function () { diff --git a/test/functional/apps/management/_test_huge_fields.js b/test/functional/apps/management/_test_huge_fields.js index 7ccce2c10c7b1e..3102becbe181f6 100644 --- a/test/functional/apps/management/_test_huge_fields.js +++ b/test/functional/apps/management/_test_huge_fields.js @@ -20,6 +20,7 @@ export default function ({ getService, getPageObjects }) { const EXPECTED_FIELD_COUNT = '10006'; before(async function () { await security.testUser.setRoles(['kibana_admin', 'test_testhuge_reader']); + await esArchiver.emptyKibanaIndex(); await esArchiver.loadIfNeeded('large_fields'); await PageObjects.settings.createIndexPattern('testhuge', 'date'); }); diff --git a/test/functional/apps/management/index.ts b/test/functional/apps/management/index.ts index 15828295b190f0..d31245b5492d1c 100644 --- a/test/functional/apps/management/index.ts +++ b/test/functional/apps/management/index.ts @@ -14,13 +14,11 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { describe('management', function () { before(async () => { await esArchiver.unload('logstash_functional'); - await esArchiver.load('empty_kibana'); await esArchiver.loadIfNeeded('makelogs'); }); after(async () => { await esArchiver.unload('makelogs'); - await esArchiver.unload('empty_kibana'); }); describe('', function () { diff --git a/test/functional/apps/visualize/_data_table.ts b/test/functional/apps/visualize/_data_table.ts index c98126dd018435..0b9cedd0ca94c2 100644 --- a/test/functional/apps/visualize/_data_table.ts +++ b/test/functional/apps/visualize/_data_table.ts @@ -267,14 +267,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should apply correct filter', async () => { - await PageObjects.visChart.filterOnTableCell(1, 3); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const data = await PageObjects.visChart.getTableVisContent(); - expect(data).to.be.eql([ - ['png', '1,373'], - ['gif', '918'], - ['Other', '445'], - ]); + await retry.try(async () => { + await PageObjects.visChart.filterOnTableCell(1, 3); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['png', '1,373'], + ['gif', '918'], + ['Other', '445'], + ]); + }); }); }); diff --git a/test/functional/apps/visualize/_data_table_notimeindex_filters.ts b/test/functional/apps/visualize/_data_table_notimeindex_filters.ts index df3af20fca6134..df219edc1d2d57 100644 --- a/test/functional/apps/visualize/_data_table_notimeindex_filters.ts +++ b/test/functional/apps/visualize/_data_table_notimeindex_filters.ts @@ -14,6 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const filterBar = getService('filterBar'); const renderable = getService('renderable'); + const retry = getService('retry'); const dashboardAddPanel = getService('dashboardAddPanel'); const PageObjects = getPageObjects([ 'common', @@ -66,13 +67,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.clickNewDashboard(); await dashboardAddPanel.addVisualization(vizName1); - // hover and click on cell to filter - await PageObjects.visChart.filterOnTableCell(1, 2); + await retry.try(async () => { + // hover and click on cell to filter + await PageObjects.visChart.filterOnTableCell(1, 2); - await PageObjects.header.waitUntilLoadingHasFinished(); - await renderable.waitForRender(); - const filterCount = await filterBar.getFilterCount(); - expect(filterCount).to.be(1); + await PageObjects.header.waitUntilLoadingHasFinished(); + await renderable.waitForRender(); + const filterCount = await filterBar.getFilterCount(); + expect(filterCount).to.be(1); + }); await filterBar.removeAllFilters(); }); diff --git a/test/functional/apps/visualize/_embedding_chart.ts b/test/functional/apps/visualize/_embedding_chart.ts index 6bf42d5948d4e8..a6f0b21f96b357 100644 --- a/test/functional/apps/visualize/_embedding_chart.ts +++ b/test/functional/apps/visualize/_embedding_chart.ts @@ -14,6 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const filterBar = getService('filterBar'); const renderable = getService('renderable'); const embedding = getService('embedding'); + const retry = getService('retry'); const PageObjects = getPageObjects([ 'visualize', 'visEditor', @@ -80,23 +81,25 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should allow to change timerange from the visualization in embedded mode', async () => { - await PageObjects.visChart.filterOnTableCell(1, 7); - await PageObjects.header.waitUntilLoadingHasFinished(); - await renderable.waitForRender(); + await retry.try(async () => { + await PageObjects.visChart.filterOnTableCell(1, 7); + await PageObjects.header.waitUntilLoadingHasFinished(); + await renderable.waitForRender(); - const data = await PageObjects.visChart.getTableVisContent(); - expect(data).to.be.eql([ - ['03:00', '0B', '1'], - ['03:00', '1.953KB', '1'], - ['03:00', '3.906KB', '1'], - ['03:00', '5.859KB', '2'], - ['03:10', '0B', '1'], - ['03:10', '5.859KB', '1'], - ['03:10', '7.813KB', '1'], - ['03:15', '0B', '1'], - ['03:15', '1.953KB', '1'], - ['03:20', '1.953KB', '1'], - ]); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['03:00', '0B', '1'], + ['03:00', '1.953KB', '1'], + ['03:00', '3.906KB', '1'], + ['03:00', '5.859KB', '2'], + ['03:10', '0B', '1'], + ['03:10', '5.859KB', '1'], + ['03:10', '7.813KB', '1'], + ['03:15', '0B', '1'], + ['03:15', '1.953KB', '1'], + ['03:20', '1.953KB', '1'], + ]); + }); }); }); }); diff --git a/test/functional/apps/visualize/input_control_vis/input_control_range.ts b/test/functional/apps/visualize/input_control_vis/input_control_range.ts index 4f4e6d6655be51..caa008080b2a30 100644 --- a/test/functional/apps/visualize/input_control_vis/input_control_range.ts +++ b/test/functional/apps/visualize/input_control_vis/input_control_range.ts @@ -12,7 +12,6 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const kibanaServer = getService('kibanaServer'); const find = getService('find'); const security = getService('security'); const { visualize, visEditor } = getPageObjects(['visualize', 'visEditor']); @@ -53,7 +52,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esArchiver.loadIfNeeded('logstash_functional'); await esArchiver.loadIfNeeded('long_window_logstash'); await esArchiver.load('visualize'); - await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-*' }); await security.testUser.restoreDefaults(); }); }); diff --git a/test/functional/fixtures/es_archiver/data/data.json.gz b/test/functional/fixtures/es_archiver/data/data.json.gz deleted file mode 100644 index 629276ccd186e1..00000000000000 Binary files a/test/functional/fixtures/es_archiver/data/data.json.gz and /dev/null differ diff --git a/test/functional/fixtures/es_archiver/data/mappings.json b/test/functional/fixtures/es_archiver/data/mappings.json deleted file mode 100644 index 256978162b9813..00000000000000 --- a/test/functional/fixtures/es_archiver/data/mappings.json +++ /dev/null @@ -1,450 +0,0 @@ -{ - "type": "index", - "value": { - "aliases": { - ".kibana": { - } - }, - "index": ".kibana_1", - "mappings": { - "_meta": { - "migrationMappingPropertyHashes": { - "application_usage_daily": "43b8830d5d0df85a6823d290885fc9fd", - "application_usage_totals": "3d1b76c39bfb2cc8296b024d73854724", - "application_usage_transactional": "3d1b76c39bfb2cc8296b024d73854724", - "config": "c63748b75f39d0c54de12d12c1ccbc20", - "core-usage-stats": "3d1b76c39bfb2cc8296b024d73854724", - "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", - "dashboard": "40554caf09725935e2c02e02563a2d07", - "index-pattern": "45915a1ad866812242df474eb0479052", - "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", - "legacy-url-alias": "3d1b76c39bfb2cc8296b024d73854724", - "migrationVersion": "4a1746014a75ade3a714e1db5763276f", - "namespace": "2f4316de49999235636386fe51dc06c1", - "namespaces": "2f4316de49999235636386fe51dc06c1", - "originId": "2f4316de49999235636386fe51dc06c1", - "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", - "references": "7997cf5a56cc02bdc9c93361bde732b0", - "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", - "search": "e5b843b43566421ffa75fb499271dc34", - "search-telemetry": "3d1b76c39bfb2cc8296b024d73854724", - "telemetry": "36a616f7026dfa617d6655df850fe16d", - "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", - "type": "2f4316de49999235636386fe51dc06c1", - "ui-counter": "0d409297dc5ebe1e3a1da691c6ee32e3", - "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", - "updated_at": "00da57df13e94e9d98437d13ace4bfe0", - "url": "c7f66a0df8b1b52f17c28c4adb111105", - "visualization": "f819cf6636b75c9e76ba733a0c6ef355" - } - }, - "dynamic": "strict", - "properties": { - "application_usage_daily": { - "dynamic": "false", - "properties": { - "timestamp": { - "type": "date" - } - } - }, - "application_usage_totals": { - "dynamic": "false", - "type": "object" - }, - "application_usage_transactional": { - "dynamic": "false", - "type": "object" - }, - "config": { - "dynamic": "false", - "properties": { - "buildNum": { - "type": "keyword" - } - } - }, - "core-usage-stats": { - "dynamic": "false", - "type": "object" - }, - "coreMigrationVersion": { - "type": "keyword" - }, - "dashboard": { - "properties": { - "description": { - "type": "text" - }, - "hits": { - "doc_values": false, - "index": false, - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "index": false, - "type": "text" - } - } - }, - "optionsJSON": { - "index": false, - "type": "text" - }, - "panelsJSON": { - "index": false, - "type": "text" - }, - "refreshInterval": { - "properties": { - "display": { - "doc_values": false, - "index": false, - "type": "keyword" - }, - "pause": { - "doc_values": false, - "index": false, - "type": "boolean" - }, - "section": { - "doc_values": false, - "index": false, - "type": "integer" - }, - "value": { - "doc_values": false, - "index": false, - "type": "integer" - } - } - }, - "timeFrom": { - "doc_values": false, - "index": false, - "type": "keyword" - }, - "timeRestore": { - "doc_values": false, - "index": false, - "type": "boolean" - }, - "timeTo": { - "doc_values": false, - "index": false, - "type": "keyword" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "index-pattern": { - "dynamic": "false", - "properties": { - "title": { - "type": "text" - }, - "type": { - "type": "keyword" - } - } - }, - "kql-telemetry": { - "properties": { - "optInCount": { - "type": "long" - }, - "optOutCount": { - "type": "long" - } - } - }, - "legacy-url-alias": { - "dynamic": "false", - "type": "object" - }, - "migrationVersion": { - "dynamic": "true", - "properties": { - "config": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "namespace": { - "type": "keyword" - }, - "namespaces": { - "type": "keyword" - }, - "originId": { - "type": "keyword" - }, - "query": { - "properties": { - "description": { - "type": "text" - }, - "filters": { - "enabled": false, - "type": "object" - }, - "query": { - "properties": { - "language": { - "type": "keyword" - }, - "query": { - "index": false, - "type": "keyword" - } - } - }, - "timefilter": { - "enabled": false, - "type": "object" - }, - "title": { - "type": "text" - } - } - }, - "references": { - "properties": { - "id": { - "type": "keyword" - }, - "name": { - "type": "keyword" - }, - "type": { - "type": "keyword" - } - }, - "type": "nested" - }, - "sample-data-telemetry": { - "properties": { - "installCount": { - "type": "long" - }, - "unInstallCount": { - "type": "long" - } - } - }, - "search": { - "properties": { - "columns": { - "doc_values": false, - "index": false, - "type": "keyword" - }, - "description": { - "type": "text" - }, - "grid": { - "enabled": false, - "type": "object" - }, - "hits": { - "doc_values": false, - "index": false, - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "index": false, - "type": "text" - } - } - }, - "pre712": { - "type": "boolean" - }, - "sort": { - "doc_values": false, - "index": false, - "type": "keyword" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "search-telemetry": { - "dynamic": "false", - "type": "object" - }, - "telemetry": { - "properties": { - "allowChangingOptInStatus": { - "type": "boolean" - }, - "enabled": { - "type": "boolean" - }, - "lastReported": { - "type": "date" - }, - "lastVersionChecked": { - "type": "keyword" - }, - "reportFailureCount": { - "type": "integer" - }, - "reportFailureVersion": { - "type": "keyword" - }, - "sendUsageFrom": { - "type": "keyword" - }, - "userHasSeenNotice": { - "type": "boolean" - } - } - }, - "timelion-sheet": { - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "timelion_chart_height": { - "type": "integer" - }, - "timelion_columns": { - "type": "integer" - }, - "timelion_interval": { - "type": "keyword" - }, - "timelion_other_interval": { - "type": "keyword" - }, - "timelion_rows": { - "type": "integer" - }, - "timelion_sheet": { - "type": "text" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "type": { - "type": "keyword" - }, - "ui-counter": { - "properties": { - "count": { - "type": "integer" - } - } - }, - "ui-metric": { - "properties": { - "count": { - "type": "integer" - } - } - }, - "updated_at": { - "type": "date" - }, - "url": { - "properties": { - "accessCount": { - "type": "long" - }, - "accessDate": { - "type": "date" - }, - "createDate": { - "type": "date" - }, - "url": { - "fields": { - "keyword": { - "ignore_above": 2048, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "visualization": { - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "index": false, - "type": "text" - } - } - }, - "savedSearchRefName": { - "doc_values": false, - "index": false, - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "index": false, - "type": "text" - }, - "version": { - "type": "integer" - }, - "visState": { - "index": false, - "type": "text" - } - } - } - } - }, - "settings": { - "index": { - "auto_expand_replicas": "0-1", - "number_of_replicas": "0", - "number_of_shards": "1" - } - } - } -} \ No newline at end of file diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index 87ec9ac27902fc..abd5975b95d0a8 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -418,7 +418,7 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr public async filterOnTableCell(columnIndex: number, rowIndex: number) { await retry.try(async () => { const cell = await dataGrid.getCellElement(rowIndex, columnIndex); - await cell.moveMouseTo(); + await cell.focus(); const filterBtn = await testSubjects.findDescendant( 'tbvChartCell__filterForCellValue', cell diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index 60f75b692ff0ea..c0a7e0f82e6920 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { chunk } from 'lodash'; import { FtrProviderContext } from '../ftr_provider_context'; import { WebElementWrapper } from './lib/web_element_wrapper'; @@ -31,14 +32,11 @@ export function DataGridProvider({ getService, getPageObjects }: FtrProviderCont const columns = $('.euiDataGridHeaderCell__content') .toArray() .map((cell) => $(cell).text()); - const rows = $.findTestSubjects('dataGridRow') + const cells = $.findTestSubjects('dataGridRowCell') .toArray() - .map((row) => - $(row) - .find('.euiDataGridRowCell__truncate') - .toArray() - .map((cell) => $(cell).text()) - ); + .map((cell) => $(cell).text()); + + const rows = chunk(cells, columns.length); return { columns, @@ -56,20 +54,18 @@ export function DataGridProvider({ getService, getPageObjects }: FtrProviderCont cellDataTestSubj: string ): Promise { const $ = await element.parseDomContent(); - return $('[data-test-subj="dataGridRow"]') + const columnNumber = $('.euiDataGridHeaderCell__content').length; + const cells = $.findTestSubjects('dataGridRowCell') .toArray() - .map((row) => - $(row) - .findTestSubjects('dataGridRowCell') - .toArray() - .map((cell) => - $(cell) - .findTestSubject(cellDataTestSubj) - .text() - .replace(/ /g, '') - .trim() - ) + .map((cell) => + $(cell) + .findTestSubject(cellDataTestSubj) + .text() + .replace(/ /g, '') + .trim() ); + + return chunk(cells, columnNumber); } /** @@ -90,62 +86,72 @@ export function DataGridProvider({ getService, getPageObjects }: FtrProviderCont * @param columnIndex column index starting from 1 (1 means 1st column) */ public async getCellElement(rowIndex: number, columnIndex: number) { + const table = await find.byCssSelector('.euiDataGrid'); + const $ = await table.parseDomContent(); + const columnNumber = $('.euiDataGridHeaderCell__content').length; return await find.byCssSelector( - `[data-test-subj="dataGridWrapper"] [data-test-subj="dataGridRow"]:nth-of-type(${ - rowIndex + 1 - }) - [data-test-subj="dataGridRowCell"]:nth-of-type(${columnIndex})` + `[data-test-subj="dataGridWrapper"] [data-test-subj="dataGridRowCell"]:nth-of-type(${ + columnNumber * (rowIndex - 1) + columnIndex + 1 + })` ); } public async getFields() { - const rows = await find.allByCssSelector('.euiDataGridRow'); - - const result = []; - for (const row of rows) { - const cells = await row.findAllByClassName('euiDataGridRowCell__truncate'); - const cellsText = []; - let cellIdx = 0; - for (const cell of cells) { - if (cellIdx > 0) { - cellsText.push(await cell.getVisibleText()); - } - cellIdx++; + const cells = await find.allByCssSelector('.euiDataGridRowCell'); + + const rows: string[][] = []; + let rowIdx = -1; + for (const cell of cells) { + if (await cell.elementHasClass('euiDataGridRowCell--firstColumn')) { + // first column contains expand icon + rowIdx++; + rows[rowIdx] = []; + } + if (!(await cell.elementHasClass('euiDataGridRowCell--controlColumn'))) { + rows[rowIdx].push(await cell.getVisibleText()); } - result.push(cellsText); } - return result; + return rows; } public async getTable(selector: string = 'docTable') { return await testSubjects.find(selector); } - public async getBodyRows(): Promise { - const table = await this.getTable(); - return await table.findAllByTestSubject('dataGridRow'); + public async getBodyRows(): Promise { + return this.getDocTableRows(); } + /** + * Returns an array of rows (which are array of cells) + */ public async getDocTableRows() { const table = await this.getTable(); - return await table.findAllByTestSubject('dataGridRow'); - } - - public async getAnchorRow(): Promise { - const table = await this.getTable(); - return await table.findByTestSubject('~docTableAnchorRow'); + const cells = await table.findAllByCssSelector('.euiDataGridRowCell'); + + const rows: WebElementWrapper[][] = []; + let rowIdx = -1; + for (const cell of cells) { + if (await cell.elementHasClass('euiDataGridRowCell--firstColumn')) { + rowIdx++; + rows[rowIdx] = []; + } + rows[rowIdx].push(cell); + } + return rows; } - public async getRow(options: SelectOptions): Promise { - return options.isAnchorRow - ? await this.getAnchorRow() - : (await this.getBodyRows())[options.rowIndex]; + /** + * Returns an array of cells for that row + */ + public async getRow(options: SelectOptions): Promise { + return (await this.getBodyRows())[options.rowIndex]; } public async clickRowToggle( options: SelectOptions = { isAnchorRow: false, rowIndex: 0 } ): Promise { const row = await this.getRow(options); - const toggle = await row.findByTestSubject('~docTableExpandToggleColumn'); + const toggle = await row[0]; await toggle.click(); } diff --git a/test/plugin_functional/test_suites/core_plugins/applications.ts b/test/plugin_functional/test_suites/core_plugins/applications.ts index 6c600aa996a33a..0e52b536410e4e 100644 --- a/test/plugin_functional/test_suites/core_plugins/applications.ts +++ b/test/plugin_functional/test_suites/core_plugins/applications.ts @@ -19,6 +19,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide const find = getService('find'); const retry = getService('retry'); const deployment = getService('deployment'); + const esArchiver = getService('esArchiver'); const loadingScreenNotShown = async () => expect(await testSubjects.exists('kbnLoadingMessage')).to.be(false); @@ -50,6 +51,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide describe('ui applications', function describeIndexTests() { before(async () => { + await esArchiver.emptyKibanaIndex(); await PageObjects.common.navigateToApp('foo'); }); diff --git a/test/plugin_functional/test_suites/data_plugin/index_patterns.ts b/test/plugin_functional/test_suites/data_plugin/index_patterns.ts index 918e9f16c5dae7..7947616ac65684 100644 --- a/test/plugin_functional/test_suites/data_plugin/index_patterns.ts +++ b/test/plugin_functional/test_suites/data_plugin/index_patterns.ts @@ -12,8 +12,12 @@ import '../../plugins/core_provider_plugin/types'; export default function ({ getService }: PluginFunctionalProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('index patterns', function () { + before(async () => { + await esArchiver.emptyKibanaIndex(); + }); let indexPatternId = ''; it('can create an index pattern', async () => { diff --git a/test/plugin_functional/test_suites/saved_objects_management/import_warnings.ts b/test/plugin_functional/test_suites/saved_objects_management/import_warnings.ts index 10a088426c8fdd..da4c785342733b 100644 --- a/test/plugin_functional/test_suites/saved_objects_management/import_warnings.ts +++ b/test/plugin_functional/test_suites/saved_objects_management/import_warnings.ts @@ -10,10 +10,15 @@ import path from 'path'; import expect from '@kbn/expect'; import { PluginFunctionalProviderContext } from '../../services'; -export default function ({ getPageObjects }: PluginFunctionalProviderContext) { +export default function ({ getPageObjects, getService }: PluginFunctionalProviderContext) { const PageObjects = getPageObjects(['common', 'settings', 'header', 'savedObjects']); + const esArchiver = getService('esArchiver'); describe('import warnings', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + }); + beforeEach(async () => { await PageObjects.settings.navigateTo(); await PageObjects.settings.clickKibanaSavedObjects(); diff --git a/test/security_functional/insecure_cluster_warning.ts b/test/security_functional/insecure_cluster_warning.ts index 229dac20390a29..44a0e2eb0e1212 100644 --- a/test/security_functional/insecure_cluster_warning.ts +++ b/test/security_functional/insecure_cluster_warning.ts @@ -31,6 +31,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before(async () => { await browser.setLocalStorageItem('insecureClusterWarningVisibility', ''); await esArchiver.unload('hamlet'); + await esArchiver.emptyKibanaIndex(); }); it('should not warn when the cluster contains no user data', async () => { diff --git a/x-pack/plugins/apm/common/projections.ts b/x-pack/plugins/apm/common/projections.ts deleted file mode 100644 index dab9dfce5e58af..00000000000000 --- a/x-pack/plugins/apm/common/projections.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export enum Projection { - services = 'services', - transactionGroups = 'transactionGroups', - traces = 'traces', - transactions = 'transactions', - metrics = 'metrics', - errorGroups = 'errorGroups', - serviceNodes = 'serviceNodes', - rumOverview = 'rumOverview', -} diff --git a/x-pack/plugins/apm/common/utils/formatters/datetime.test.ts b/x-pack/plugins/apm/common/utils/formatters/datetime.test.ts index 6aee1e2b9842de..9efb7184f39275 100644 --- a/x-pack/plugins/apm/common/utils/formatters/datetime.test.ts +++ b/x-pack/plugins/apm/common/utils/formatters/datetime.test.ts @@ -170,37 +170,52 @@ describe('date time formatters', () => { it('milliseconds', () => { const start = moment('2019-10-29 08:00:00.001'); const end = moment('2019-10-29 08:00:00.005'); - expect(getDateDifference(start, end, 'milliseconds')).toEqual(4); + expect( + getDateDifference({ start, end, unitOfTime: 'milliseconds' }) + ).toEqual(4); }); it('seconds', () => { const start = moment('2019-10-29 08:00:00'); const end = moment('2019-10-29 08:00:10'); - expect(getDateDifference(start, end, 'seconds')).toEqual(10); + expect(getDateDifference({ start, end, unitOfTime: 'seconds' })).toEqual( + 10 + ); }); it('minutes', () => { const start = moment('2019-10-29 08:00:00'); const end = moment('2019-10-29 08:15:00'); - expect(getDateDifference(start, end, 'minutes')).toEqual(15); + expect(getDateDifference({ start, end, unitOfTime: 'minutes' })).toEqual( + 15 + ); }); it('hours', () => { const start = moment('2019-10-29 08:00:00'); const end = moment('2019-10-29 10:00:00'); - expect(getDateDifference(start, end, 'hours')).toEqual(2); + expect(getDateDifference({ start, end, unitOfTime: 'hours' })).toEqual(2); }); it('days', () => { const start = moment('2019-10-29 08:00:00'); const end = moment('2019-10-30 10:00:00'); - expect(getDateDifference(start, end, 'days')).toEqual(1); + expect(getDateDifference({ start, end, unitOfTime: 'days' })).toEqual(1); }); it('months', () => { const start = moment('2019-10-29 08:00:00'); const end = moment('2019-12-29 08:00:00'); - expect(getDateDifference(start, end, 'months')).toEqual(2); + expect(getDateDifference({ start, end, unitOfTime: 'months' })).toEqual( + 2 + ); }); it('years', () => { const start = moment('2019-10-29 08:00:00'); const end = moment('2020-10-29 08:00:00'); - expect(getDateDifference(start, end, 'years')).toEqual(1); + expect(getDateDifference({ start, end, unitOfTime: 'years' })).toEqual(1); + }); + it('precise days', () => { + const start = moment('2019-10-29 08:00:00'); + const end = moment('2019-10-30 10:00:00'); + expect( + getDateDifference({ start, end, unitOfTime: 'days', precise: true }) + ).toEqual(1.0833333333333333); }); }); }); diff --git a/x-pack/plugins/apm/common/utils/formatters/datetime.ts b/x-pack/plugins/apm/common/utils/formatters/datetime.ts index 624a0b8a664bcd..88f70753f47c82 100644 --- a/x-pack/plugins/apm/common/utils/formatters/datetime.ts +++ b/x-pack/plugins/apm/common/utils/formatters/datetime.ts @@ -58,37 +58,43 @@ function getDateFormat(dateUnit: DateUnit) { } } -export const getDateDifference = ( - start: moment.Moment, - end: moment.Moment, - unitOfTime: DateUnit | TimeUnit -) => end.diff(start, unitOfTime); +export const getDateDifference = ({ + start, + end, + unitOfTime, + precise, +}: { + start: moment.Moment; + end: moment.Moment; + unitOfTime: DateUnit | TimeUnit; + precise?: boolean; +}) => end.diff(start, unitOfTime, precise); function getFormatsAccordingToDateDifference( start: moment.Moment, end: moment.Moment ) { - if (getDateDifference(start, end, 'years') >= 5) { + if (getDateDifference({ start, end, unitOfTime: 'years' }) >= 5) { return { dateFormat: getDateFormat('years') }; } - if (getDateDifference(start, end, 'months') >= 5) { + if (getDateDifference({ start, end, unitOfTime: 'months' }) >= 5) { return { dateFormat: getDateFormat('months') }; } const dateFormatWithDays = getDateFormat('days'); - if (getDateDifference(start, end, 'days') > 1) { + if (getDateDifference({ start, end, unitOfTime: 'days' }) > 1) { return { dateFormat: dateFormatWithDays }; } - if (getDateDifference(start, end, 'minutes') >= 1) { + if (getDateDifference({ start, end, unitOfTime: 'minutes' }) >= 1) { return { dateFormat: dateFormatWithDays, timeFormat: getTimeFormat('minutes'), }; } - if (getDateDifference(start, end, 'seconds') >= 10) { + if (getDateDifference({ start, end, unitOfTime: 'seconds' }) >= 10) { return { dateFormat: dateFormatWithDays, timeFormat: getTimeFormat('seconds'), diff --git a/x-pack/plugins/apm/public/components/app/Home/index.tsx b/x-pack/plugins/apm/public/components/app/Home/index.tsx index bb3903727f5092..834c2d5c40bcec 100644 --- a/x-pack/plugins/apm/public/components/app/Home/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Home/index.tsx @@ -16,7 +16,7 @@ import { useTraceOverviewHref } from '../../shared/Links/apm/TraceOverviewLink'; import { MainTabs } from '../../shared/main_tabs'; import { ServiceMap } from '../ServiceMap'; import { ServiceInventory } from '../service_inventory'; -import { TraceOverview } from '../TraceOverview'; +import { TraceOverview } from '../trace_overview'; interface Tab { key: string; diff --git a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx index 0fd85df37bb787..08d95aca24714b 100644 --- a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx @@ -23,7 +23,7 @@ import { AnomalyDetection } from '../../Settings/anomaly_detection'; import { ApmIndices } from '../../Settings/ApmIndices'; import { CustomizeUI } from '../../Settings/CustomizeUI'; import { TraceLink } from '../../TraceLink'; -import { TransactionDetails } from '../../TransactionDetails'; +import { TransactionDetails } from '../../transaction_details'; import { CreateAgentConfigurationRouteHandler, EditAgentConfigurationRouteHandler, diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterBadgeList.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterBadgeList.tsx similarity index 95% rename from x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterBadgeList.tsx rename to x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterBadgeList.tsx index 6423d295da4691..6bc345ea5bd87d 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterBadgeList.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterBadgeList.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { EuiFlexGrid, EuiFlexItem, EuiBadge } from '@elastic/eui'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; -import { unit, px, truncate } from '../../../../style/variables'; +import { unit, px, truncate } from '../../../../../style/variables'; const BadgeText = styled.div` display: inline-block; diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterTitleButton.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterTitleButton.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterTitleButton.tsx rename to x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterTitleButton.tsx diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/index.tsx similarity index 98% rename from x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx rename to x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/index.tsx index 59ec3b683b4d34..e1debde1117f96 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/index.tsx @@ -21,7 +21,7 @@ import { import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; import { FilterBadgeList } from './FilterBadgeList'; -import { unit, px } from '../../../../style/variables'; +import { unit, px } from '../../../../../style/variables'; import { FilterTitleButton } from './FilterTitleButton'; const Popover = styled((EuiPopover as unknown) as FunctionComponent).attrs( diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/index.tsx similarity index 89% rename from x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx rename to x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/index.tsx index 0cab58bc5f448f..a07997fb74921d 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/index.tsx @@ -15,12 +15,10 @@ import { import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; import { Filter } from './Filter'; -import { useLocalUIFilters } from '../../../hooks/useLocalUIFilters'; -import { Projection } from '../../../../common/projections'; -import { LocalUIFilterName } from '../../../../common/ui_filter'; +import { useLocalUIFilters } from '../hooks/useLocalUIFilters'; +import { LocalUIFilterName } from '../../../../../common/ui_filter'; interface Props { - projection: Projection; filterNames: LocalUIFilterName[]; params?: Record; showCount?: boolean; @@ -33,7 +31,6 @@ const ButtonWrapper = styled.div` `; function LocalUIFilters({ - projection, params, filterNames, children, @@ -42,7 +39,6 @@ function LocalUIFilters({ }: Props) { const { filters, setFilterValue, clearValues } = useLocalUIFilters({ filterNames, - projection, params, shouldFetch, }); diff --git a/x-pack/plugins/apm/public/hooks/useLocalUIFilters.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts similarity index 76% rename from x-pack/plugins/apm/public/hooks/useLocalUIFilters.ts rename to x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts index 1e0aa4fd961719..3f366300792aca 100644 --- a/x-pack/plugins/apm/public/hooks/useLocalUIFilters.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts @@ -7,19 +7,21 @@ import { omit } from 'lodash'; import { useHistory } from 'react-router-dom'; -import { Projection } from '../../common/projections'; -import { pickKeys } from '../../common/utils/pick_keys'; +import { LocalUIFilterName } from '../../../../../common/ui_filter'; +import { pickKeys } from '../../../../../common/utils/pick_keys'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { LocalUIFiltersAPIResponse } from '../../server/lib/ui_filters/local_ui_filters'; +import { LocalUIFiltersAPIResponse } from '../../../../../server/lib/ui_filters/local_ui_filters'; import { localUIFilters, // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../server/lib/ui_filters/local_ui_filters/config'; -import { fromQuery, toQuery } from '../components/shared/Links/url_helpers'; -import { removeUndefinedProps } from '../context/url_params_context/helpers'; -import { useFetcher } from './use_fetcher'; -import { useUrlParams } from '../context/url_params_context/use_url_params'; -import { LocalUIFilterName } from '../../common/ui_filter'; +} from '../../../../../server/lib/ui_filters/local_ui_filters/config'; +import { + fromQuery, + toQuery, +} from '../../../../components/shared/Links/url_helpers'; +import { removeUndefinedProps } from '../../../../context/url_params_context/helpers'; +import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; +import { useFetcher } from '../../../../hooks/use_fetcher'; const getInitialData = ( filterNames: LocalUIFilterName[] @@ -31,12 +33,10 @@ const getInitialData = ( }; export function useLocalUIFilters({ - projection, filterNames, params, shouldFetch, }: { - projection: Projection; filterNames: LocalUIFilterName[]; params?: Record; shouldFetch: boolean; @@ -72,7 +72,7 @@ export function useLocalUIFilters({ (callApmApi) => { if (shouldFetch && urlParams.start && urlParams.end) { return callApmApi({ - endpoint: `GET /api/apm/ui_filters/local_filters/${projection}` as const, + endpoint: `GET /api/apm/ui_filters/local_filters/rumOverview`, params: { query: { uiFilters: JSON.stringify(uiFilters), @@ -87,7 +87,6 @@ export function useLocalUIFilters({ } }, [ - projection, uiFilters, urlParams.start, urlParams.end, diff --git a/x-pack/plugins/apm/public/hooks/useCallApi.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/use_call_api.ts similarity index 68% rename from x-pack/plugins/apm/public/hooks/useCallApi.ts rename to x-pack/plugins/apm/public/components/app/RumDashboard/hooks/use_call_api.ts index a2bb77c6ad6fc4..5b448871804ebf 100644 --- a/x-pack/plugins/apm/public/hooks/useCallApi.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/use_call_api.ts @@ -6,9 +6,9 @@ */ import { useMemo } from 'react'; -import { callApi } from '../services/rest/callApi'; -import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context'; -import { FetchOptions } from '../../common/fetch_options'; +import { callApi } from '../../../../services/rest/callApi'; +import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; +import { FetchOptions } from '../../../../../common/fetch_options'; export function useCallApi() { const { http } = useApmPluginContext().core; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx index 7b0b1d204ac4d3..9bdad14eb8a188 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx @@ -5,13 +5,11 @@ * 2.0. */ -import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import React, { useMemo } from 'react'; import { useTrackPageview } from '../../../../../observability/public'; -import { Projection } from '../../../../common/projections'; +import { LocalUIFilters } from './LocalUIFilters'; import { RumDashboard } from './RumDashboard'; - -import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { URLFilter } from './URLFilter'; export function RumOverview() { @@ -21,7 +19,6 @@ export function RumOverview() { const localUIFiltersConfig = useMemo(() => { const config: React.ComponentProps = { filterNames: ['location', 'device', 'os', 'browser'], - projection: Projection.rumOverview, }; return config; diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/List.test.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/List/List.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/List.test.tsx rename to x-pack/plugins/apm/public/components/app/error_group_overview/List/List.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__fixtures__/props.json b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__fixtures__/props.json similarity index 100% rename from x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__fixtures__/props.json rename to x-pack/plugins/apm/public/components/app/error_group_overview/List/__fixtures__/props.json diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap similarity index 100% rename from x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__snapshots__/List.test.tsx.snap rename to x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx rename to x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx similarity index 59% rename from x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx rename to x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx index 58fea5e985fae6..29bdf6467e5447 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx @@ -7,29 +7,26 @@ import { EuiFlexGroup, - EuiFlexItem, EuiPage, EuiPanel, EuiSpacer, EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { useMemo } from 'react'; +import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; -import { Projection } from '../../../../common/projections'; -import { useFetcher } from '../../../hooks/use_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { LocalUIFilters } from '../../shared/LocalUIFilters'; +import { useErrorGroupDistributionFetcher } from '../../../hooks/use_error_group_distribution_fetcher'; +import { useFetcher } from '../../../hooks/use_fetcher'; import { SearchBar } from '../../shared/search_bar'; import { ErrorDistribution } from '../ErrorGroupDetails/Distribution'; import { ErrorGroupList } from './List'; -import { useErrorGroupDistributionFetcher } from '../../../hooks/use_error_group_distribution_fetcher'; interface ErrorGroupOverviewProps { serviceName: string; } -function ErrorGroupOverview({ serviceName }: ErrorGroupOverviewProps) { +export function ErrorGroupOverview({ serviceName }: ErrorGroupOverviewProps) { const { urlParams, uiFilters } = useUrlParams(); const { start, end, sortField, sortDirection } = urlParams; const { errorDistributionData } = useErrorGroupDistributionFetcher({ @@ -68,18 +65,6 @@ function ErrorGroupOverview({ serviceName }: ErrorGroupOverviewProps) { }); useTrackPageview({ app: 'apm', path: 'error_group_overview', delay: 15000 }); - const localUIFiltersConfig = useMemo(() => { - const config: React.ComponentProps = { - filterNames: ['host', 'containerId', 'podName', 'serviceVersion'], - params: { - serviceName, - }, - projection: Projection.errorGroups, - }; - - return config; - }, [serviceName]); - if (!errorDistributionData || !errorGroupListData) { return null; } @@ -88,41 +73,34 @@ function ErrorGroupOverview({ serviceName }: ErrorGroupOverviewProps) { <> - - - - - - - - + + + + - + - - -

Errors

-
- + + +

Errors

+
+ - -
-
+ +
); } - -export { ErrorGroupOverview }; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx index 1c8a33d1968b18..23f699b63d207f 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx +++ b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx @@ -20,9 +20,9 @@ import { useServiceNodeOverviewHref } from '../../shared/Links/apm/ServiceNodeOv import { useServiceOverviewHref } from '../../shared/Links/apm/service_overview_link'; import { useTransactionsOverviewHref } from '../../shared/Links/apm/transaction_overview_link'; import { MainTabs } from '../../shared/main_tabs'; -import { ErrorGroupOverview } from '../ErrorGroupOverview'; +import { ErrorGroupOverview } from '../error_group_overview'; import { ServiceMap } from '../ServiceMap'; -import { ServiceNodeOverview } from '../ServiceNodeOverview'; +import { ServiceNodeOverview } from '../service_node_overview'; import { ServiceMetrics } from '../service_metrics'; import { ServiceOverview } from '../service_overview'; import { TransactionOverview } from '../transaction_overview'; diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx index 4ba96b63c91f45..1cb420a8ac1949 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx @@ -13,21 +13,19 @@ import { EuiPanel, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect } from 'react'; import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; import { useTrackPageview } from '../../../../../observability/public'; -import { Projection } from '../../../../common/projections'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; -import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; -import { useLocalStorage } from '../../../hooks/useLocalStorage'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { LocalUIFilters } from '../../shared/LocalUIFilters'; +import { useLocalStorage } from '../../../hooks/useLocalStorage'; +import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; +import { useUpgradeAssistantHref } from '../../shared/Links/kibana'; import { SearchBar } from '../../shared/search_bar'; import { NoServicesMessage } from './no_services_message'; import { ServiceList } from './ServiceList'; import { MLCallout } from './ServiceList/MLCallout'; import { useAnomalyDetectionJobsFetcher } from './use_anomaly_detection_jobs_fetcher'; -import { useUpgradeAssistantHref } from '../../shared/Links/kibana'; const initialData = { items: [], @@ -100,16 +98,6 @@ export function ServiceInventory() { useTrackPageview({ app: 'apm', path: 'services_overview' }); useTrackPageview({ app: 'apm', path: 'services_overview', delay: 15000 }); - const localFiltersConfig: React.ComponentProps< - typeof LocalUIFilters - > = useMemo( - () => ({ - filterNames: ['host', 'agentName'], - projection: Projection.services, - }), - [] - ); - const { anomalyDetectionJobsData, anomalyDetectionJobsStatus, @@ -132,33 +120,24 @@ export function ServiceInventory() { <> - - - - - - - {displayMlCallout ? ( - - setUserHasDismissedCallout(true)} + + {displayMlCallout ? ( + + setUserHasDismissedCallout(true)} /> + + ) : null} + + + - - ) : null} - - - - } - /> - - - + } + /> + diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx index 647792bb130461..69b4149625824d 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx @@ -20,7 +20,6 @@ import { MockApmPluginContextWrapper, } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; -import * as useLocalUIFilters from '../../../hooks/useLocalUIFilters'; import * as useDynamicIndexPatternHooks from '../../../hooks/use_dynamic_index_pattern'; import { SessionStorageMock } from '../../../services/__mocks__/SessionStorageMock'; import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; @@ -76,13 +75,6 @@ describe('ServiceInventory', () => { // @ts-expect-error global.sessionStorage = new SessionStorageMock(); - jest.spyOn(useLocalUIFilters, 'useLocalUIFilters').mockReturnValue({ - filters: [], - setFilterValue: () => null, - clearValues: () => null, - status: FETCH_STATUS.SUCCESS, - }); - jest.spyOn(hook, 'useAnomalyDetectionJobsFetcher').mockReturnValue({ anomalyDetectionJobsStatus: FETCH_STATUS.SUCCESS, anomalyDetectionJobsData: { jobs: [], hasLegacyJobs: false }, diff --git a/x-pack/plugins/apm/public/components/app/service_metrics/index.tsx b/x-pack/plugins/apm/public/components/app/service_metrics/index.tsx index d1e6cc0d84ac4f..44a5adf31d0b6c 100644 --- a/x-pack/plugins/apm/public/components/app/service_metrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_metrics/index.tsx @@ -7,19 +7,17 @@ import { EuiFlexGrid, + EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel, EuiSpacer, - EuiFlexGroup, } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import React from 'react'; +import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useServiceMetricChartsFetcher } from '../../../hooks/use_service_metric_charts_fetcher'; import { MetricsChart } from '../../shared/charts/metrics_chart'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; -import { Projection } from '../../../../common/projections'; -import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { SearchBar } from '../../shared/search_bar'; interface ServiceMetricsProps { @@ -37,47 +35,28 @@ export function ServiceMetrics({ }); const { start, end } = urlParams; - const localFiltersConfig: React.ComponentProps< - typeof LocalUIFilters - > = useMemo( - () => ({ - filterNames: ['host', 'containerId', 'podName', 'serviceVersion'], - params: { - serviceName, - }, - projection: Projection.metrics, - showCount: false, - }), - [serviceName] - ); - return ( <> - - - - - - - - {data.charts.map((chart) => ( - - - - - - ))} - - - - + + + + {data.charts.map((chart) => ( + + + + + + ))} + + + diff --git a/x-pack/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx similarity index 78% rename from x-pack/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx rename to x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx index 01874c956e8f91..00d184f692e3bc 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx @@ -4,30 +4,21 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { - EuiFlexGroup, - EuiFlexItem, - EuiPage, - EuiPanel, - EuiToolTip, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiPage, EuiPanel, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { useMemo } from 'react'; +import React from 'react'; import styled from 'styled-components'; import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../common/i18n'; -import { Projection } from '../../../../common/projections'; import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; import { asDynamicBytes, asInteger, asPercent, } from '../../../../common/utils/formatters'; -import { useFetcher } from '../../../hooks/use_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useFetcher } from '../../../hooks/use_fetcher'; import { px, truncate, unit } from '../../../style/variables'; import { ServiceNodeMetricOverviewLink } from '../../shared/Links/apm/ServiceNodeMetricOverviewLink'; -import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { ITableColumn, ManagedTable } from '../../shared/ManagedTable'; import { SearchBar } from '../../shared/search_bar'; @@ -47,19 +38,6 @@ function ServiceNodeOverview({ serviceName }: ServiceNodeOverviewProps) { const { uiFilters, urlParams } = useUrlParams(); const { start, end } = urlParams; - const localFiltersConfig: React.ComponentProps< - typeof LocalUIFilters - > = useMemo( - () => ({ - filterNames: ['host', 'containerId', 'podName'], - params: { - serviceName, - }, - projection: Projection.serviceNodes, - }), - [serviceName] - ); - const { data: items = [] } = useFetcher( (callApmApi) => { if (!start || !end) { @@ -164,27 +142,22 @@ function ServiceNodeOverview({ serviceName }: ServiceNodeOverviewProps) { <> - - - - - - - - - + + + + diff --git a/x-pack/plugins/apm/public/components/app/TraceOverview/TraceList.tsx b/x-pack/plugins/apm/public/components/app/trace_overview/TraceList.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TraceOverview/TraceList.tsx rename to x-pack/plugins/apm/public/components/app/trace_overview/TraceList.tsx diff --git a/x-pack/plugins/apm/public/components/app/TraceOverview/index.tsx b/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx similarity index 66% rename from x-pack/plugins/apm/public/components/app/TraceOverview/index.tsx rename to x-pack/plugins/apm/public/components/app/trace_overview/index.tsx index 624aee1e924722..d29dad7a7e3dea 100644 --- a/x-pack/plugins/apm/public/components/app/TraceOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx @@ -6,16 +6,14 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; -import { Projection } from '../../../../common/projections'; -import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { APIReturnType } from '../../../services/rest/createCallApmApi'; -import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { SearchBar } from '../../shared/search_bar'; -import { TraceList } from './TraceList'; import { Correlations } from '../Correlations'; +import { TraceList } from './TraceList'; type TracesAPIResponse = APIReturnType<'GET /api/apm/traces'>; const DEFAULT_RESPONSE: TracesAPIResponse = { @@ -48,32 +46,22 @@ export function TraceOverview() { useTrackPageview({ app: 'apm', path: 'traces_overview' }); useTrackPageview({ app: 'apm', path: 'traces_overview', delay: 15000 }); - const localUIFiltersConfig = useMemo(() => { - const config: React.ComponentProps = { - filterNames: ['transactionResult', 'host', 'containerId', 'podName'], - projection: Projection.traces, - }; - - return config; - }, []); - return ( <> - - - - - - - - - - + + + + + + + + + diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/distribution.test.ts b/x-pack/plugins/apm/public/components/app/transaction_details/Distribution/distribution.test.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/distribution.test.ts rename to x-pack/plugins/apm/public/components/app/transaction_details/Distribution/distribution.test.ts diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/Distribution/index.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/Distribution/index.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/ErrorCount.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/ErrorCount.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/ErrorCount.test.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/ErrorCount.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/ErrorCount.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/ErrorCount.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/ErrorCount.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/ErrorCount.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/MaybeViewTraceLink.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/MaybeViewTraceLink.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/MaybeViewTraceLink.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/MaybeViewTraceLink.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/PercentOfParent.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/PercentOfParent.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/PercentOfParent.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/PercentOfParent.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/TransactionTabs.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/TransactionTabs.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks.test.ts b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks.test.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks.test.ts rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks.test.ts diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks.ts b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks.ts rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks.ts diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.test.ts b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.test.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.test.ts rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.test.ts diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.ts b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.ts rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.ts diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/index.ts b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/index.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/index.ts rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/index.ts diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/ServiceLegends.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/ServiceLegends.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/ServiceLegends.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/ServiceLegends.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/FlyoutTopLevelProperties.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/FlyoutTopLevelProperties.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/FlyoutTopLevelProperties.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/FlyoutTopLevelProperties.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/ResponsiveFlyout.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/ResponsiveFlyout.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/ResponsiveFlyout.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/ResponsiveFlyout.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/DatabaseContext.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/DatabaseContext.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/DatabaseContext.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/DatabaseContext.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/HttpContext.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/HttpContext.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/HttpContext.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/HttpContext.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/StickySpanProperties.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/StickySpanProperties.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/StickySpanProperties.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/StickySpanProperties.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/TruncateHeightSection.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/TruncateHeightSection.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/TruncateHeightSection.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/TruncateHeightSection.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/index.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/index.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/index.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/DroppedSpansWarning.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/DroppedSpansWarning.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/DroppedSpansWarning.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/DroppedSpansWarning.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/index.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/index.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/index.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallFlyout.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallFlyout.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallFlyout.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallFlyout.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/accordion_waterfall.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/accordion_waterfall.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/accordion_waterfall.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/accordion_waterfall.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/mock_responses/spans.json b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/mock_responses/spans.json similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/mock_responses/spans.json rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/mock_responses/spans.json diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/mock_responses/transaction.json b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/mock_responses/transaction.json similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/mock_responses/transaction.json rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/mock_responses/transaction.json diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.test.ts b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.test.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.test.ts rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.test.ts diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/index.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/index.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/index.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/waterfallContainer.stories.data.ts b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/waterfallContainer.stories.data.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/waterfallContainer.stories.data.ts rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/waterfallContainer.stories.data.ts diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/index.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/index.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx similarity index 59% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/index.tsx index b155672405b9f5..d5f5eed311de89 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx @@ -14,26 +14,23 @@ import { EuiSpacer, EuiTitle, } from '@elastic/eui'; -import React, { useMemo } from 'react'; -import { isEmpty, flatten } from 'lodash'; -import { useHistory } from 'react-router-dom'; -import { RouteComponentProps } from 'react-router-dom'; +import { flatten, isEmpty } from 'lodash'; +import React from 'react'; +import { RouteComponentProps, useHistory } from 'react-router-dom'; +import { useTrackPageview } from '../../../../../observability/public'; +import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; import { useTransactionDistributionFetcher } from '../../../hooks/use_transaction_distribution_fetcher'; -import { useWaterfallFetcher } from './use_waterfall_fetcher'; import { ApmHeader } from '../../shared/ApmHeader'; import { TransactionCharts } from '../../shared/charts/transaction_charts'; -import { TransactionDistribution } from './Distribution'; -import { WaterfallWithSummmary } from './WaterfallWithSummmary'; -import { FETCH_STATUS } from '../../../hooks/use_fetcher'; -import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; -import { useTrackPageview } from '../../../../../observability/public'; -import { Projection } from '../../../../common/projections'; -import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { HeightRetainer } from '../../shared/HeightRetainer'; -import { Correlations } from '../Correlations'; +import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; import { SearchBar } from '../../shared/search_bar'; +import { Correlations } from '../Correlations'; +import { TransactionDistribution } from './Distribution'; +import { useWaterfallFetcher } from './use_waterfall_fetcher'; +import { WaterfallWithSummmary } from './WaterfallWithSummmary'; interface Sample { traceId: string; @@ -46,7 +43,6 @@ export function TransactionDetails({ location, match, }: TransactionDetailsProps) { - const { serviceName } = match.params; const { urlParams } = useUrlParams(); const history = useHistory(); const { @@ -59,24 +55,11 @@ export function TransactionDetails({ exceedsMax, status: waterfallStatus, } = useWaterfallFetcher(); - const { transactionName, transactionType } = urlParams; + const { transactionName } = urlParams; useTrackPageview({ app: 'apm', path: 'transaction_details' }); useTrackPageview({ app: 'apm', path: 'transaction_details', delay: 15000 }); - const localUIFiltersConfig = useMemo(() => { - const config: React.ComponentProps = { - filterNames: ['transactionResult', 'serviceVersion'], - projection: Projection.transactions, - params: { - transactionName, - transactionType, - serviceName, - }, - }; - return config; - }, [transactionName, transactionType, serviceName]); - const selectedSample = flatten( distributionData.buckets.map((bucket) => bucket.samples) ).find( @@ -116,45 +99,45 @@ export function TransactionDetails({ - - - - - - - - - - - - - - { - if (!isEmpty(bucket.samples)) { - selectSampleFromBucketClick(bucket.samples[0]); - } - }} - /> - - - - - - - - + + + + + + + + + + + + + + + { + if (!isEmpty(bucket.samples)) { + selectSampleFromBucketClick(bucket.samples[0]); + } + }} + /> + + + + + + + diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/use_waterfall_fetcher.ts b/x-pack/plugins/apm/public/components/app/transaction_details/use_waterfall_fetcher.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionDetails/use_waterfall_fetcher.ts rename to x-pack/plugins/apm/public/components/app/transaction_details/use_waterfall_fetcher.ts diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index 08904da3966781..1f8b431d072b79 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -10,7 +10,6 @@ import { EuiCode, EuiFlexGroup, EuiFlexItem, - EuiHorizontalRule, EuiPage, EuiPanel, EuiSpacer, @@ -19,25 +18,23 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { Location } from 'history'; -import React, { useMemo } from 'react'; +import React from 'react'; import { useLocation } from 'react-router-dom'; import { useTrackPageview } from '../../../../../observability/public'; -import { Projection } from '../../../../common/projections'; import { TRANSACTION_PAGE_LOAD } from '../../../../common/transaction_types'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { IUrlParams } from '../../../context/url_params_context/types'; -import { useTransactionListFetcher } from './use_transaction_list'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; -import { LocalUIFilters } from '../../shared/LocalUIFilters'; -import { TransactionTypeFilter } from '../../shared/LocalUIFilters/TransactionTypeFilter'; import { SearchBar } from '../../shared/search_bar'; +import { TransactionTypeSelect } from '../../shared/transaction_type_select'; import { Correlations } from '../Correlations'; import { TransactionList } from './TransactionList'; import { useRedirect } from './useRedirect'; import { UserExperienceCallout } from './user_experience_callout'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useTransactionListFetcher } from './use_transaction_list'; function getRedirectLocation({ location, @@ -68,7 +65,7 @@ interface TransactionOverviewProps { export function TransactionOverview({ serviceName }: TransactionOverviewProps) { const location = useLocation(); const { urlParams } = useUrlParams(); - const { transactionType, transactionTypes } = useApmServiceContext(); + const { transactionType } = useApmServiceContext(); // redirect to first transaction type useRedirect(getRedirectLocation({ location, transactionType, urlParams })); @@ -80,27 +77,6 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) { transactionListStatus, } = useTransactionListFetcher(); - const localFiltersConfig: React.ComponentProps< - typeof LocalUIFilters - > = useMemo( - () => ({ - shouldFetch: !!transactionType, - filterNames: [ - 'transactionResult', - 'host', - 'containerId', - 'podName', - 'serviceVersion', - ], - params: { - serviceName, - transactionType, - }, - projection: Projection.transactionGroups, - }), - [serviceName, transactionType] - ); - // TODO: improve urlParams typings. // `serviceName` or `transactionType` will never be undefined here, and this check should not be needed if (!serviceName) { @@ -112,74 +88,92 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) { - - - - - + + + + + + + +

+ {i18n.translate('xpack.apm.transactionOverviewTitle', { + defaultMessage: 'Transactions', + })} +

+
+
+ + + +
- -
-
- - {transactionType === TRANSACTION_PAGE_LOAD && ( - <> - - - - )} - - - - -

Transactions

-
- - {!transactionListData.isAggregationAccurate && ( - -

- - xpack.apm.ui.transactionGroupBucketSize - - ), - }} - /> + + + + + - - {i18n.translate( - 'xpack.apm.transactionCardinalityWarning.docsLink', - { defaultMessage: 'Learn more in the docs' } - )} - -

-
- )} + {transactionType === TRANSACTION_PAGE_LOAD && ( + <> + - -
-
+ + )} + + + + +

Transactions

+
+ + {!transactionListData.isAggregationAccurate && ( + +

+ + xpack.apm.ui.transactionGroupBucketSize + + ), + }} + /> + + + {i18n.translate( + 'xpack.apm.transactionCardinalityWarning.docsLink', + { defaultMessage: 'Learn more in the docs' } + )} + +

+
+ )} + + +
diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx index e0b1a4cbd05d5e..7d0ada3e31bffc 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx @@ -136,7 +136,9 @@ describe('TransactionOverview', () => { expect(getByText(container, 'firstType')).toBeInTheDocument(); expect(getByText(container, 'secondType')).toBeInTheDocument(); - fireEvent.click(getByText(container, 'firstType')); + fireEvent.change(getByText(container, 'firstType').parentElement!, { + target: { value: 'firstType' }, + }); expect(history.push).toHaveBeenCalled(); expect(history.location.search).toEqual( diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/TransactionTypeFilter/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/TransactionTypeFilter/index.tsx deleted file mode 100644 index 19eefca5ee27e7..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/TransactionTypeFilter/index.tsx +++ /dev/null @@ -1,65 +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 { - EuiHorizontalRule, - EuiRadioGroup, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { useHistory } from 'react-router-dom'; -import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; -import { fromQuery, toQuery } from '../../Links/url_helpers'; - -interface Props { - transactionTypes: string[]; -} - -function TransactionTypeFilter({ transactionTypes }: Props) { - const history = useHistory(); - const { - urlParams: { transactionType }, - } = useUrlParams(); - - const options = transactionTypes.map((type) => ({ - id: type, - label: type, - })); - - return ( - <> - -

- {i18n.translate('xpack.apm.localFilters.titles.transactionType', { - defaultMessage: 'Transaction type', - })} -

-
- - - - { - const newLocation = { - ...history.location, - search: fromQuery({ - ...toQuery(history.location.search), - transactionType: selectedTransactionType, - }), - }; - history.push(newLocation); - }} - /> - - ); -} - -export { TransactionTypeFilter }; diff --git a/x-pack/plugins/apm/public/components/shared/Summary/DurationSummaryItem.tsx b/x-pack/plugins/apm/public/components/shared/Summary/DurationSummaryItem.tsx index 94fc79dd2164e0..1ceccc5203fb2f 100644 --- a/x-pack/plugins/apm/public/components/shared/Summary/DurationSummaryItem.tsx +++ b/x-pack/plugins/apm/public/components/shared/Summary/DurationSummaryItem.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiToolTip, EuiText } from '@elastic/eui'; import { asDuration } from '../../../../common/utils/formatters'; -import { PercentOfParent } from '../../app/TransactionDetails/WaterfallWithSummmary/PercentOfParent'; +import { PercentOfParent } from '../../app/transaction_details/WaterfallWithSummmary/PercentOfParent'; interface Props { duration: number; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.test.tsx index 28a581d09908e1..1411a264b065ef 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.test.tsx @@ -8,7 +8,7 @@ import { shallow } from 'enzyme'; import React from 'react'; import { AgentMarker } from './AgentMarker'; -import { AgentMark } from '../../../../app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks'; +import { AgentMark } from '../../../../app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks'; import { EuiThemeProvider } from '../../../../../../../../../src/plugins/kibana_react/common'; describe('AgentMarker', () => { diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.tsx index f669063f07545c..ad8b85ba70c9b7 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.tsx @@ -12,7 +12,7 @@ import { asDuration } from '../../../../../../common/utils/formatters'; import { useTheme } from '../../../../../hooks/use_theme'; import { px, units } from '../../../../../style/variables'; import { Legend } from '../../Legend'; -import { AgentMark } from '../../../../app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks'; +import { AgentMark } from '../../../../app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks'; const NameContainer = styled.div` border-bottom: 1px solid ${({ theme }) => theme.eui.euiColorMediumShade}; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx index 29e553235e57b8..36634f97a3a450 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx @@ -14,7 +14,7 @@ import { expectTextsInDocument, renderWithTheme, } from '../../../../../utils/testHelpers'; -import { ErrorMark } from '../../../../app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks'; +import { ErrorMark } from '../../../../app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks'; import { ErrorMarker } from './ErrorMarker'; function Wrapper({ children }: { children?: ReactNode }) { diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx index c38cc079559964..393281b2bf848c 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx @@ -16,7 +16,7 @@ import { } from '../../../../../../common/elasticsearch_fieldnames'; import { useUrlParams } from '../../../../../context/url_params_context/use_url_params'; import { px, unit, units } from '../../../../../style/variables'; -import { ErrorMark } from '../../../../app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks'; +import { ErrorMark } from '../../../../app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks'; import { ErrorDetailLink } from '../../../Links/apm/ErrorDetailLink'; import { Legend, Shape } from '../../Legend'; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/index.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/index.test.tsx index 16ded0b2402c44..f156d82f05a515 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/index.test.tsx @@ -8,8 +8,8 @@ import { shallow } from 'enzyme'; import React from 'react'; import { Marker } from './'; -import { AgentMark } from '../../../../app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks'; -import { ErrorMark } from '../../../../app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks'; +import { AgentMark } from '../../../../app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks'; +import { ErrorMark } from '../../../../app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks'; describe('Marker', () => { it('renders agent marker', () => { diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/index.tsx index 14688fe7e0c61a..b426a10a7562d2 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/index.tsx @@ -10,8 +10,8 @@ import styled from 'styled-components'; import { px } from '../../../../../style/variables'; import { AgentMarker } from './AgentMarker'; import { ErrorMarker } from './ErrorMarker'; -import { AgentMark } from '../../../../app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks'; -import { ErrorMark } from '../../../../app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks'; +import { AgentMark } from '../../../../app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks'; +import { ErrorMark } from '../../../../app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks'; interface Props { mark: ErrorMark | AgentMark; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/VerticalLines.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/VerticalLines.tsx index 218bdde37abd08..428da80fb808a4 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/VerticalLines.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/VerticalLines.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { VerticalGridLines, XYPlot } from 'react-vis'; import { useTheme } from '../../../../hooks/use_theme'; -import { Mark } from '../../../app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks'; +import { Mark } from '../../../app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks'; import { PlotValues } from './plotUtils'; interface VerticalLinesProps { diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/index.tsx index 84bdd7998cfad9..650faa195271c5 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/index.tsx @@ -11,8 +11,8 @@ import { makeWidthFlexible } from 'react-vis'; import { getPlotValues } from './plotUtils'; import { TimelineAxis } from './TimelineAxis'; import { VerticalLines } from './VerticalLines'; -import { ErrorMark } from '../../../app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks'; -import { AgentMark } from '../../../app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks'; +import { ErrorMark } from '../../../app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks'; +import { AgentMark } from '../../../app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks'; export type Mark = AgentMark | ErrorMark; diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.tsx index 296ec3c2d32e99..34ba1d86264c17 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.tsx @@ -16,7 +16,7 @@ import { useBreakPoints } from '../../hooks/use_break_points'; const SearchBarFlexGroup = styled(EuiFlexGroup)` margin: ${({ theme }) => - `${theme.eui.euiSizeM} ${theme.eui.euiSizeM} -${theme.eui.gutterTypes.gutterMedium} ${theme.eui.euiSizeM}`}; + `${theme.eui.euiSizeS} ${theme.eui.euiSizeS} -${theme.eui.gutterTypes.gutterMedium} ${theme.eui.euiSizeS}`}; `; interface Props { diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx index 52d971a5511440..4ace78f74ee79e 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx @@ -17,6 +17,7 @@ import { } from '../../../utils/testHelpers'; import { TimeComparison } from './'; import * as urlHelpers from '../../shared/Links/url_helpers'; +import moment from 'moment'; function getWrapper(params?: IUrlParams) { return ({ children }: { children?: ReactNode }) => { @@ -31,6 +32,10 @@ function getWrapper(params?: IUrlParams) { } describe('TimeComparison', () => { + beforeAll(() => { + moment.tz.setDefault('Europe/Amsterdam'); + }); + afterAll(() => moment.tz.setDefault('')); const spy = jest.spyOn(urlHelpers, 'replace'); beforeEach(() => { jest.resetAllMocks(); @@ -40,6 +45,7 @@ describe('TimeComparison', () => { const Wrapper = getWrapper({ start: '2021-01-28T14:45:00.000Z', end: '2021-01-28T15:00:00.000Z', + rangeTo: 'now', }); render(, { wrapper: Wrapper, @@ -57,6 +63,7 @@ describe('TimeComparison', () => { end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, comparisonType: 'yesterday', + rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, @@ -67,13 +74,64 @@ describe('TimeComparison', () => { .selectedIndex ).toEqual(0); }); + + it('enables yesterday option when date difference is equal to 24 hours', () => { + const Wrapper = getWrapper({ + start: '2021-01-28T10:00:00.000Z', + end: '2021-01-29T10:00:00.000Z', + comparisonEnabled: true, + comparisonType: 'yesterday', + rangeTo: 'now', + }); + const component = render(, { + wrapper: Wrapper, + }); + expectTextsInDocument(component, ['Yesterday', 'A week ago']); + expect( + (component.getByTestId('comparisonSelect') as HTMLSelectElement) + .selectedIndex + ).toEqual(0); + }); + + it('selects previous period when rangeTo is different than now', () => { + const Wrapper = getWrapper({ + start: '2021-01-28T10:00:00.000Z', + end: '2021-01-29T10:00:00.000Z', + comparisonEnabled: true, + comparisonType: 'previousPeriod', + rangeTo: 'now-15m', + }); + const component = render(, { + wrapper: Wrapper, + }); + expectTextsInDocument(component, ['28/01 11:00 - 29/01 11:00']); + expect( + (component.getByTestId('comparisonSelect') as HTMLSelectElement) + .selectedIndex + ).toEqual(0); + }); }); describe('Time range is between 24 hours - 1 week', () => { + it("doesn't show yesterday option when date difference is greater than 24 hours", () => { + const Wrapper = getWrapper({ + start: '2021-01-28T10:00:00.000Z', + end: '2021-01-29T11:00:00.000Z', + comparisonEnabled: true, + comparisonType: 'week', + rangeTo: 'now', + }); + const component = render(, { + wrapper: Wrapper, + }); + expectTextsNotInDocument(component, ['Yesterday']); + expectTextsInDocument(component, ['A week ago']); + }); it('sets default values', () => { const Wrapper = getWrapper({ start: '2021-01-26T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', + rangeTo: 'now', }); render(, { wrapper: Wrapper, @@ -91,6 +149,7 @@ describe('TimeComparison', () => { end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, comparisonType: 'week', + rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, @@ -102,6 +161,24 @@ describe('TimeComparison', () => { .selectedIndex ).toEqual(0); }); + + it('selects previous period when rangeTo is different than now', () => { + const Wrapper = getWrapper({ + start: '2021-01-26T15:00:00.000Z', + end: '2021-01-28T15:00:00.000Z', + comparisonEnabled: true, + comparisonType: 'previousPeriod', + rangeTo: '2021-01-28T15:00:00.000Z', + }); + const component = render(, { + wrapper: Wrapper, + }); + expectTextsInDocument(component, ['26/01 16:00 - 28/01 16:00']); + expect( + (component.getByTestId('comparisonSelect') as HTMLSelectElement) + .selectedIndex + ).toEqual(0); + }); }); describe('Time range is greater than 7 days', () => { @@ -111,12 +188,13 @@ describe('TimeComparison', () => { end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, comparisonType: 'previousPeriod', + rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); expect(spy).not.toHaveBeenCalled(); - expectTextsInDocument(component, ['20/01 - 28/01']); + expectTextsInDocument(component, ['20/01 16:00 - 28/01 16:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -129,12 +207,13 @@ describe('TimeComparison', () => { end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, comparisonType: 'previousPeriod', + rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); expect(spy).not.toHaveBeenCalled(); - expectTextsInDocument(component, ['20/12/20 - 28/01/21']); + expectTextsInDocument(component, ['20/12/20 16:00 - 28/01/21 16:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx index bb50ca1a45e8ca..02064ea786fb09 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx @@ -33,14 +33,21 @@ function formatPreviousPeriodDates({ momentEnd: moment.Moment; }) { const isDifferentYears = momentStart.get('year') !== momentEnd.get('year'); - const dateFormat = isDifferentYears ? 'DD/MM/YY' : 'DD/MM'; + const dateFormat = isDifferentYears ? 'DD/MM/YY HH:mm' : 'DD/MM HH:mm'; return `${momentStart.format(dateFormat)} - ${momentEnd.format(dateFormat)}`; } -function getSelectOptions({ start, end }: { start?: string; end?: string }) { +function getSelectOptions({ + start, + end, + rangeTo, +}: { + start?: string; + end?: string; + rangeTo?: string; +}) { const momentStart = moment(start); const momentEnd = moment(end); - const dateDiff = getDateDifference(momentStart, momentEnd, 'days'); const yesterdayOption = { value: 'yesterday', @@ -56,22 +63,32 @@ function getSelectOptions({ start, end }: { start?: string; end?: string }) { }), }; + const dateDiff = getDateDifference({ + start: momentStart, + end: momentEnd, + unitOfTime: 'days', + precise: true, + }); + const isRangeToNow = rangeTo === 'now'; + + if (isRangeToNow) { + // Less than or equals to one day + if (dateDiff <= 1) { + return [yesterdayOption, aWeekAgoOption]; + } + + // Less than or equals to one week + if (dateDiff <= 7) { + return [aWeekAgoOption]; + } + } + const prevPeriodOption = { value: 'previousPeriod', text: formatPreviousPeriodDates({ momentStart, momentEnd }), }; - // Less than one day - if (dateDiff < 1) { - return [yesterdayOption, aWeekAgoOption]; - } - - // Less than one week - if (dateDiff <= 7) { - return [aWeekAgoOption]; - } - - // above one week + // above one week or when rangeTo is not "now" return [prevPeriodOption]; } @@ -79,10 +96,10 @@ export function TimeComparison() { const history = useHistory(); const { isMedium, isLarge } = useBreakPoints(); const { - urlParams: { start, end, comparisonEnabled, comparisonType }, + urlParams: { start, end, comparisonEnabled, comparisonType, rangeTo }, } = useUrlParams(); - const selectOptions = getSelectOptions({ start, end }); + const selectOptions = getSelectOptions({ start, end, rangeTo }); // Sets default values if (comparisonEnabled === undefined || comparisonType === undefined) { @@ -113,7 +130,7 @@ export function TimeComparison() { 0} + checked={comparisonEnabled} onChange={() => { urlHelpers.push(history, { query: { 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 new file mode 100644 index 00000000000000..772b42ed13577f --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +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'; +import { useApmServiceContext } from '../../context/apm_service/use_apm_service_context'; +import { useUrlParams } from '../../context/url_params_context/use_url_params'; +import * as urlHelpers from './Links/url_helpers'; + +// The default transaction type (for non-RUM services) is "request". Set the +// 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; +`; + +export function TransactionTypeSelect() { + const { transactionTypes } = useApmServiceContext(); + const history = useHistory(); + const { + urlParams: { transactionType }, + } = useUrlParams(); + + const handleChange = useCallback( + (event: FormEvent) => { + const selectedTransactionType = event.currentTarget.value; + urlHelpers.push(history, { + query: { transactionType: selectedTransactionType }, + }); + }, + [history] + ); + + const options = transactionTypes.map((t) => ({ text: t, value: t })); + + return ( + <> + + + ); +} diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 610442d4ff614e..5d580fc0e253a5 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -66,15 +66,8 @@ import { transactionThroughputChatsRoute, } from './transactions'; import { - errorGroupsLocalFiltersRoute, - metricsLocalFiltersRoute, - servicesLocalFiltersRoute, - tracesLocalFiltersRoute, - transactionGroupsLocalFiltersRoute, - transactionsLocalFiltersRoute, - serviceNodesLocalFiltersRoute, - uiFiltersEnvironmentsRoute, rumOverviewLocalFiltersRoute, + uiFiltersEnvironmentsRoute, } from './ui_filters'; import { serviceMapRoute, serviceMapServiceNodeRoute } from './service_map'; import { @@ -176,13 +169,6 @@ const createApmApi = () => { .add(transactionThroughputChatsRoute) // UI filters - .add(errorGroupsLocalFiltersRoute) - .add(metricsLocalFiltersRoute) - .add(servicesLocalFiltersRoute) - .add(tracesLocalFiltersRoute) - .add(transactionGroupsLocalFiltersRoute) - .add(transactionsLocalFiltersRoute) - .add(serviceNodesLocalFiltersRoute) .add(uiFiltersEnvironmentsRoute) // Service map diff --git a/x-pack/plugins/apm/server/routes/ui_filters.ts b/x-pack/plugins/apm/server/routes/ui_filters.ts index 9cedbf16e161b4..b14a47e302caa9 100644 --- a/x-pack/plugins/apm/server/routes/ui_filters.ts +++ b/x-pack/plugins/apm/server/routes/ui_filters.ts @@ -7,29 +7,23 @@ import * as t from 'io-ts'; import { omit } from 'lodash'; +import { jsonRt } from '../../common/runtime_types/json_rt'; +import { LocalUIFilterName } from '../../common/ui_filter'; +import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; +import { getEsFilter } from '../lib/helpers/convert_ui_filters/get_es_filter'; import { - setupRequest, Setup, + setupRequest, SetupTimeRange, } from '../lib/helpers/setup_request'; import { getEnvironments } from '../lib/ui_filters/get_environments'; -import { Projection } from '../projections/typings'; -import { localUIFilterNames } from '../lib/ui_filters/local_ui_filters/config'; -import { getEsFilter } from '../lib/helpers/convert_ui_filters/get_es_filter'; import { getLocalUIFilters } from '../lib/ui_filters/local_ui_filters'; -import { getServicesProjection } from '../projections/services'; -import { getTransactionGroupsProjection } from '../projections/transaction_groups'; -import { getMetricsProjection } from '../projections/metrics'; -import { getErrorGroupsProjection } from '../projections/errors'; -import { getTransactionsProjection } from '../projections/transactions'; -import { createRoute } from './create_route'; -import { uiFiltersRt, rangeRt } from './default_api_types'; -import { jsonRt } from '../../common/runtime_types/json_rt'; -import { getServiceNodesProjection } from '../projections/service_nodes'; +import { localUIFilterNames } from '../lib/ui_filters/local_ui_filters/config'; import { getRumPageLoadTransactionsProjection } from '../projections/rum_page_load_transactions'; -import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; +import { Projection } from '../projections/typings'; +import { createRoute } from './create_route'; +import { rangeRt, uiFiltersRt } from './default_api_types'; import { APMRequestHandlerContext } from './typings'; -import { LocalUIFilterName } from '../../common/ui_filter'; export const uiFiltersEnvironmentsRoute = createRoute({ endpoint: 'GET /api/apm/ui_filters/environments', @@ -122,136 +116,6 @@ function createLocalFiltersRoute< }); } -export const servicesLocalFiltersRoute = createLocalFiltersRoute({ - endpoint: `GET /api/apm/ui_filters/local_filters/services`, - getProjection: async ({ context, setup }) => { - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); - - return getServicesProjection({ setup, searchAggregatedTransactions }); - }, - queryRt: t.type({}), -}); - -export const transactionGroupsLocalFiltersRoute = createLocalFiltersRoute({ - endpoint: 'GET /api/apm/ui_filters/local_filters/transactionGroups', - getProjection: async ({ context, setup, query }) => { - const { transactionType, serviceName, transactionName } = query; - - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); - - return getTransactionGroupsProjection({ - setup, - options: { - type: 'top_transactions', - transactionType, - serviceName, - transactionName, - searchAggregatedTransactions, - }, - }); - }, - queryRt: t.intersection([ - t.type({ - serviceName: t.string, - transactionType: t.string, - }), - t.partial({ - transactionName: t.string, - }), - ]), -}); - -export const tracesLocalFiltersRoute = createLocalFiltersRoute({ - endpoint: 'GET /api/apm/ui_filters/local_filters/traces', - getProjection: async ({ setup, context }) => { - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); - - return getTransactionGroupsProjection({ - setup, - options: { type: 'top_traces', searchAggregatedTransactions }, - }); - }, - queryRt: t.type({}), -}); - -export const transactionsLocalFiltersRoute = createLocalFiltersRoute({ - endpoint: 'GET /api/apm/ui_filters/local_filters/transactions', - getProjection: async ({ context, setup, query }) => { - const { transactionType, serviceName, transactionName } = query; - - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); - - return getTransactionsProjection({ - setup, - transactionType, - serviceName, - transactionName, - searchAggregatedTransactions, - }); - }, - queryRt: t.type({ - transactionType: t.string, - transactionName: t.string, - serviceName: t.string, - }), -}); - -export const metricsLocalFiltersRoute = createLocalFiltersRoute({ - endpoint: 'GET /api/apm/ui_filters/local_filters/metrics', - getProjection: ({ setup, query }) => { - const { serviceName, serviceNodeName } = query; - return getMetricsProjection({ - setup, - serviceName, - serviceNodeName, - }); - }, - queryRt: t.intersection([ - t.type({ - serviceName: t.string, - }), - t.partial({ - serviceNodeName: t.string, - }), - ]), -}); - -export const errorGroupsLocalFiltersRoute = createLocalFiltersRoute({ - endpoint: 'GET /api/apm/ui_filters/local_filters/errorGroups', - getProjection: ({ setup, query }) => { - const { serviceName } = query; - return getErrorGroupsProjection({ - setup, - serviceName, - }); - }, - queryRt: t.type({ - serviceName: t.string, - }), -}); - -export const serviceNodesLocalFiltersRoute = createLocalFiltersRoute({ - endpoint: 'GET /api/apm/ui_filters/local_filters/serviceNodes', - getProjection: ({ setup, query }) => { - const { serviceName } = query; - return getServiceNodesProjection({ - setup, - serviceName, - }); - }, - queryRt: t.type({ - serviceName: t.string, - }), -}); - export const rumOverviewLocalFiltersRoute = createLocalFiltersRoute({ endpoint: 'GET /api/apm/ui_filters/local_filters/rumOverview', getProjection: async ({ setup }) => { diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/cancel_button.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/delete_button.tsx similarity index 70% rename from x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/cancel_button.tsx rename to x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/delete_button.tsx index 5edbc7b08985c2..d505752ec3fad9 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/cancel_button.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/delete_button.tsx @@ -13,31 +13,31 @@ import { SearchSessionsMgmtAPI } from '../../lib/api'; import { TableText } from '../'; import { OnActionComplete } from './types'; -interface CancelButtonProps { +interface DeleteButtonProps { id: string; name: string; api: SearchSessionsMgmtAPI; onActionComplete: OnActionComplete; } -const CancelConfirm = ({ - onConfirmDismiss, +const DeleteConfirm = ({ + onConfirmCancel, ...props -}: CancelButtonProps & { onConfirmDismiss: () => void }) => { +}: DeleteButtonProps & { onConfirmCancel: () => void }) => { const { id, name, api, onActionComplete } = props; const [isLoading, setIsLoading] = useState(false); const title = i18n.translate('xpack.data.mgmt.searchSessions.cancelModal.title', { - defaultMessage: 'Cancel search session', + defaultMessage: 'Delete search session', }); - const confirm = i18n.translate('xpack.data.mgmt.searchSessions.cancelModal.cancelButton', { - defaultMessage: 'Cancel', + const confirm = i18n.translate('xpack.data.mgmt.searchSessions.cancelModal.deleteButton', { + defaultMessage: 'Delete', }); - const cancel = i18n.translate('xpack.data.mgmt.searchSessions.cancelModal.dontCancelButton', { - defaultMessage: 'Dismiss', + const cancel = i18n.translate('xpack.data.mgmt.searchSessions.cancelModal.cancelButton', { + defaultMessage: 'Cancel', }); const message = i18n.translate('xpack.data.mgmt.searchSessions.cancelModal.message', { - defaultMessage: `Canceling the search session \'{name}\' will expire any cached results, so that quick restore will no longer be available. You will still be able to re-run it, using the reload action.`, + defaultMessage: `Deleting the search session \'{name}\' deletes all cached results.`, values: { name, }, @@ -47,7 +47,7 @@ const CancelConfirm = ({ { setIsLoading(true); await api.sendCancel(id); @@ -65,14 +65,14 @@ const CancelConfirm = ({ ); }; -export const CancelButton = (props: CancelButtonProps) => { +export const DeleteButton = (props: DeleteButtonProps) => { const [showConfirm, setShowConfirm] = useState(false); const onClick = () => { setShowConfirm(true); }; - const onConfirmDismiss = () => { + const onConfirmCancel = () => { setShowConfirm(false); }; @@ -80,11 +80,11 @@ export const CancelButton = (props: CancelButtonProps) => { <> - {showConfirm ? : null} + {showConfirm ? : null} ); }; diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/get_action.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/get_action.tsx index bc849abf125c15..edc5037f1dbec9 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/get_action.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/get_action.tsx @@ -10,30 +10,22 @@ import { IClickActionDescriptor } from '../'; import extendSessionIcon from '../../icons/extend_session.svg'; import { SearchSessionsMgmtAPI } from '../../lib/api'; import { UISession } from '../../types'; -import { CancelButton } from './cancel_button'; +import { DeleteButton } from './delete_button'; import { ExtendButton } from './extend_button'; -import { ReloadButton } from './reload_button'; import { ACTION, OnActionComplete } from './types'; export const getAction = ( api: SearchSessionsMgmtAPI, actionType: string, - { id, name, expires, reloadUrl }: UISession, + { id, name, expires }: UISession, onActionComplete: OnActionComplete ): IClickActionDescriptor | null => { switch (actionType) { - case ACTION.CANCEL: + case ACTION.DELETE: return { iconType: 'crossInACircleFilled', textColor: 'default', - label: , - }; - - case ACTION.RELOAD: - return { - iconType: 'refresh', - textColor: 'default', - label: , + label: , }; case ACTION.EXTEND: diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/popover_actions.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/popover_actions.tsx index e47a9a5944b244..fe71b5125dfbb0 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/popover_actions.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/popover_actions.tsx @@ -90,7 +90,7 @@ export const PopoverActionsMenu = ({ api, onActionComplete, session }: PopoverAc // add a line above the delete action (when there are multiple) // NOTE: Delete action MUST be the final action[] item - if (actions.length > 1 && actionType === ACTION.CANCEL) { + if (actions.length > 1 && actionType === ACTION.DELETE) { itemSet.push({ isSeparator: true, key: 'separadorable' }); } diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/reload_button.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/reload_button.tsx deleted file mode 100644 index 70ca279c2450df..00000000000000 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/reload_button.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; -import { TableText } from '../'; -import { SearchSessionsMgmtAPI } from '../../lib/api'; - -interface ReloadButtonProps { - api: SearchSessionsMgmtAPI; - reloadUrl: string; -} - -export const ReloadButton = (props: ReloadButtonProps) => { - function onClick() { - props.api.reloadSearchSession(props.reloadUrl); - } - - return ( - <> - - - - - ); -}; diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/types.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/types.ts index 97e67909baea2a..5f82f16adcbb6f 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/types.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/types.ts @@ -9,6 +9,5 @@ export type OnActionComplete = () => void; export enum ACTION { EXTEND = 'extend', - CANCEL = 'cancel', - RELOAD = 'reload', + DELETE = 'delete', } diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.test.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.test.ts index 2ec9d588d7fd7d..86acbcdb530016 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.test.ts @@ -61,9 +61,8 @@ describe('Search Sessions Management API', () => { Array [ Object { "actions": Array [ - "reload", "extend", - "cancel", + "delete", ], "appId": "pizza", "created": undefined, @@ -146,7 +145,7 @@ describe('Search Sessions Management API', () => { await api.sendCancel('abc-123-cool-session-ID'); expect(mockCoreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith({ - title: 'The search session was canceled and expired.', + title: 'The search session was deleted.', }); }); @@ -162,37 +161,11 @@ describe('Search Sessions Management API', () => { expect(mockCoreStart.notifications.toasts.addError).toHaveBeenCalledWith( new Error('implementation is so bad'), - { title: 'Failed to cancel the search session!' } + { title: 'Failed to delete the search session!' } ); }); }); - describe('reload', () => { - beforeEach(() => { - sessionsClient.find = jest.fn().mockImplementation(async () => { - return { - saved_objects: [ - { - id: 'hello-pizza-123', - attributes: { name: 'Veggie', appId: 'pizza', status: SearchSessionStatus.COMPLETE }, - }, - ], - } as SavedObjectsFindResponse; - }); - }); - - test('send cancel calls the cancel endpoint with a session ID', async () => { - const api = new SearchSessionsMgmtAPI(sessionsClient, mockConfig, { - urls: mockUrls, - notifications: mockCoreStart.notifications, - application: mockCoreStart.application, - }); - await api.reloadSearchSession('www.myurl.com'); - - expect(mockCoreStart.application.navigateToUrl).toHaveBeenCalledWith('www.myurl.com'); - }); - }); - describe('extend', () => { beforeEach(() => { sessionsClient.find = jest.fn().mockImplementation(async () => { diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts index 1b024dae1bfcab..264556f91cc372 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts @@ -21,10 +21,9 @@ type UrlGeneratorsStart = SharePluginStart['urlGenerators']; function getActions(status: SearchSessionStatus) { const actions: ACTION[] = []; - actions.push(ACTION.RELOAD); if (status === SearchSessionStatus.IN_PROGRESS || status === SearchSessionStatus.COMPLETE) { actions.push(ACTION.EXTEND); - actions.push(ACTION.CANCEL); + actions.push(ACTION.DELETE); } return actions; } @@ -162,8 +161,8 @@ export class SearchSessionsMgmtAPI { await this.sessionsClient.delete(id); this.deps.notifications.toasts.addSuccess({ - title: i18n.translate('xpack.data.mgmt.searchSessions.api.canceled', { - defaultMessage: 'The search session was canceled and expired.', + title: i18n.translate('xpack.data.mgmt.searchSessions.api.deleted', { + defaultMessage: 'The search session was deleted.', }), }); } catch (err) { @@ -171,8 +170,8 @@ export class SearchSessionsMgmtAPI { console.error(err); this.deps.notifications.toasts.addError(err, { - title: i18n.translate('xpack.data.mgmt.searchSessions.api.cancelError', { - defaultMessage: 'Failed to cancel the search session!', + title: i18n.translate('xpack.data.mgmt.searchSessions.api.deletedError', { + defaultMessage: 'Failed to delete the search session!', }), }); } diff --git a/x-pack/plugins/data_enhanced/server/plugin.ts b/x-pack/plugins/data_enhanced/server/plugin.ts index 76235c917b1398..3aaf50fbeb3e69 100644 --- a/x-pack/plugins/data_enhanced/server/plugin.ts +++ b/x-pack/plugins/data_enhanced/server/plugin.ts @@ -6,7 +6,6 @@ */ import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; -import { Observable } from 'rxjs'; import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; import { PluginSetup as DataPluginSetup, @@ -40,11 +39,11 @@ export class EnhancedDataServerPlugin implements Plugin { private readonly logger: Logger; private sessionService!: SearchSessionService; - private config$: Observable; + private config: ConfigSchema; constructor(private initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get('data_enhanced'); - this.config$ = this.initializerContext.config.create(); + this.config = this.initializerContext.config.get(); } public setup(core: CoreSetup, deps: SetupDependencies) { @@ -56,7 +55,7 @@ export class EnhancedDataServerPlugin deps.data.search.registerSearchStrategy( ENHANCED_ES_SEARCH_STRATEGY, enhancedEsSearchStrategyProvider( - this.config$, + this.config, this.initializerContext.config.legacy.globalConfig$, this.logger, usage @@ -68,10 +67,7 @@ export class EnhancedDataServerPlugin eqlSearchStrategyProvider(this.logger) ); - this.sessionService = new SearchSessionService( - this.logger, - this.initializerContext.config.create() - ); + this.sessionService = new SearchSessionService(this.logger, this.config); deps.data.__enhance({ search: { diff --git a/x-pack/plugins/data_enhanced/server/routes/session.test.ts b/x-pack/plugins/data_enhanced/server/routes/session.test.ts index 830524da0fb97b..ebc501346aed21 100644 --- a/x-pack/plugins/data_enhanced/server/routes/session.test.ts +++ b/x-pack/plugins/data_enhanced/server/routes/session.test.ts @@ -16,6 +16,13 @@ import type { import { dataPluginMock } from '../../../../../src/plugins/data/server/mocks'; import { registerSessionRoutes } from './session'; +enum PostHandlerIndex { + SAVE, + FIND, + CANCEL, + EXTEND, +} + describe('registerSessionRoutes', () => { let mockCoreSetup: MockedKeys>; let mockContext: jest.Mocked; @@ -37,7 +44,7 @@ describe('registerSessionRoutes', () => { const mockResponse = httpServerMock.createResponseFactory(); const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; - const [[, saveHandler]] = mockRouter.post.mock.calls; + const [, saveHandler] = mockRouter.post.mock.calls[PostHandlerIndex.SAVE]; saveHandler(mockContext, mockRequest, mockResponse); @@ -71,7 +78,7 @@ describe('registerSessionRoutes', () => { const mockResponse = httpServerMock.createResponseFactory(); const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; - const [, [, findHandler]] = mockRouter.post.mock.calls; + const [, findHandler] = mockRouter.post.mock.calls[PostHandlerIndex.FIND]; findHandler(mockContext, mockRequest, mockResponse); @@ -89,14 +96,14 @@ describe('registerSessionRoutes', () => { const mockResponse = httpServerMock.createResponseFactory(); const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; - const [[, updateHandler]] = mockRouter.put.mock.calls; + const [, updateHandler] = mockRouter.put.mock.calls[0]; updateHandler(mockContext, mockRequest, mockResponse); expect(mockContext.search!.updateSession).toHaveBeenCalledWith(id, body); }); - it('delete calls cancelSession with id', async () => { + it('cancel calls cancelSession with id', async () => { const id = 'd7170a35-7e2c-48d6-8dec-9a056721b489'; const params = { id }; @@ -104,13 +111,28 @@ describe('registerSessionRoutes', () => { const mockResponse = httpServerMock.createResponseFactory(); const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; - const [[, deleteHandler]] = mockRouter.delete.mock.calls; + const [, cancelHandler] = mockRouter.post.mock.calls[PostHandlerIndex.CANCEL]; - deleteHandler(mockContext, mockRequest, mockResponse); + cancelHandler(mockContext, mockRequest, mockResponse); expect(mockContext.search!.cancelSession).toHaveBeenCalledWith(id); }); + it('delete calls deleteSession with id', async () => { + const id = 'd7170a35-7e2c-48d6-8dec-9a056721b489'; + const params = { id }; + + const mockRequest = httpServerMock.createKibanaRequest({ params }); + const mockResponse = httpServerMock.createResponseFactory(); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const [, deleteHandler] = mockRouter.delete.mock.calls[0]; + + await deleteHandler(mockContext, mockRequest, mockResponse); + + expect(mockContext.search!.deleteSession).toHaveBeenCalledWith(id); + }); + it('extend calls extendSession with id', async () => { const id = 'd7170a35-7e2c-48d6-8dec-9a056721b489'; const expires = new Date().toISOString(); @@ -121,7 +143,7 @@ describe('registerSessionRoutes', () => { const mockResponse = httpServerMock.createResponseFactory(); const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; - const [, , [, extendHandler]] = mockRouter.post.mock.calls; + const [, extendHandler] = mockRouter.post.mock.calls[PostHandlerIndex.EXTEND]; extendHandler(mockContext, mockRequest, mockResponse); diff --git a/x-pack/plugins/data_enhanced/server/routes/session.ts b/x-pack/plugins/data_enhanced/server/routes/session.ts index 0b953f8201ecea..80388a84d98f81 100644 --- a/x-pack/plugins/data_enhanced/server/routes/session.ts +++ b/x-pack/plugins/data_enhanced/server/routes/session.ts @@ -129,6 +129,29 @@ export function registerSessionRoutes(router: DataEnhancedPluginRouter, logger: }), }, }, + async (context, request, res) => { + const { id } = request.params; + try { + await context.search!.deleteSession(id); + + return res.ok(); + } catch (e) { + const err = e.output?.payload || e; + logger.error(err); + return reportServerError(res, err); + } + } + ); + + router.post( + { + path: '/internal/session/{id}/cancel', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, async (context, request, res) => { const { id } = request.params; try { diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts index 019b94f638ca4a..d529e981aaea10 100644 --- a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts @@ -72,13 +72,13 @@ describe('ES search strategy', () => { }, }); - const mockConfig$ = new BehaviorSubject({ + const mockConfig: any = { search: { sessions: { defaultExpiration: moment.duration('1', 'm'), }, }, - }); + }; beforeEach(() => { mockApiCaller.mockClear(); @@ -89,7 +89,7 @@ describe('ES search strategy', () => { it('returns a strategy with `search and `cancel`', async () => { const esSearch = await enhancedEsSearchStrategyProvider( - mockConfig$, + mockConfig, mockLegacyConfig$, mockLogger ); @@ -104,7 +104,7 @@ describe('ES search strategy', () => { const params = { index: 'logstash-*', body: { query: {} } }; const esSearch = await enhancedEsSearchStrategyProvider( - mockConfig$, + mockConfig, mockLegacyConfig$, mockLogger ); @@ -123,7 +123,7 @@ describe('ES search strategy', () => { const params = { index: 'logstash-*', body: { query: {} } }; const esSearch = await enhancedEsSearchStrategyProvider( - mockConfig$, + mockConfig, mockLegacyConfig$, mockLogger ); @@ -142,7 +142,7 @@ describe('ES search strategy', () => { const params = { index: 'foo-*', body: {} }; const esSearch = await enhancedEsSearchStrategyProvider( - mockConfig$, + mockConfig, mockLegacyConfig$, mockLogger ); @@ -160,7 +160,7 @@ describe('ES search strategy', () => { const params = { index: 'foo-程', body: {} }; const esSearch = await enhancedEsSearchStrategyProvider( - mockConfig$, + mockConfig, mockLegacyConfig$, mockLogger ); @@ -189,7 +189,7 @@ describe('ES search strategy', () => { const params = { index: 'logstash-*', body: { query: {} } }; const esSearch = await enhancedEsSearchStrategyProvider( - mockConfig$, + mockConfig, mockLegacyConfig$, mockLogger ); @@ -209,7 +209,7 @@ describe('ES search strategy', () => { const params = { index: 'logstash-*', body: { query: {} } }; const esSearch = await enhancedEsSearchStrategyProvider( - mockConfig$, + mockConfig, mockLegacyConfig$, mockLogger ); @@ -237,7 +237,7 @@ describe('ES search strategy', () => { const params = { index: 'logstash-*', body: { query: {} } }; const esSearch = await enhancedEsSearchStrategyProvider( - mockConfig$, + mockConfig, mockLegacyConfig$, mockLogger ); @@ -262,7 +262,7 @@ describe('ES search strategy', () => { const params = { index: 'logstash-*', body: { query: {} } }; const esSearch = await enhancedEsSearchStrategyProvider( - mockConfig$, + mockConfig, mockLegacyConfig$, mockLogger ); @@ -287,7 +287,7 @@ describe('ES search strategy', () => { const id = 'some_id'; const esSearch = await enhancedEsSearchStrategyProvider( - mockConfig$, + mockConfig, mockLegacyConfig$, mockLogger ); @@ -311,7 +311,7 @@ describe('ES search strategy', () => { const id = 'some_id'; const esSearch = await enhancedEsSearchStrategyProvider( - mockConfig$, + mockConfig, mockLegacyConfig$, mockLogger ); @@ -338,7 +338,7 @@ describe('ES search strategy', () => { const id = 'some_other_id'; const keepAlive = '1d'; const esSearch = await enhancedEsSearchStrategyProvider( - mockConfig$, + mockConfig, mockLegacyConfig$, mockLogger ); @@ -357,7 +357,7 @@ describe('ES search strategy', () => { const id = 'some_other_id'; const keepAlive = '1d'; const esSearch = await enhancedEsSearchStrategyProvider( - mockConfig$, + mockConfig, mockLegacyConfig$, mockLogger ); diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts index 402058a776605f..fc1cc63146358e 100644 --- a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts +++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts @@ -39,7 +39,7 @@ import { ConfigSchema } from '../../config'; import { getKbnServerError, KbnServerError } from '../../../../../src/plugins/kibana_utils/server'; export const enhancedEsSearchStrategyProvider = ( - config$: Observable, + config: ConfigSchema, legacyConfig$: Observable, logger: Logger, usage?: SearchUsage @@ -60,7 +60,6 @@ export const enhancedEsSearchStrategyProvider = ( const client = esClient.asCurrentUser.asyncSearch; const search = async () => { - const config = await config$.pipe(first()).toPromise(); const params = id ? getDefaultAsyncGetParams(options) : { diff --git a/x-pack/plugins/data_enhanced/server/search/session/monitoring_task.ts b/x-pack/plugins/data_enhanced/server/search/session/monitoring_task.ts index 75b6089cddf9b0..8aa35def387b73 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/monitoring_task.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/monitoring_task.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { Observable } from 'rxjs'; -import { first } from 'rxjs/operators'; import { Duration } from 'moment'; import { TaskManagerSetupContract, @@ -24,14 +22,13 @@ export const SEARCH_SESSIONS_TASK_ID = `data_enhanced_${SEARCH_SESSIONS_TASK_TYP interface SearchSessionTaskDeps { taskManager: TaskManagerSetupContract; logger: Logger; - config$: Observable; + config: ConfigSchema; } -function searchSessionRunner(core: CoreSetup, { logger, config$ }: SearchSessionTaskDeps) { +function searchSessionRunner(core: CoreSetup, { logger, config }: SearchSessionTaskDeps) { return ({ taskInstance }: RunContext) => { return { async run() { - const config = await config$.pipe(first()).toPromise(); const sessionConfig = config.search.sessions; const [coreStart] = await core.getStartServices(); const internalRepo = coreStart.savedObjects.createInternalRepository([SEARCH_SESSION_TYPE]); diff --git a/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts b/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts index 19679f02df0ad8..24d13cf24ccfb5 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { BehaviorSubject } from 'rxjs'; import { SavedObject, SavedObjectsClientContract, @@ -46,7 +45,7 @@ describe('SearchSessionService', () => { beforeEach(async () => { savedObjectsClient = savedObjectsClientMock.create(); - const config$ = new BehaviorSubject({ + const config: ConfigSchema = { search: { sessions: { enabled: true, @@ -59,13 +58,13 @@ describe('SearchSessionService', () => { management: {} as any, }, }, - }); + }; const mockLogger: any = { debug: jest.fn(), warn: jest.fn(), error: jest.fn(), }; - service = new SearchSessionService(mockLogger, config$); + service = new SearchSessionService(mockLogger, config); const coreStart = coreMock.createStart(); const mockTaskManager = taskManagerMock.createStart(); await flushPromises(); diff --git a/x-pack/plugins/data_enhanced/server/search/session/session_service.ts b/x-pack/plugins/data_enhanced/server/search/session/session_service.ts index 9d8a730004e1b6..2d0e7e519e3bd4 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/session_service.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/session_service.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { Observable } from 'rxjs'; -import { first } from 'rxjs/operators'; import { CoreSetup, CoreStart, @@ -50,32 +48,33 @@ function sleep(ms: number) { } export class SearchSessionService implements ISearchSessionService { - private config!: SearchSessionsConfig; + private sessionConfig: SearchSessionsConfig; - constructor( - private readonly logger: Logger, - private readonly config$: Observable - ) {} + constructor(private readonly logger: Logger, private readonly config: ConfigSchema) { + this.sessionConfig = this.config.search.sessions; + } public setup(core: CoreSetup, deps: SetupDependencies) { registerSearchSessionsTask(core, { - config$: this.config$, + config: this.config, taskManager: deps.taskManager, logger: this.logger, }); } public async start(core: CoreStart, deps: StartDependencies) { - const configPromise = await this.config$.pipe(first()).toPromise(); - this.config = (await configPromise).search.sessions; return this.setupMonitoring(core, deps); } public stop() {} private setupMonitoring = async (core: CoreStart, deps: StartDependencies) => { - if (this.config.enabled) { - scheduleSearchSessionsTasks(deps.taskManager, this.logger, this.config.trackingInterval); + if (this.sessionConfig.enabled) { + scheduleSearchSessionsTasks( + deps.taskManager, + this.logger, + this.sessionConfig.trackingInterval + ); } }; @@ -107,7 +106,7 @@ export class SearchSessionService } catch (createError) { if ( SavedObjectsErrorHelpers.isConflictError(createError) && - retry < this.config.maxUpdateRetries + retry < this.sessionConfig.maxUpdateRetries ) { return await retryOnConflict(createError); } else { @@ -116,7 +115,7 @@ export class SearchSessionService } } else if ( SavedObjectsErrorHelpers.isConflictError(e) && - retry < this.config.maxUpdateRetries + retry < this.sessionConfig.maxUpdateRetries ) { return await retryOnConflict(e); } else { @@ -164,7 +163,7 @@ export class SearchSessionService sessionId, status: SearchSessionStatus.IN_PROGRESS, expires: new Date( - Date.now() + this.config.defaultExpiration.asMilliseconds() + Date.now() + this.sessionConfig.defaultExpiration.asMilliseconds() ).toISOString(), created: new Date().toISOString(), touched: new Date().toISOString(), @@ -226,6 +225,11 @@ export class SearchSessionService }); }; + // TODO: Throw an error if this session doesn't belong to this user + public delete = ({ savedObjectsClient }: SearchSessionDependencies, sessionId: string) => { + return savedObjectsClient.delete(SEARCH_SESSION_TYPE, sessionId); + }; + /** * Tracks the given search request/search ID in the saved session. * @internal @@ -308,6 +312,7 @@ export class SearchSessionService update: this.update.bind(this, deps), extend: this.extend.bind(this, deps), cancel: this.cancel.bind(this, deps), + delete: this.delete.bind(this, deps), }; }; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx index e21a01d2b97ec7..0266b64f97104f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx @@ -106,7 +106,7 @@ export const Credentials: React.FC = () => { showCredentialsForm()} > {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.createKey', { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_flyout/footer.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_flyout/footer.tsx index 68fae9d942e9d4..dc2d52a073b36d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_flyout/footer.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_flyout/footer.tsx @@ -35,7 +35,7 @@ export const CredentialsFlyoutFooter: React.FC = () => { { const { activeApiToken } = useValues(CredentialsLogic); return ( - +

{activeApiToken.id diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_flyout/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_flyout/index.tsx index 1e0c2d3eb822c4..1335a3cdeea18f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_flyout/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_flyout/index.tsx @@ -22,8 +22,8 @@ export const CredentialsFlyout: React.FC = () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.test.tsx index 3c3f02106fe121..dd3d8ef8069bac 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.test.tsx @@ -207,7 +207,7 @@ describe('Credentials', () => { isHidden: expect.any(Boolean), text: ( - ••••••• + ••••••• ), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_creation_button.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_creation_button.tsx index b26a244397cba2..a05005fefa082e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_creation_button.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_creation_button.tsx @@ -19,7 +19,7 @@ export const DocumentCreationButton: React.FC = () => { return ( <> { DocumentDetailLogic.actions.getDocumentDetails('1'); - expect(http.get).toHaveBeenCalledWith(`/api/app_search/engines/engine1/documents/1`); + expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/engine1/documents/1'); await nextTick(); expect(DocumentDetailLogic.actions.setFields).toHaveBeenCalledWith(fields); }); @@ -96,7 +96,7 @@ describe('DocumentDetailLogic', () => { mount(); DocumentDetailLogic.actions.deleteDocument('1'); - expect(http.delete).toHaveBeenCalledWith(`/api/app_search/engines/engine1/documents/1`); + expect(http.delete).toHaveBeenCalledWith('/api/app_search/engines/engine1/documents/1'); await nextTick(); expect(setQueuedSuccessMessage).toHaveBeenCalledWith( 'Successfully marked document for deletion. It will be deleted momentarily.' diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.tsx index 69fc32d05b823a..2d3604b2ba2792 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.tsx @@ -82,7 +82,7 @@ export const CustomizationModal: React.FC = ({ defaultMessage: 'Filter fields', } )} - fullWidth={true} + fullWidth helpText={i18n.translate( 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.filterFields', { @@ -93,7 +93,7 @@ export const CustomizationModal: React.FC = ({ > = ({ defaultMessage: 'Sort fields', } )} - fullWidth={true} + fullWidth helpText={i18n.translate( 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.sortFields', { @@ -117,7 +117,7 @@ export const CustomizationModal: React.FC = ({ > {
= ({ options={checkboxGroupOptions} idToSelectedMap={idToSelectedMap} onChange={onChange} - compressed={true} + compressed /> {showMore && ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/views/result_view.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/views/result_view.test.tsx index 0eb0861ee3b027..e06603894c2884 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/views/result_view.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/views/result_view.test.tsx @@ -34,7 +34,7 @@ describe('ResultView', () => { it('renders', () => { const wrapper = shallow( - + ); expect(wrapper.find(Result).props()).toEqual({ result, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/views/result_view.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/views/result_view.tsx index 441216f75a40c4..9dd3fcea5f754a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/views/result_view.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/views/result_view.tsx @@ -22,7 +22,7 @@ export const ResultView: React.FC = ({ result, schemaForTypeHighlights, i
  • 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 a828747788f777..b1b31c245eb99a 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 @@ -105,7 +105,7 @@ export const EngineNav: React.FC = () => { {canViewEngineAnalytics && ( {ANALYTICS_TITLE} @@ -114,7 +114,7 @@ export const EngineNav: React.FC = () => { {canViewEngineDocuments && ( {DOCUMENTS_TITLE} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx index 9d7b05e68baf48..2d39b5a9aa05cb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx @@ -198,7 +198,7 @@ export const Library: React.FC = () => {

    With a link

    - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/index.ts index 909d10aae68235..07e53d0d29282f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/index.ts @@ -7,3 +7,4 @@ export { RELEVANCE_TUNING_TITLE } from './constants'; export { RelevanceTuning } from './relevance_tuning'; +export { RelevanceTuningLogic } from './relevance_tuning_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts new file mode 100644 index 00000000000000..586a845ce382af --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts @@ -0,0 +1,297 @@ +/* + * 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 } from '../../../__mocks__'; + +import { BoostType } from './types'; + +import { RelevanceTuningLogic } from './relevance_tuning_logic'; + +describe('RelevanceTuningLogic', () => { + const { mount } = new LogicMounter(RelevanceTuningLogic); + + const searchSettings = { + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + }, + ], + }, + search_fields: {}, + }; + const schema = {}; + const schemaConflicts = {}; + const relevanceTuningProps = { + searchSettings, + schema, + schemaConflicts, + }; + const searchResults = [{}, {}]; + + const DEFAULT_VALUES = { + dataLoading: true, + schema: {}, + schemaConflicts: {}, + searchSettings: {}, + unsavedChanges: false, + filterInputValue: '', + query: '', + resultsLoading: false, + searchResults: null, + showSchemaConflictCallout: true, + engineHasSchemaFields: false, + schemaFields: [], + schemaFieldsWithConflicts: [], + filteredSchemaFields: [], + filteredSchemaFieldsWithConflicts: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values', () => { + mount(); + expect(RelevanceTuningLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('onInitializeRelevanceTuning', () => { + it('should set searchSettings, schema, & schemaConflicts from the API response, and set dataLoading to false', () => { + mount({ + dataLoading: true, + }); + RelevanceTuningLogic.actions.onInitializeRelevanceTuning(relevanceTuningProps); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + searchSettings, + schema, + dataLoading: false, + schemaConflicts, + }); + }); + }); + + describe('setSearchSettings', () => { + it('should set setSearchSettings and set unsavedChanges to true', () => { + mount({ + unsavedChanges: false, + }); + RelevanceTuningLogic.actions.setSearchSettings(searchSettings); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + searchSettings, + unsavedChanges: true, + }); + }); + }); + + describe('setFilterValue', () => { + it('should set filterInputValue', () => { + mount(); + RelevanceTuningLogic.actions.setFilterValue('foo'); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + filterInputValue: 'foo', + }); + }); + }); + + describe('setSearchQuery', () => { + it('should set query', () => { + mount(); + RelevanceTuningLogic.actions.setSearchQuery('foo'); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + query: 'foo', + }); + }); + }); + + describe('setSearchResults', () => { + it('should set searchResults and set resultLoading to false', () => { + mount({ + resultsLoading: true, + }); + RelevanceTuningLogic.actions.setSearchResults(searchResults); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + searchResults, + resultsLoading: false, + }); + }); + }); + + describe('setResultsLoading', () => { + it('should set resultsLoading', () => { + mount({ + resultsLoading: false, + }); + RelevanceTuningLogic.actions.setResultsLoading(true); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + resultsLoading: true, + }); + }); + }); + + describe('clearSearchResults', () => { + it('should set searchResults', () => { + mount({ + searchResults: [{}], + }); + RelevanceTuningLogic.actions.clearSearchResults(); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + searchResults: null, + }); + }); + }); + + describe('resetSearchSettingsState', () => { + it('should set dataLoading', () => { + mount({ + dataLoading: false, + }); + RelevanceTuningLogic.actions.resetSearchSettingsState(); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + dataLoading: true, + }); + }); + }); + + describe('dismissSchemaConflictCallout', () => { + it('should set showSchemaConflictCallout to false', () => { + mount({ + showSchemaConflictCallout: true, + }); + RelevanceTuningLogic.actions.dismissSchemaConflictCallout(); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + showSchemaConflictCallout: false, + }); + }); + }); + }); + + describe('selectors', () => { + describe('engineHasSchemaFields', () => { + it('should return false if there is only a single field in a schema', () => { + // This is because if a schema only has a single field, it is "id", which we do not + // consider a tunable field. + mount({ + schema: { + id: 'foo', + }, + }); + expect(RelevanceTuningLogic.values.engineHasSchemaFields).toEqual(false); + }); + + it('should return true otherwise', () => { + mount({ + schema: { + id: 'foo', + bar: 'bar', + }, + }); + expect(RelevanceTuningLogic.values.engineHasSchemaFields).toEqual(true); + }); + }); + + describe('schemaFields', () => { + it('should return the list of field names from the schema', () => { + mount({ + schema: { + id: 'foo', + bar: 'bar', + }, + }); + expect(RelevanceTuningLogic.values.schemaFields).toEqual(['id', 'bar']); + }); + }); + + describe('schemaFieldsWithConflicts', () => { + it('should return the list of field names that have schema conflicts', () => { + mount({ + schemaConflicts: { + foo: { + text: ['source_engine_1'], + number: ['source_engine_2'], + }, + }, + }); + expect(RelevanceTuningLogic.values.schemaFieldsWithConflicts).toEqual(['foo']); + }); + }); + + describe('filteredSchemaFields', () => { + it('should return a list of schema field names that contain the text from filterInputValue ', () => { + mount({ + filterInputValue: 'ba', + schema: { + id: 'string', + foo: 'string', + bar: 'string', + baz: 'string', + }, + }); + expect(RelevanceTuningLogic.values.filteredSchemaFields).toEqual(['bar', 'baz']); + }); + + it('should return all schema fields if there is no filter applied', () => { + mount({ + filterTerm: '', + schema: { + id: 'string', + foo: 'string', + bar: 'string', + baz: 'string', + }, + }); + expect(RelevanceTuningLogic.values.filteredSchemaFields).toEqual([ + 'id', + 'foo', + 'bar', + 'baz', + ]); + }); + }); + + describe('filteredSchemaFieldsWithConflicts', () => { + it('should return a list of schema field names that contain the text from filterInputValue, and if that field has a schema conflict', () => { + mount({ + filterInputValue: 'ba', + schema: { + id: 'string', + foo: 'string', + bar: 'string', + baz: 'string', + }, + schemaConflicts: { + bar: { + text: ['source_engine_1'], + number: ['source_engine_2'], + }, + }, + }); + expect(RelevanceTuningLogic.values.filteredSchemaFieldsWithConflicts).toEqual(['bar']); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.ts new file mode 100644 index 00000000000000..d4ec5e37f6ce5e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { Schema, SchemaConflicts } from '../../../shared/types'; + +import { SearchSettings } from './types'; + +interface RelevanceTuningProps { + searchSettings: SearchSettings; + schema: Schema; + schemaConflicts: SchemaConflicts; +} + +interface RelevanceTuningActions { + onInitializeRelevanceTuning(props: RelevanceTuningProps): RelevanceTuningProps; + setSearchSettings(searchSettings: SearchSettings): { searchSettings: SearchSettings }; + setFilterValue(value: string): string; + setSearchQuery(value: string): string; + setSearchResults(searchResults: object[]): object[]; + setResultsLoading(resultsLoading: boolean): boolean; + clearSearchResults(): void; + resetSearchSettingsState(): void; + dismissSchemaConflictCallout(): void; +} + +interface RelevanceTuningValues { + searchSettings: Partial; + schema: Schema; + schemaFields: string[]; + schemaFieldsWithConflicts: string[]; + filteredSchemaFields: string[]; + filteredSchemaFieldsWithConflicts: string[]; + schemaConflicts: SchemaConflicts; + showSchemaConflictCallout: boolean; + engineHasSchemaFields: boolean; + filterInputValue: string; + query: string; + unsavedChanges: boolean; + dataLoading: boolean; + searchResults: object[] | null; + resultsLoading: boolean; +} + +// If the user hasn't entered a filter, then we can skip filtering the array entirely +const filterIfTerm = (array: string[], filterTerm: string): string[] => { + return filterTerm === '' ? array : array.filter((item) => item.includes(filterTerm)); +}; + +export const RelevanceTuningLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'app_search', 'relevance_tuning_logic'], + actions: () => ({ + onInitializeRelevanceTuning: (props) => props, + setSearchSettings: (searchSettings) => ({ searchSettings }), + setFilterValue: (value) => value, + setSearchQuery: (query) => query, + setSearchResults: (searchResults) => searchResults, + setResultsLoading: (resultsLoading) => resultsLoading, + clearSearchResults: true, + resetSearchSettingsState: true, + dismissSchemaConflictCallout: true, + }), + reducers: () => ({ + searchSettings: [ + {}, + { + onInitializeRelevanceTuning: (_, { searchSettings }) => searchSettings, + setSearchSettings: (_, { searchSettings }) => searchSettings, + }, + ], + schema: [ + {}, + { + onInitializeRelevanceTuning: (_, { schema }) => schema, + }, + ], + schemaConflicts: [ + {}, + { + onInitializeRelevanceTuning: (_, { schemaConflicts }) => schemaConflicts, + }, + ], + showSchemaConflictCallout: [ + true, + { + dismissSchemaConflictCallout: () => false, + }, + ], + filterInputValue: [ + '', + { + setFilterValue: (_, filterInputValue) => filterInputValue, + }, + ], + query: [ + '', + { + setSearchQuery: (_, query) => query, + }, + ], + unsavedChanges: [ + false, + { + setSearchSettings: () => true, + }, + ], + + dataLoading: [ + true, + { + onInitializeRelevanceTuning: () => false, + resetSearchSettingsState: () => true, + }, + ], + searchResults: [ + null, + { + clearSearchResults: () => null, + setSearchResults: (_, searchResults) => searchResults, + }, + ], + resultsLoading: [ + false, + { + setResultsLoading: (_, resultsLoading) => resultsLoading, + setSearchResults: () => false, + }, + ], + }), + selectors: ({ selectors }) => ({ + schemaFields: [() => [selectors.schema], (schema: Schema) => Object.keys(schema)], + schemaFieldsWithConflicts: [ + () => [selectors.schemaConflicts], + (schemaConflicts: SchemaConflicts) => Object.keys(schemaConflicts), + ], + filteredSchemaFields: [ + () => [selectors.schemaFields, selectors.filterInputValue], + (schemaFields: string[], filterInputValue: string): string[] => + filterIfTerm(schemaFields, filterInputValue), + ], + filteredSchemaFieldsWithConflicts: [ + () => [selectors.schemaFieldsWithConflicts, selectors.filterInputValue], + (schemaFieldsWithConflicts: string[], filterInputValue: string): string[] => + filterIfTerm(schemaFieldsWithConflicts, filterInputValue), + ], + engineHasSchemaFields: [ + () => [selectors.schema], + (schema: Schema): boolean => Object.keys(schema).length >= 2, + ], + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts new file mode 100644 index 00000000000000..25187df89d64c5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type BoostType = 'value' | 'functional' | 'proximity'; + +export interface Boost { + type: BoostType; + operation?: string; + function?: string; + newBoost?: boolean; + center?: string | number; + value?: string | number | string[] | number[]; + factor: number; +} + +export interface SearchSettings { + boosts: Record; + search_fields: object; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx index cbec65ec9f884e..0c3749d1ccb3db 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx @@ -62,7 +62,7 @@ describe('Result', () => { }); it('passes showScore, resultMeta, and isMetaEngine to ResultHeader', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(ResultHeader).props()).toEqual({ isMetaEngine: true, showScore: true, @@ -76,7 +76,7 @@ describe('Result', () => { describe('document detail link', () => { it('will render a link if shouldLinkToDetailPage is true', () => { - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(ReactRouterHelper).forEach((link) => { expect(link.prop('to')).toEqual('/engines/my-engine/documents/1'); }); @@ -96,7 +96,7 @@ describe('Result', () => { it('will render field details with type highlights if schemaForTypeHighlights has been provided', () => { const wrapper = shallow( - + ); expect(wrapper.find(ResultField).map((rf) => rf.prop('type'))).toEqual([ 'text', diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx index 1e7be7027f7b31..9d90b3ae35a8f7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx @@ -34,7 +34,7 @@ describe('ResultHeader', () => { describe('score', () => { it('renders score if showScore is true ', () => { const wrapper = shallow( - + ); expect(wrapper.find('[data-test-subj="ResultScore"]').prop('value')).toEqual(100); }); @@ -51,12 +51,12 @@ describe('ResultHeader', () => { it('renders engine name if this is a meta engine', () => { const wrapper = shallow( ); expect(wrapper.find('[data-test-subj="ResultEngine"]').prop('value')).toBe('my-engine'); @@ -65,7 +65,7 @@ describe('ResultHeader', () => { it('does not render an engine if this is not a meta engine', () => { const wrapper = shallow( { const initializeAppData = jest.fn(); setMockActions({ initializeAppData }); - shallow(); + shallow(); expect(initializeAppData).toHaveBeenCalledWith({ ilmEnabled: true }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.tsx index d4e879ebc11ce3..d31daeef54de98 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.tsx @@ -53,7 +53,7 @@ export const ProductCard: React.FC = ({ product, image }) => { className="productCard" titleElement="h2" title={i18n.translate('xpack.enterpriseSearch.overview.productCard.heading', { - defaultMessage: `Elastic {productName}`, + defaultMessage: 'Elastic {productName}', values: { productName: product.NAME }, })} image={ @@ -67,7 +67,7 @@ export const ProductCard: React.FC = ({ product, image }) => { sendEnterpriseSearchTelemetry({ action: 'clicked', diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/hidden_text/hidden_text.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/hidden_text/hidden_text.tsx index 1886afb468404d..5503baf0bdae43 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/hidden_text/hidden_text.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/hidden_text/hidden_text.tsx @@ -27,7 +27,7 @@ export const HiddenText: React.FC = ({ text, children }) => { }); const hiddenText = isHidden ? ( - {text.replace(/./g, '•')} + {text.replace(/./g, '•')} ) : ( text diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/indexing_status/indexing_status.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/indexing_status/indexing_status.tsx index 6bcdc9623cb915..3898eda126415d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/indexing_status/indexing_status.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/indexing_status/indexing_status.tsx @@ -41,7 +41,7 @@ export const IndexingStatus: React.FC = ({ return ( <> {percentageComplete < 100 && ( - + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx index 3f6d4e781cda12..c67518e977de2a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx @@ -57,7 +57,7 @@ describe('Layout', () => { }); it('renders a read-only mode callout', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiCallOut)).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.test.tsx index f9269e425f84ab..4de43ce997b48d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.test.tsx @@ -53,7 +53,7 @@ describe('EUI & React Router Component Helpers', () => { }); it('passes down all ...rest props', () => { - const wrapper = shallow(); + const wrapper = shallow(); const link = wrapper.find(EuiLink); expect(link.prop('external')).toEqual(true); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/schema/schema_add_field_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/schema/schema_add_field_modal.tsx index 1ef665a52c782d..bbde6c5d3b55de 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/schema/schema_add_field_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/schema/schema_add_field_modal.tsx @@ -94,7 +94,7 @@ export const SchemaAddFieldModal: React.FC = ({ = ({ placeholder="name" type="text" onChange={handleChange} - required={true} + required value={rawFieldName} - fullWidth={true} - autoFocus={true} + fullWidth + autoFocus isLoading={loading} data-test-subj="SchemaAddFieldNameField" /> @@ -132,7 +132,7 @@ export const SchemaAddFieldModal: React.FC = ({ {FIELD_NAME_MODAL_CANCEL} = ({ {!isVisible ? ( - + ) : ( )} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx index 2b09babbb03fca..73ee7662888bb9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -63,7 +63,7 @@ describe('WorkplaceSearchConfigured', () => { }); it('initializes app data with passed props', () => { - shallow(); + shallow(); expect(initializeAppData).toHaveBeenCalledWith({ isFederatedAuth: true }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_header.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_header.tsx index 39c432eb274914..f12c24feb8e1a9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_header.tsx @@ -36,7 +36,7 @@ export const AddSourceHeader: React.FC = ({ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx index 0dd3850b86de89..3a0db0f44047d1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx @@ -109,7 +109,7 @@ export const AddSourceList: React.FC = () => { data-test-subj="FilterSourcesInput" value={filterValue} onChange={handleFilterChange} - fullWidth={true} + fullWidth placeholder={ADD_SOURCE_PLACEHOLDER} /> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx index bc697a39984c0c..62beb4e40793b8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx @@ -118,7 +118,7 @@ export const DisplaySettings: React.FC = ({ tabId }) => { description={DISPLAY_SETTINGS_DESCRIPTION} action={ hasDocuments ? ( - + {SAVE_BUTTON} ) : null diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx index 6171bddbd1527b..9a6af035c1c8d8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx @@ -73,9 +73,9 @@ export const FieldEditorModal: React.FC = () => { { setLabel(e.target.value)} @@ -95,7 +95,7 @@ export const FieldEditorModal: React.FC = () => { {CANCEL_BUTTON} - + {ACTION_LABEL} {FIELD_LABEL} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx index 3930768628aba9..8382ddc9e82b36 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx @@ -79,7 +79,7 @@ export const ResultDetail: React.FC = () => { <> {detailFields.map(({ fieldName, label }, index) => ( @@ -87,7 +87,7 @@ export const ResultDetail: React.FC = () => { key={`${fieldName}-${index}`} index={index} draggableId={`${fieldName}-${index}`} - customDragHandle={true} + customDragHandle spacing="m" > {(provided) => ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx index f7491ae8778c37..b2ba2b13e5ec3c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx @@ -76,10 +76,10 @@ export const SearchResults: React.FC = () => { > setTitleField(e.target.value)} @@ -88,9 +88,9 @@ export const SearchResults: React.FC = () => { setUrlField(e.target.value)} @@ -110,7 +110,7 @@ export const SearchResults: React.FC = () => { @@ -129,7 +129,7 @@ export const SearchResults: React.FC = () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx index 936dceba89e56a..fe48e1c14ff41a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx @@ -122,7 +122,7 @@ export const Schema: React.FC = () => { {addFieldButton} {percentageComplete < 100 ? ( - + {SCHEMA_UPDATING} ) : ( @@ -130,7 +130,7 @@ export const Schema: React.FC = () => { disabled={formUnchanged} data-test-subj="UpdateTypesButton" onClick={updateFields} - fill={true} + fill > {SCHEMA_SAVE_BUTTON} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_fields_table.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_fields_table.tsx index d93bafe6b972e6..a683d9384f6362 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_fields_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_fields_table.tsx @@ -57,7 +57,7 @@ export const SchemaFieldsTable: React.FC = () => { disabled={fieldName === 'id'} key={fieldName} fieldName={fieldName} - hideName={true} + hideName fieldType={filteredSchemaFields[fieldName]} updateExistingFieldType={updateExistingFieldType} /> 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 dbde764a568614..2fa00c7f029f1a 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 @@ -48,30 +48,31 @@ import { ViewContentHeader } from '../../../components/shared/view_content_heade import { SourceDataItem } from '../../../types'; import { AppLogic } from '../../../app_logic'; +import { AddSourceLogic } from '../components/add_source/add_source_logic'; import { staticSourceData } from '../source_data'; import { SourceLogic } from '../source_logic'; export const SourceSettings: React.FC = () => { - const { - updateContentSource, - removeContentSource, - resetSourceState, - getSourceConfigData, - } = useActions(SourceLogic); + const { updateContentSource, removeContentSource, resetSourceState } = useActions(SourceLogic); + const { getSourceConfigData } = useActions(AddSourceLogic); const { contentSource: { name, id, serviceType }, buttonLoading, - sourceConfigData: { configuredFields }, } = useValues(SourceLogic); + const { + sourceConfigData: { configuredFields }, + } = useValues(AddSourceLogic); + const { isOrganization } = useValues(AppLogic); useEffect(() => { getSourceConfigData(serviceType); return resetSourceState; }, []); + const { configuration: { isPublicKey }, editPath, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx index 6dcc4379515a38..d68b451ffa6f57 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx @@ -109,7 +109,7 @@ export const PrivateSources: React.FC = () => { const privateSourcesTable = ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.test.ts index bf5ec5a949b8d2..15df7ddc993954 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.test.ts @@ -18,11 +18,7 @@ jest.mock('../../app_logic', () => ({ AppLogic: { values: { isOrganization: true } }, })); -import { - fullContentSources, - sourceConfigData, - contentItems, -} from '../../__mocks__/content_sources.mock'; +import { fullContentSources, contentItems } from '../../__mocks__/content_sources.mock'; import { meta } from '../../__mocks__/meta.mock'; import { DEFAULT_META } from '../../../shared/constants'; @@ -46,7 +42,6 @@ describe('SourceLogic', () => { const defaultValues = { contentSource: {}, contentItems: [], - sourceConfigData: {}, dataLoading: true, sectionLoading: true, buttonLoading: false, @@ -88,13 +83,6 @@ describe('SourceLogic', () => { expect(setSuccessMessage).toHaveBeenCalled(); }); - it('setSourceConfigData', () => { - SourceLogic.actions.setSourceConfigData(sourceConfigData); - - expect(SourceLogic.values.sourceConfigData).toEqual(sourceConfigData); - expect(SourceLogic.values.dataLoading).toEqual(false); - }); - it('setSearchResults', () => { SourceLogic.actions.setSearchResults(searchServerResponse); @@ -402,40 +390,6 @@ describe('SourceLogic', () => { }); }); - describe('getSourceConfigData', () => { - const serviceType = 'github'; - - it('calls API and sets values', async () => { - AppLogic.values.isOrganization = true; - - const setSourceConfigDataSpy = jest.spyOn(SourceLogic.actions, 'setSourceConfigData'); - const promise = Promise.resolve(contentSource); - http.get.mockReturnValue(promise); - SourceLogic.actions.getSourceConfigData(serviceType); - - expect(http.get).toHaveBeenCalledWith( - `/api/workplace_search/org/settings/connectors/${serviceType}` - ); - await promise; - expect(setSourceConfigDataSpy).toHaveBeenCalled(); - }); - - it('handles error', async () => { - const error = { - response: { - error: 'this is an error', - status: 400, - }, - }; - const promise = Promise.reject(error); - http.get.mockReturnValue(promise); - SourceLogic.actions.getSourceConfigData(serviceType); - await expectedAsyncError(promise); - - expect(flashAPIErrors).toHaveBeenCalledWith(error); - }); - }); - it('resetSourceState', () => { SourceLogic.actions.resetSourceState(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts index 1eef715350848e..c1f5d6033543fc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts @@ -27,7 +27,6 @@ import { ContentSourceFullData, Meta, DocumentSummaryItem, SourceContentItem } f export interface SourceActions { onInitializeSource(contentSource: ContentSourceFullData): ContentSourceFullData; onUpdateSourceName(name: string): string; - setSourceConfigData(sourceConfigData: SourceConfigData): SourceConfigData; setSearchResults(searchResultsResponse: SearchResultsResponse): SearchResultsResponse; initializeFederatedSummary(sourceId: string): { sourceId: string }; onUpdateSummary(summary: DocumentSummaryItem[]): DocumentSummaryItem[]; @@ -41,28 +40,9 @@ export interface SourceActions { resetSourceState(): void; removeContentSource(sourceId: string): { sourceId: string }; initializeSource(sourceId: string): { sourceId: string }; - getSourceConfigData(serviceType: string): { serviceType: string }; setButtonNotLoading(): void; } -interface SourceConfigData { - serviceType: string; - name: string; - configured: boolean; - categories: string[]; - needsPermissions?: boolean; - privateSourcesEnabled: boolean; - configuredFields: { - publicKey: string; - privateKey: string; - consumerKey: string; - baseUrl?: string; - clientId?: string; - clientSecret?: string; - }; - accountContextOnly?: boolean; -} - interface SourceValues { contentSource: ContentSourceFullData; dataLoading: boolean; @@ -71,7 +51,6 @@ interface SourceValues { contentItems: SourceContentItem[]; contentMeta: Meta; contentFilterValue: string; - sourceConfigData: SourceConfigData; } interface SearchResultsResponse { @@ -84,7 +63,6 @@ export const SourceLogic = kea>({ actions: { onInitializeSource: (contentSource: ContentSourceFullData) => contentSource, onUpdateSourceName: (name: string) => name, - setSourceConfigData: (sourceConfigData: SourceConfigData) => sourceConfigData, onUpdateSummary: (summary: object[]) => summary, setSearchResults: (searchResultsResponse: SearchResultsResponse) => searchResultsResponse, setContentFilterValue: (contentFilterValue: string) => contentFilterValue, @@ -96,7 +74,6 @@ export const SourceLogic = kea>({ removeContentSource: (sourceId: string) => ({ sourceId, }), - getSourceConfigData: (serviceType: string) => ({ serviceType }), resetSourceState: () => true, setButtonNotLoading: () => false, }, @@ -115,17 +92,10 @@ export const SourceLogic = kea>({ }), }, ], - sourceConfigData: [ - {} as SourceConfigData, - { - setSourceConfigData: (_, sourceConfigData) => sourceConfigData, - }, - ], dataLoading: [ true, { onInitializeSource: () => false, - setSourceConfigData: () => false, resetSourceState: () => false, }, ], @@ -133,7 +103,6 @@ export const SourceLogic = kea>({ false, { setButtonNotLoading: () => false, - setSourceConfigData: () => false, resetSourceState: () => false, removeContentSource: () => true, }, @@ -181,7 +150,6 @@ export const SourceLogic = kea>({ actions.initializeFederatedSummary(sourceId); } } catch (e) { - // TODO: Verify this works once components are there. Not sure if the catch gives a status code. if (e.response.status === 404) { KibanaLogic.values.navigateToUrl(NOT_FOUND_PATH); } else { @@ -260,16 +228,6 @@ export const SourceLogic = kea>({ actions.setButtonNotLoading(); } }, - getSourceConfigData: async ({ serviceType }) => { - const route = `/api/workplace_search/org/settings/connectors/${serviceType}`; - - try { - const response = await HttpLogic.values.http.get(route); - actions.setSourceConfigData(response); - } catch (e) { - flashAPIErrors(e); - } - }, onUpdateSourceName: (name: string) => { setSuccessMessage( i18n.translate( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx index b19003e431ee51..f49c978d06e907 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx @@ -73,7 +73,7 @@ export const AddGroupModal: React.FC<{}> = () => { {ADD_GROUP_SUBMIT} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_popover.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_popover.tsx index 6cba9fcb509ea8..b47232197c47fa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_popover.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_popover.tsx @@ -41,7 +41,7 @@ export const FilterableUsersPopover: React.FC = ({ return ( = ({ addFilteredUser={addFilteredUser} allGroupUsersLoading={allGroupUsersLoading} removeFilteredUser={removeFilteredUser} - isPopover={true} + isPopover /> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_source_prioritization.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_source_prioritization.tsx index 4fb9350d0b3628..6907618e40b465 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_source_prioritization.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_source_prioritization.tsx @@ -96,7 +96,7 @@ export const GroupSourcePrioritization: React.FC = () => { {HEADER_ACTION_TEXT} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/groups_table.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/groups_table.tsx index ff596e41f55386..31f549c3e20655 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/groups_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/groups_table.tsx @@ -91,7 +91,7 @@ export const GroupsTable: React.FC<{}> = () => { - {showPagination && } + {showPagination && } ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_users_dropdown.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_users_dropdown.tsx index 49dc3bfa671d95..9ddb955767c145 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_users_dropdown.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_users_dropdown.tsx @@ -44,7 +44,7 @@ export const TableFilterUsersDropdown: React.FC<{}> = () => { { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx index 144aaabba407d2..7a8b9343691f90 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx @@ -86,7 +86,7 @@ export const Groups: React.FC = () => { const headerAction = ( - + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.addGroupForm.action', { defaultMessage: 'Create a group', })} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx index 79f418a48dabc5..4b59e0f3401c56 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx @@ -15,7 +15,6 @@ import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; import { Loading } from '../../../../shared/loading'; import { SourceDataItem } from '../../../types'; import { staticSourceData } from '../../content_sources/source_data'; -import { SourceLogic } from '../../content_sources/source_logic'; import { AddSourceLogic } from '../../content_sources/components/add_source/add_source_logic'; import { AddSourceHeader } from '../../content_sources/components/add_source/add_source_header'; @@ -31,18 +30,18 @@ export const SourceConfig: React.FC = ({ sourceIndex }) => { const [confirmModalVisible, setConfirmModalVisibility] = useState(false); const { configuration, serviceType } = staticSourceData[sourceIndex] as SourceDataItem; const { deleteSourceConfig } = useActions(SettingsLogic); - const { getSourceConfigData } = useActions(SourceLogic); - const { saveSourceConfig } = useActions(AddSourceLogic); + const { saveSourceConfig, getSourceConfigData } = useActions(AddSourceLogic); const { sourceConfigData: { name, categories }, - dataLoading: sourceDataLoading, - } = useValues(SourceLogic); + dataLoading, + } = useValues(AddSourceLogic); useEffect(() => { getSourceConfigData(serviceType); }, []); - if (sourceDataLoading) return ; + if (dataLoading) return ; + const hideConfirmModal = () => setConfirmModalVisibility(false); const showConfirmModal = () => setConfirmModalVisibility(true); const saveUpdatedConfig = () => saveSourceConfig(true); diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts b/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts index 7fde7934cf7ad1..88cf30bb2a5492 100644 --- a/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts +++ b/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts @@ -23,7 +23,6 @@ type PayloadType = 'params' | 'query' | 'body'; interface IMockRouter { method: MethodType; path: string; - payload?: PayloadType; } interface IMockRouterRequest { body?: object; @@ -39,11 +38,10 @@ export class MockRouter { public payload?: PayloadType; public response = httpServerMock.createResponseFactory(); - constructor({ method, path, payload }: IMockRouter) { + constructor({ method, path }: IMockRouter) { this.createRouter(); this.method = method; this.path = path; - this.payload = payload; } public createRouter = () => { @@ -62,16 +60,17 @@ export class MockRouter { */ public validateRoute = (request: MockRouterRequest) => { - if (!this.payload) throw new Error('Cannot validate wihout a payload type specified.'); - const route = this.findRouteRegistration(); const [config] = route; const validate = config.validate as RouteValidatorConfig<{}, {}, {}>; + const payloads = Object.keys(request) as PayloadType[]; - const payloadValidation = validate[this.payload] as { validate(request: KibanaRequest): void }; - const payloadRequest = request[this.payload] as KibanaRequest; + payloads.forEach((payload: PayloadType) => { + const payloadValidation = validate[payload] as { validate(request: KibanaRequest): void }; + const payloadRequest = request[payload] as KibanaRequest; - payloadValidation.validate(payloadRequest); + payloadValidation.validate(payloadRequest); + }); }; public shouldValidate = (request: MockRouterRequest) => { @@ -99,7 +98,6 @@ export class MockRouter { // const mockRouter = new MockRouter({ // method: 'get', // path: '/api/app_search/test', -// payload: 'body' // }); // // beforeEach(() => { diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/analytics.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/analytics.test.ts index 3d63e4044e75bc..8e4a7dba165b10 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/analytics.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/analytics.test.ts @@ -18,7 +18,6 @@ describe('analytics routes', () => { mockRouter = new MockRouter({ method: 'get', path: '/api/app_search/engines/{engineName}/analytics/queries', - payload: 'query', }); registerAnalyticsRoutes({ @@ -71,7 +70,6 @@ describe('analytics routes', () => { mockRouter = new MockRouter({ method: 'get', path: '/api/app_search/engines/{engineName}/analytics/queries/{query}', - payload: 'query', }); registerAnalyticsRoutes({ diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts index 7a513b1c76b4e7..d9e84d3e62f281 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts @@ -18,7 +18,6 @@ describe('credentials routes', () => { mockRouter = new MockRouter({ method: 'get', path: '/api/app_search/credentials', - payload: 'query', }); registerCredentialsRoutes({ @@ -54,7 +53,6 @@ describe('credentials routes', () => { mockRouter = new MockRouter({ method: 'post', path: '/api/app_search/credentials', - payload: 'body', }); registerCredentialsRoutes({ @@ -167,7 +165,6 @@ describe('credentials routes', () => { mockRouter = new MockRouter({ method: 'get', path: '/api/app_search/credentials/details', - payload: 'query', }); registerCredentialsRoutes({ @@ -191,7 +188,6 @@ describe('credentials routes', () => { mockRouter = new MockRouter({ method: 'put', path: '/api/app_search/credentials/{name}', - payload: 'body', }); registerCredentialsRoutes({ diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/documents.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/documents.test.ts index fdae51444bb54e..af54d340ad150b 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/documents.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/documents.test.ts @@ -18,7 +18,6 @@ describe('documents routes', () => { mockRouter = new MockRouter({ method: 'post', path: '/api/app_search/engines/{engineName}/documents', - payload: 'body', }); registerDocumentsRoutes({ diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/documents.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/documents.ts index 3a408b62bd540c..78463fc8724ac7 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/documents.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/documents.ts @@ -26,7 +26,7 @@ export function registerDocumentsRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: `/as/engines/:engineName/documents/new`, + path: '/as/engines/:engineName/documents/new', }) ); } @@ -46,7 +46,7 @@ export function registerDocumentRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: `/as/engines/:engineName/documents/:documentId`, + path: '/as/engines/:engineName/documents/:documentId', }) ); router.delete( @@ -60,7 +60,7 @@ export function registerDocumentRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: `/as/engines/:engineName/documents/:documentId`, + path: '/as/engines/:engineName/documents/:documentId', }) ); } diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index e874a188a10f7d..abd26e18c7b9d8 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -29,7 +29,6 @@ describe('engine routes', () => { mockRouter = new MockRouter({ method: 'get', path: '/api/app_search/engines', - payload: 'query', }); registerEnginesRoutes({ diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index edf5d1f3855e3a..49ff0353bef03d 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -56,7 +56,7 @@ export function registerEnginesRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: `/as/engines/:name/details`, + path: '/as/engines/:name/details', }) ); router.get( @@ -69,7 +69,7 @@ export function registerEnginesRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: `/as/engines/:name/overview_metrics`, + path: '/as/engines/:name/overview_metrics', }) ); } diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.test.ts index 92a695af12aaab..d8f677e2f0d826 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.test.ts @@ -87,7 +87,6 @@ describe('search settings routes', () => { const mockRouter = new MockRouter({ method: 'put', path: '/api/app_search/engines/{engineName}/search_settings', - payload: 'body', }); beforeEach(() => { @@ -149,7 +148,6 @@ describe('search settings routes', () => { const mockRouter = new MockRouter({ method: 'post', path: '/api/app_search/engines/{engineName}/search_settings_search', - payload: 'body', }); beforeEach(() => { @@ -188,29 +186,18 @@ describe('search settings routes', () => { }); describe('validates query', () => { - const queryRouter = new MockRouter({ - method: 'post', - path: '/api/app_search/engines/{engineName}/search_settings_search', - payload: 'query', - }); - it('correctly', () => { - registerSearchSettingsRoutes({ - ...mockDependencies, - router: queryRouter.router, - }); - const request = { query: { query: 'foo', }, }; - queryRouter.shouldValidate(request); + mockRouter.shouldValidate(request); }); it('missing required fields', () => { const request = { query: {} }; - queryRouter.shouldThrow(request); + mockRouter.shouldThrow(request); }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.ts index c68c8e61d539b9..82b0497cd0946e 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.ts @@ -38,7 +38,7 @@ export function registerSearchSettingsRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: `/as/engines/:engineName/search_settings/details`, + path: '/as/engines/:engineName/search_settings/details', }) ); @@ -52,7 +52,7 @@ export function registerSearchSettingsRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: `/as/engines/:engineName/search_settings/reset`, + path: '/as/engines/:engineName/search_settings/reset', }) ); @@ -67,7 +67,7 @@ export function registerSearchSettingsRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: `/as/engines/:engineName/search_settings`, + path: '/as/engines/:engineName/search_settings', }) ); @@ -88,7 +88,7 @@ export function registerSearchSettingsRoutes({ }, }, enterpriseSearchRequestHandler.createRequest({ - path: `/as/engines/:engineName/search_settings_search`, + path: '/as/engines/:engineName/search_settings_search', }) ); } diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/settings.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/settings.test.ts index 5d56bbf4fcd11a..6df9a4f16d7106 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/settings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/settings.test.ts @@ -41,7 +41,6 @@ describe('log settings routes', () => { mockRouter = new MockRouter({ method: 'put', path: '/api/app_search/log_settings', - payload: 'body', }); registerSettingsRoutes({ diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts index f41ad367839c3e..08c398ba3eb0d9 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts @@ -29,7 +29,6 @@ describe('Enterprise Search Telemetry API', () => { mockRouter = new MockRouter({ method: 'put', path: '/api/enterprise_search/stats', - payload: 'body', }); registerTelemetryRoute({ diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.test.ts index e67ca4c064886f..68a9ae725f8a47 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.test.ts @@ -26,7 +26,6 @@ describe('groups routes', () => { mockRouter = new MockRouter({ method: 'get', path: '/api/workplace_search/groups', - payload: 'query', }); registerGroupsRoute({ @@ -50,7 +49,6 @@ describe('groups routes', () => { mockRouter = new MockRouter({ method: 'post', path: '/api/workplace_search/groups', - payload: 'body', }); registerGroupsRoute({ @@ -85,7 +83,6 @@ describe('groups routes', () => { mockRouter = new MockRouter({ method: 'post', path: '/api/workplace_search/groups/search', - payload: 'body', }); registerSearchGroupsRoute({ @@ -163,7 +160,6 @@ describe('groups routes', () => { mockRouter = new MockRouter({ method: 'put', path: '/api/workplace_search/groups/{id}', - payload: 'body', }); registerGroupRoute({ @@ -246,7 +242,6 @@ describe('groups routes', () => { mockRouter = new MockRouter({ method: 'post', path: '/api/workplace_search/groups/{id}/share', - payload: 'body', }); registerShareGroupRoute({ @@ -282,7 +277,6 @@ describe('groups routes', () => { mockRouter = new MockRouter({ method: 'post', path: '/api/workplace_search/groups/{id}/assign', - payload: 'body', }); registerAssignGroupRoute({ @@ -318,7 +312,6 @@ describe('groups routes', () => { mockRouter = new MockRouter({ method: 'put', path: '/api/workplace_search/groups/{id}/boosts', - payload: 'body', }); registerBoostsGroupRoute({ diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts index 1afb85b389b427..bdf885648dff7f 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts @@ -18,7 +18,6 @@ describe('Overview route', () => { mockRouter = new MockRouter({ method: 'get', path: '/api/workplace_search/overview', - payload: 'query', }); registerOverviewRoute({ diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts index f2117a8bc948a6..a1615499c56a29 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts @@ -45,7 +45,6 @@ describe('security routes', () => { mockRouter = new MockRouter({ method: 'get', path: '/api/workplace_search/org/security/source_restrictions', - payload: 'body', }); registerSecuritySourceRestrictionsRoute({ @@ -72,7 +71,6 @@ describe('security routes', () => { mockRouter = new MockRouter({ method: 'patch', path: '/api/workplace_search/org/security/source_restrictions', - payload: 'body', }); registerSecuritySourceRestrictionsRoute({ diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.test.ts index cf654918beb49b..00a5b6c75df0a5 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.test.ts @@ -45,7 +45,6 @@ describe('settings routes', () => { mockRouter = new MockRouter({ method: 'put', path: '/api/workplace_search/org/settings/customize', - payload: 'body', }); registerOrgSettingsCustomizeRoute({ @@ -76,7 +75,6 @@ describe('settings routes', () => { mockRouter = new MockRouter({ method: 'put', path: '/api/workplace_search/org/settings/oauth_application', - payload: 'body', }); registerOrgSettingsOauthApplicationRoute({ diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts index 2ae10e85ea9c0c..a2fbe759f1a115 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts @@ -154,7 +154,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'post', path: '/api/workplace_search/account/create_source', - payload: 'body', }); registerAccountCreateSourceRoute({ @@ -194,7 +193,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'post', path: '/api/workplace_search/account/sources/{id}/documents', - payload: 'body', }); registerAccountSourceDocumentsRoute({ @@ -281,7 +279,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'patch', path: '/api/workplace_search/account/sources/{id}/settings', - payload: 'body', }); registerAccountSourceSettingsRoute({ @@ -364,7 +361,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'put', path: '/api/workplace_search/account/sources/{id}/searchable', - payload: 'body', }); registerAccountSourceSearchableRoute({ @@ -422,7 +418,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'post', path: '/api/workplace_search/account/sources/{id}/display_settings/config', - payload: 'body', }); registerAccountSourceDisplaySettingsConfig({ @@ -489,7 +484,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'post', path: '/api/workplace_search/account/sources/{id}/schemas', - payload: 'body', }); registerAccountSourceSchemasRoute({ @@ -667,7 +661,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'post', path: '/api/workplace_search/org/create_source', - payload: 'body', }); registerOrgCreateSourceRoute({ @@ -707,7 +700,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'post', path: '/api/workplace_search/org/sources/{id}/documents', - payload: 'body', }); registerOrgSourceDocumentsRoute({ @@ -794,7 +786,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'patch', path: '/api/workplace_search/org/sources/{id}/settings', - payload: 'body', }); registerOrgSourceSettingsRoute({ @@ -877,7 +868,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'put', path: '/api/workplace_search/org/sources/{id}/searchable', - payload: 'body', }); registerOrgSourceSearchableRoute({ @@ -935,7 +925,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'post', path: '/api/workplace_search/org/sources/{id}/display_settings/config', - payload: 'body', }); registerOrgSourceDisplaySettingsConfig({ @@ -1002,7 +991,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'post', path: '/api/workplace_search/org/sources/{id}/schemas', - payload: 'body', }); registerOrgSourceSchemasRoute({ @@ -1102,7 +1090,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'post', path: '/api/workplace_search/org/settings/connectors', - payload: 'body', }); registerOrgSourceOauthConfigurationsRoute({ @@ -1133,7 +1120,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'put', path: '/api/workplace_search/org/settings/connectors', - payload: 'body', }); registerOrgSourceOauthConfigurationsRoute({ @@ -1187,7 +1173,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'post', path: '/api/workplace_search/org/settings/connectors/{serviceType}', - payload: 'body', }); registerOrgSourceOauthConfigurationRoute({ @@ -1218,7 +1203,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'put', path: '/api/workplace_search/org/settings/connectors/{serviceType}', - payload: 'body', }); registerOrgSourceOauthConfigurationRoute({ @@ -1272,7 +1256,6 @@ describe('sources routes', () => { mockRouter = new MockRouter({ method: 'get', path: '/api/workplace_search/sources/create', - payload: 'query', }); registerOauthConnectorParamsRoute({ diff --git a/x-pack/plugins/fleet/common/constants/agent_policy.ts b/x-pack/plugins/fleet/common/constants/agent_policy.ts index 363607aae2b467..96b6249585bfcd 100644 --- a/x-pack/plugins/fleet/common/constants/agent_policy.ts +++ b/x-pack/plugins/fleet/common/constants/agent_policy.ts @@ -24,6 +24,7 @@ export const DEFAULT_AGENT_POLICY: Omit< status: agentPolicyStatuses.Active, package_policies: [], is_default: true, + is_managed: false, monitoring_enabled: ['logs', 'metrics'] as Array<'logs' | 'metrics'>, }; diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 57c42d887bc83c..2e18d427272ce5 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -130,8 +130,8 @@ interface AgentBase { enrolled_at: string; unenrolled_at?: string; unenrollment_started_at?: string; - upgraded_at?: string; - upgrade_started_at?: string; + upgraded_at?: string | null; + upgrade_started_at?: string | null; access_api_key_id?: string; default_api_key?: string; default_api_key_id?: string; @@ -155,3 +155,163 @@ export interface AgentSOAttributes extends AgentBase { current_error_events?: string; packages?: string[]; } + +// Generated from FleetServer schema.json + +/** + * An Elastic Agent that has enrolled into Fleet + */ +export interface FleetServerAgent { + /** + * The version of the document in the index + */ + _version?: number; + /** + * Shared ID + */ + shared_id?: string; + /** + * Type + */ + type: AgentType; + /** + * Active flag + */ + active: boolean; + /** + * Date/time the Elastic Agent enrolled + */ + enrolled_at: string; + /** + * Date/time the Elastic Agent unenrolled + */ + unenrolled_at?: string; + /** + * Date/time the Elastic Agent unenrolled started + */ + unenrollment_started_at?: string; + /** + * Date/time the Elastic Agent was last upgraded + */ + upgraded_at?: string | null; + /** + * Date/time the Elastic Agent started the current upgrade + */ + upgrade_started_at?: string | null; + /** + * ID of the API key the Elastic Agent must used to contact Fleet Server + */ + access_api_key_id?: string; + agent?: FleetServerAgentMetadata; + /** + * User provided metadata information for the Elastic Agent + */ + user_provided_metadata: AgentMetadata; + /** + * Local metadata information for the Elastic Agent + */ + local_metadata: AgentMetadata; + /** + * The policy ID for the Elastic Agent + */ + policy_id?: string; + /** + * The current policy revision_idx for the Elastic Agent + */ + policy_revision_idx?: number | null; + /** + * The current policy coordinator for the Elastic Agent + */ + policy_coordinator_idx?: number; + /** + * Date/time the Elastic Agent was last updated + */ + last_updated?: string; + /** + * Date/time the Elastic Agent checked in last time + */ + last_checkin?: string; + /** + * Lst checkin status + */ + last_checkin_status?: 'error' | 'online' | 'degraded' | 'updating'; + /** + * ID of the API key the Elastic Agent uses to authenticate with elasticsearch + */ + default_api_key_id?: string; + /** + * API key the Elastic Agent uses to authenticate with elasticsearch + */ + default_api_key?: string; + /** + * Date/time the Elastic Agent was last updated + */ + updated_at?: string; + /** + * Packages array + */ + packages?: string[]; + /** + * The last acknowledged action sequence number for the Elastic Agent + */ + action_seq_no?: number; +} +/** + * An Elastic Agent metadata + */ +export interface FleetServerAgentMetadata { + /** + * The unique identifier for the Elastic Agent + */ + id: string; + /** + * The version of the Elastic Agent + */ + version: string; + [k: string]: any; +} + +/** + * An Elastic Agent action + */ +export interface FleetServerAgentAction { + /** + * The unique identifier for action document + */ + _id?: string; + /** + * The action sequence number + */ + _seq_no?: number; + /** + * The unique identifier for the Elastic Agent action. There could be multiple documents with the same action_id if the action is split into two separate documents. + */ + action_id?: string; + /** + * Date/time the action was created + */ + '@timestamp'?: string; + /** + * The action expiration date/time + */ + expiration?: string; + /** + * The action type. APP_ACTION is the value for the actions that suppose to be routed to the endpoints/beats. + */ + type?: string; + /** + * The input identifier the actions should be routed to. + */ + input_id?: string; + /** + * The Agent IDs the action is intended for. No support for json.RawMessage with the current generator. Could be useful to lazy parse the agent ids + */ + agents?: string[]; + /** + * The opaque payload. + */ + data?: { + [k: string]: unknown; + }; + [k: string]: unknown; +} 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 5e86e8e6acb70c..5f41b0f70ca74f 100644 --- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts @@ -17,6 +17,7 @@ export interface NewAgentPolicy { namespace: string; description?: string; is_default?: boolean; + is_managed?: boolean; // Optional when creating a policy monitoring_enabled?: Array>; } @@ -24,6 +25,7 @@ export interface AgentPolicy extends NewAgentPolicy { id: string; status: ValueOf; package_policies: string[] | PackagePolicy[]; + is_managed: boolean; // required for created policy updated_at: string; updated_by: string; revision: number; diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_directory_header_link.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_directory_header_link.tsx index 12d3647aeb5249..cd378ec842679f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_directory_header_link.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_directory_header_link.tsx @@ -6,19 +6,16 @@ */ import React, { memo, useState, useEffect } from 'react'; -import { BehaviorSubject } from 'rxjs'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonEmpty } from '@elastic/eui'; import type { TutorialDirectoryHeaderLinkComponent } from 'src/plugins/home/public'; -import { useLink, useCapabilities } from '../../hooks'; - -const tutorialDirectoryNoticeState$ = new BehaviorSubject({ - settingsDataLoaded: false, - hasSeenNotice: false, -}); +import { RedirectAppLinks } from '../../../../../../../../src/plugins/kibana_react/public'; +import { useLink, useCapabilities, useStartServices } from '../../hooks'; +import { tutorialDirectoryNoticeState$ } from './tutorial_directory_notice'; const TutorialDirectoryHeaderLink: TutorialDirectoryHeaderLinkComponent = memo(() => { const { getHref } = useLink(); + const { application } = useStartServices(); const { show: hasIngestManager } = useCapabilities(); const [noticeState, setNoticeState] = useState({ settingsDataLoaded: false, @@ -33,12 +30,14 @@ const TutorialDirectoryHeaderLink: TutorialDirectoryHeaderLinkComponent = memo(( }, []); return hasIngestManager && noticeState.settingsDataLoaded && noticeState.hasSeenNotice ? ( - - - + + + + + ) : null; }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_directory_notice.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_directory_notice.tsx index 57a28030383015..8ea0c8730fdb5d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_directory_notice.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_directory_notice.tsx @@ -19,7 +19,14 @@ import { EuiSpacer, } from '@elastic/eui'; import type { TutorialDirectoryNoticeComponent } from 'src/plugins/home/public'; -import { sendPutSettings, useGetSettings, useLink, useCapabilities } from '../../hooks'; +import { RedirectAppLinks } from '../../../../../../../../src/plugins/kibana_react/public'; +import { + sendPutSettings, + useGetSettings, + useLink, + useCapabilities, + useStartServices, +} from '../../hooks'; const FlexItemButtonWrapper = styled(EuiFlexItem)` &&& { @@ -27,13 +34,14 @@ const FlexItemButtonWrapper = styled(EuiFlexItem)` } `; -const tutorialDirectoryNoticeState$ = new BehaviorSubject({ +export const tutorialDirectoryNoticeState$ = new BehaviorSubject({ settingsDataLoaded: false, hasSeenNotice: false, }); const TutorialDirectoryNotice: TutorialDirectoryNoticeComponent = memo(() => { const { getHref } = useLink(); + const { application } = useStartServices(); const { show: hasIngestManager } = useCapabilities(); const { data: settingsData, isLoading } = useGetSettings(); const [dismissedNotice, setDismissedNotice] = useState(false); @@ -98,12 +106,14 @@ const TutorialDirectoryNotice: TutorialDirectoryNoticeComponent = memo(() => {
    - - - + + + + +
    diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx index 30588c10178da6..b60d3b5eb1f2d1 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx @@ -687,6 +687,7 @@ On Windows, the module was tested with Nginx installed from the Chocolatey repos 'e8a37031-2907-44f6-89d2-98bd493f60dc', ], is_default: true, + is_managed: false, monitoring_enabled: ['logs', 'metrics'], revision: 6, updated_at: '2020-12-09T13:46:31.840Z', @@ -701,6 +702,7 @@ On Windows, the module was tested with Nginx installed from the Chocolatey repos status: 'active', package_policies: ['e8a37031-2907-44f6-89d2-98bd493f60cd'], is_default: false, + is_managed: false, monitoring_enabled: ['logs', 'metrics'], revision: 2, updated_at: '2020-12-09T13:46:31.840Z', diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index ee30c01ac8eece..a903de01380392 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -32,3 +32,5 @@ export class PackageCacheError extends IngestManagerError {} export class PackageOperationNotSupportedError extends IngestManagerError {} export class FleetAdminUserInvalidError extends IngestManagerError {} export class ConcurrentInstallOperationError extends IngestManagerError {} +export class AgentReassignmentError extends IngestManagerError {} +export class AgentUnenrollmentError extends IngestManagerError {} diff --git a/x-pack/plugins/fleet/server/routes/agent/acks_handlers.ts b/x-pack/plugins/fleet/server/routes/agent/acks_handlers.ts index 2d7c884edad83c..22b5035378a20d 100644 --- a/x-pack/plugins/fleet/server/routes/agent/acks_handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/acks_handlers.ts @@ -20,7 +20,7 @@ export const postAgentAcksHandlerBuilder = function ( try { const soClient = ackService.getSavedObjectsClientContract(request); const esClient = ackService.getElasticsearchClientContract(); - const agent = await ackService.authenticateAgentWithAccessToken(soClient, request); + const agent = await ackService.authenticateAgentWithAccessToken(soClient, esClient, request); const agentEvents = request.body.events as AgentEvent[]; // validate that all events are for the authorized agent obtained from the api key diff --git a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts index bf0cfd2d476dd8..d032945245faf0 100644 --- a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts @@ -30,7 +30,7 @@ export const postNewAgentActionHandlerBuilder = function ( const newAgentAction = request.body.action; - const savedAgentAction = await actionsService.createAgentAction(soClient, { + const savedAgentAction = await actionsService.createAgentAction(soClient, esClient, { created_at: new Date().toISOString(), ...newAgentAction, agent_id: agent.id, diff --git a/x-pack/plugins/fleet/server/routes/agent/handlers.ts b/x-pack/plugins/fleet/server/routes/agent/handlers.ts index 411da6da0223c6..cd91e8c325c066 100644 --- a/x-pack/plugins/fleet/server/routes/agent/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/handlers.ts @@ -132,8 +132,8 @@ export const updateAgentHandler: RequestHandler< const esClient = context.core.elasticsearch.client.asCurrentUser; try { - await AgentService.updateAgent(soClient, request.params.agentId, { - userProvidedMetatada: request.body.user_provided_metadata, + await AgentService.updateAgent(soClient, esClient, request.params.agentId, { + user_provided_metadata: request.body.user_provided_metadata, }); const agent = await AgentService.getAgent(soClient, esClient, request.params.agentId); @@ -164,12 +164,13 @@ export const postAgentCheckinHandler: RequestHandler< try { const soClient = appContextService.getInternalUserSOClient(request); const esClient = appContextService.getInternalUserESClient(); - const agent = await AgentService.authenticateAgentWithAccessToken(soClient, request); + const agent = await AgentService.authenticateAgentWithAccessToken(soClient, esClient, request); const abortController = new AbortController(); request.events.aborted$.subscribe(() => { abortController.abort(); }); const signal = abortController.signal; + const { actions } = await AgentService.agentCheckin( soClient, esClient, @@ -205,8 +206,13 @@ export const postAgentEnrollHandler: RequestHandler< > = async (context, request, response) => { try { const soClient = appContextService.getInternalUserSOClient(request); + const esClient = context.core.elasticsearch.client.asInternalUser; const { apiKeyId } = APIKeyService.parseApiKeyFromHeaders(request.headers); - const enrollmentAPIKey = await APIKeyService.getEnrollmentAPIKeyById(soClient, apiKeyId); + const enrollmentAPIKey = await APIKeyService.getEnrollmentAPIKeyById( + soClient, + esClient, + apiKeyId + ); if (!enrollmentAPIKey || !enrollmentAPIKey.active) { return response.unauthorized({ @@ -311,21 +317,16 @@ export const postBulkAgentsReassignHandler: RequestHandler< const soClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asInternalUser; try { - // Reassign by array of IDs - const result = Array.isArray(request.body.agents) - ? await AgentService.reassignAgents( - soClient, - esClient, - { agentIds: request.body.agents }, - request.body.policy_id - ) - : await AgentService.reassignAgents( - soClient, - esClient, - { kuery: request.body.agents }, - request.body.policy_id - ); - const body: PostBulkAgentReassignResponse = result.saved_objects.reduce((acc, so) => { + const results = await AgentService.reassignAgents( + soClient, + esClient, + Array.isArray(request.body.agents) + ? { agentIds: request.body.agents } + : { kuery: request.body.agents }, + request.body.policy_id + ); + + const body: PostBulkAgentReassignResponse = results.items.reduce((acc, so) => { return { ...acc, [so.id]: { diff --git a/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts b/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts index 0365d9f5a29fe5..614ccd8a266245 100644 --- a/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts +++ b/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts @@ -24,7 +24,7 @@ export const postAgentUnenrollHandler: RequestHandler< if (request.body?.force === true) { await AgentService.forceUnenrollAgent(soClient, esClient, request.params.agentId); } else { - await AgentService.unenrollAgent(soClient, request.params.agentId); + await AgentService.unenrollAgent(soClient, esClient, request.params.agentId); } const body: PostAgentUnenrollResponse = {}; diff --git a/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts b/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts index 0215b8f27b3932..086a9411f20b8d 100644 --- a/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts +++ b/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts @@ -8,18 +8,13 @@ import { RequestHandler } from 'src/core/server'; import { TypeOf } from '@kbn/config-schema'; import semverCoerce from 'semver/functions/coerce'; -import { - AgentSOAttributes, - PostAgentUpgradeResponse, - PostBulkAgentUpgradeResponse, -} from '../../../common/types'; +import { PostAgentUpgradeResponse, PostBulkAgentUpgradeResponse } from '../../../common/types'; import { PostAgentUpgradeRequestSchema, PostBulkAgentUpgradeRequestSchema } from '../../types'; import * as AgentService from '../../services/agents'; import { appContextService } from '../../services'; import { defaultIngestErrorHandler } from '../../errors'; -import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; -import { savedObjectToAgent } from '../../services/agents/saved_objects'; import { isAgentUpgradeable } from '../../../common/services'; +import { getAgent } from '../../services/agents'; export const postAgentUpgradeHandler: RequestHandler< TypeOf, @@ -27,6 +22,7 @@ export const postAgentUpgradeHandler: RequestHandler< TypeOf > = async (context, request, response) => { const soClient = context.core.savedObjects.client; + const esClient = context.core.elasticsearch.client.asInternalUser; const { version, source_uri: sourceUri, force } = request.body; const kibanaVersion = appContextService.getKibanaVersion(); try { @@ -39,12 +35,8 @@ export const postAgentUpgradeHandler: RequestHandler< }, }); } - - const agentSO = await soClient.get( - AGENT_SAVED_OBJECT_TYPE, - request.params.agentId - ); - if (agentSO.attributes.unenrollment_started_at || agentSO.attributes.unenrolled_at) { + const agent = await getAgent(soClient, esClient, request.params.agentId); + if (agent.unenrollment_started_at || agent.unenrolled_at) { return response.customError({ statusCode: 400, body: { @@ -53,7 +45,6 @@ export const postAgentUpgradeHandler: RequestHandler< }); } - const agent = savedObjectToAgent(agentSO); if (!force && !isAgentUpgradeable(agent, kibanaVersion)) { return response.customError({ statusCode: 400, @@ -66,6 +57,7 @@ export const postAgentUpgradeHandler: RequestHandler< try { await AgentService.sendUpgradeAgentAction({ soClient, + esClient, agentId: request.params.agentId, version, sourceUri, diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index c61dd1b8e4a194..d50db8d9809f4d 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -32,7 +32,7 @@ import { migrateSettingsToV7100, migrateAgentActionToV7100, } from './migrations/to_v7_10_0'; -import { migrateAgentToV7120 } from './migrations/to_v7_12_0'; +import { migrateAgentToV7120, migrateAgentPolicyToV7120 } from './migrations/to_v7_12_0'; /* * Saved object types and mappings @@ -161,6 +161,7 @@ const getSavedObjectTypes = ( description: { type: 'text' }, namespace: { type: 'keyword' }, is_default: { type: 'boolean' }, + is_managed: { type: 'boolean' }, status: { type: 'keyword' }, package_policies: { type: 'keyword' }, updated_at: { type: 'date' }, @@ -171,6 +172,7 @@ const getSavedObjectTypes = ( }, migrations: { '7.10.0': migrateAgentPolicyToV7100, + '7.12.0': migrateAgentPolicyToV7120, }, }, [ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE]: { diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_12_0.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_12_0.ts index 1635f38cd55220..49a0d6fc7737fa 100644 --- a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_12_0.ts +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_12_0.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { SavedObjectMigrationFn } from 'kibana/server'; -import { Agent } from '../../types'; +import type { SavedObjectMigrationFn } from 'kibana/server'; +import type { Agent, AgentPolicy } from '../../types'; export const migrateAgentToV7120: SavedObjectMigrationFn = ( agentDoc @@ -15,3 +15,14 @@ export const migrateAgentToV7120: SavedObjectMigrationFn, + AgentPolicy +> = (agentPolicyDoc) => { + const isV12 = 'is_managed' in agentPolicyDoc.attributes; + if (!isV12) { + agentPolicyDoc.attributes.is_managed = false; + } + return agentPolicyDoc; +}; diff --git a/x-pack/plugins/fleet/server/services/agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policy.test.ts index b70041e66dcd9a..800d4f479bfde2 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.test.ts @@ -8,17 +8,16 @@ import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks'; import { agentPolicyService } from './agent_policy'; import { agentPolicyUpdateEventHandler } from './agent_policy_update'; -import { Output } from '../types'; +import type { AgentPolicy, NewAgentPolicy, Output } from '../types'; function getSavedObjectMock(agentPolicyAttributes: any) { const mock = savedObjectsClientMock.create(); - mock.get.mockImplementation(async (type: string, id: string) => { return { type, id, references: [], - attributes: agentPolicyAttributes, + attributes: agentPolicyAttributes as AgentPolicy, }; }); mock.find.mockImplementation(async (options) => { @@ -69,10 +68,59 @@ function getAgentPolicyUpdateMock() { >; } +function getAgentPolicyCreateMock() { + const soClient = savedObjectsClientMock.create(); + soClient.create.mockImplementation(async (type, attributes) => { + return { + attributes: (attributes as unknown) as NewAgentPolicy, + id: 'mocked', + type: 'mocked', + references: [], + }; + }); + return soClient; +} describe('agent policy', () => { beforeEach(() => { getAgentPolicyUpdateMock().mockClear(); }); + + describe('create', () => { + it('is_managed present and false by default', async () => { + // ignore unrelated unique name constraint + agentPolicyService.requireUniqueName = async () => {}; + const soClient = getAgentPolicyCreateMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + await expect( + agentPolicyService.create(soClient, esClient, { + name: 'No is_managed provided', + namespace: 'default', + }) + ).resolves.toHaveProperty('is_managed', false); + + const [, attributes] = soClient.create.mock.calls[0]; + expect(attributes).toHaveProperty('is_managed', false); + }); + + it('should set is_managed property, if given', async () => { + // ignore unrelated unique name constraint + agentPolicyService.requireUniqueName = async () => {}; + const soClient = getAgentPolicyCreateMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await expect( + agentPolicyService.create(soClient, esClient, { + name: 'is_managed: true provided', + namespace: 'default', + is_managed: true, + }) + ).resolves.toHaveProperty('is_managed', true); + + const [, attributes] = soClient.create.mock.calls[0]; + expect(attributes).toHaveProperty('is_managed', true); + }); + }); + describe('bumpRevision', () => { it('should call agentPolicyUpdateEventHandler with updated event once', async () => { const soClient = getSavedObjectMock({ @@ -208,4 +256,37 @@ describe('agent policy', () => { }); }); }); + + describe('update', () => { + it('should update is_managed property, if given', async () => { + // ignore unrelated unique name constraint + agentPolicyService.requireUniqueName = async () => {}; + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + soClient.get.mockResolvedValue({ + attributes: {}, + id: 'mocked', + type: 'mocked', + references: [], + }); + await agentPolicyService.update(soClient, esClient, 'mocked', { + name: 'mocked', + namespace: 'default', + is_managed: false, + }); + // soClient.update is called with updated values + let calledWith = soClient.update.mock.calls[0]; + expect(calledWith[2]).toHaveProperty('is_managed', false); + + await agentPolicyService.update(soClient, esClient, 'mocked', { + name: 'is_managed: true provided', + namespace: 'default', + is_managed: true, + }); + // soClient.update is called with updated values + calledWith = soClient.update.mock.calls[1]; + expect(calledWith[2]).toHaveProperty('is_managed', true); + }); + }); }); diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 4a3319941b5757..ca131efeff68cc 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -140,6 +140,7 @@ class AgentPolicyService { SAVED_OBJECT_TYPE, { ...agentPolicy, + is_managed: agentPolicy.is_managed ?? false, revision: 1, updated_at: new Date().toISOString(), updated_by: options?.user?.username || 'system', @@ -487,17 +488,17 @@ class AgentPolicyService { soClient: SavedObjectsClientContract, agentPolicyId: string ) { - return appContextService.getConfig()?.agents.fleetServerEnabled - ? this.createFleetPolicyChangeFleetServer( - soClient, - appContextService.getInternalUserESClient(), - agentPolicyId - ) - : this.createFleetPolicyChangeActionSO(soClient, agentPolicyId); + const esClient = appContextService.getInternalUserESClient(); + if (appContextService.getConfig()?.agents?.fleetServerEnabled) { + await this.createFleetPolicyChangeFleetServer(soClient, esClient, agentPolicyId); + } + + return this.createFleetPolicyChangeActionSO(soClient, esClient, agentPolicyId); } public async createFleetPolicyChangeActionSO( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, agentPolicyId: string ) { // If Agents is not setup skip the creation of POLICY_CHANGE agent actions @@ -517,7 +518,7 @@ class AgentPolicyService { return acc; }, []); - await createAgentPolicyAction(soClient, { + await createAgentPolicyAction(soClient, esClient, { type: 'POLICY_CHANGE', data: { policy }, ack_data: { packages }, diff --git a/x-pack/plugins/fleet/server/services/agents/acks.test.ts b/x-pack/plugins/fleet/server/services/agents/acks.test.ts index c1a6067195c979..5aec696f5e1440 100644 --- a/x-pack/plugins/fleet/server/services/agents/acks.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/acks.test.ts @@ -106,19 +106,19 @@ describe('test agent acks services', () => { } as AgentEvent, ] ); - expect(mockSavedObjectsClient.bulkUpdate).toBeCalled(); - expect(mockSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toHaveLength(1); - expect(mockSavedObjectsClient.bulkUpdate.mock.calls[0][0][0]).toMatchInlineSnapshot(` - Object { - "attributes": Object { + expect(mockSavedObjectsClient.bulkUpdate).not.toBeCalled(); + expect(mockSavedObjectsClient.update).toBeCalled(); + expect(mockSavedObjectsClient.update.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "fleet-agents", + "id", + Object { "packages": Array [ "system", ], "policy_revision": 4, }, - "id": "id", - "type": "fleet-agents", - } + ] `); }); @@ -168,19 +168,19 @@ describe('test agent acks services', () => { } as AgentEvent, ] ); - expect(mockSavedObjectsClient.bulkUpdate).toBeCalled(); - expect(mockSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toHaveLength(1); - expect(mockSavedObjectsClient.bulkUpdate.mock.calls[0][0][0]).toMatchInlineSnapshot(` - Object { - "attributes": Object { + expect(mockSavedObjectsClient.bulkUpdate).not.toBeCalled(); + expect(mockSavedObjectsClient.update).toBeCalled(); + expect(mockSavedObjectsClient.update.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "fleet-agents", + "id", + Object { "packages": Array [ "system", ], "policy_revision": 4, }, - "id": "id", - "type": "fleet-agents", - } + ] `); }); @@ -230,8 +230,8 @@ describe('test agent acks services', () => { } as AgentEvent, ] ); - expect(mockSavedObjectsClient.bulkUpdate).toBeCalled(); - expect(mockSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toHaveLength(0); + expect(mockSavedObjectsClient.bulkUpdate).not.toBeCalled(); + expect(mockSavedObjectsClient.update).not.toBeCalled(); }); it('should not update config field on the agent if a policy change for an old revision is acknowledged', async () => { @@ -277,8 +277,8 @@ describe('test agent acks services', () => { } as AgentEvent, ] ); - expect(mockSavedObjectsClient.bulkUpdate).toBeCalled(); - expect(mockSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toHaveLength(0); + expect(mockSavedObjectsClient.bulkUpdate).not.toBeCalled(); + expect(mockSavedObjectsClient.update).not.toBeCalled(); }); it('should fail for actions that cannot be found on agent actions list', async () => { diff --git a/x-pack/plugins/fleet/server/services/agents/acks.ts b/x-pack/plugins/fleet/server/services/agents/acks.ts index a09107b90a0156..c639a9b0332ac6 100644 --- a/x-pack/plugins/fleet/server/services/agents/acks.ts +++ b/x-pack/plugins/fleet/server/services/agents/acks.ts @@ -24,14 +24,11 @@ import { AgentSOAttributes, AgentActionSOAttributes, } from '../../types'; -import { - AGENT_EVENT_SAVED_OBJECT_TYPE, - AGENT_SAVED_OBJECT_TYPE, - AGENT_ACTION_SAVED_OBJECT_TYPE, -} from '../../constants'; +import { AGENT_EVENT_SAVED_OBJECT_TYPE, AGENT_ACTION_SAVED_OBJECT_TYPE } from '../../constants'; import { getAgentActionByIds } from './actions'; import { forceUnenrollAgent } from './unenroll'; import { ackAgentUpgraded } from './upgrade'; +import { updateAgent } from './crud'; const ALLOWED_ACKNOWLEDGEMENT_TYPE: string[] = ['ACTION_RESULT']; @@ -87,26 +84,23 @@ export async function acknowledgeAgentActions( const upgradeAction = actions.find((action) => action.type === 'UPGRADE'); if (upgradeAction) { - await ackAgentUpgraded(soClient, upgradeAction); + await ackAgentUpgraded(soClient, esClient, upgradeAction); } const configChangeAction = getLatestConfigChangePolicyActionIfUpdated(agent, actions); - await soClient.bulkUpdate([ - ...(configChangeAction - ? [ - { - type: AGENT_SAVED_OBJECT_TYPE, - id: agent.id, - attributes: { - policy_revision: configChangeAction.policy_revision, - packages: configChangeAction?.ack_data?.packages, - }, - }, - ] - : []), - ...buildUpdateAgentActionSentAt(agentActionsIds), - ]); + if (configChangeAction) { + await updateAgent(soClient, esClient, agent.id, { + policy_revision: configChangeAction.policy_revision, + packages: configChangeAction?.ack_data?.packages, + }); + } + + if (agentActionsIds.length > 0) { + await soClient.bulkUpdate([ + ...buildUpdateAgentActionSentAt(agentActionsIds), + ]); + } return actions; } @@ -206,6 +200,7 @@ export interface AcksService { authenticateAgentWithAccessToken: ( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, request: KibanaRequest ) => Promise; diff --git a/x-pack/plugins/fleet/server/services/agents/actions.test.ts b/x-pack/plugins/fleet/server/services/agents/actions.test.ts index 5b3c2ea5ce7088..3d391cc89a7e31 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.test.ts @@ -8,12 +8,12 @@ import { createAgentAction } from './actions'; import { SavedObject } from 'kibana/server'; import { AgentAction } from '../../../common/types/models'; -import { savedObjectsClientMock } from 'src/core/server/mocks'; +import { savedObjectsClientMock, elasticsearchServiceMock } from 'src/core/server/mocks'; describe('test agent actions services', () => { it('should create a new action', async () => { const mockSavedObjectsClient = savedObjectsClientMock.create(); - + const mockedEsClient = elasticsearchServiceMock.createInternalClient(); const newAgentAction: Omit = { agent_id: 'agentid', type: 'POLICY_CHANGE', @@ -32,7 +32,7 @@ describe('test agent actions services', () => { }, } as SavedObject) ); - await createAgentAction(mockSavedObjectsClient, newAgentAction); + await createAgentAction(mockSavedObjectsClient, mockedEsClient, newAgentAction); const createdAction = (mockSavedObjectsClient.create.mock .calls[0][1] as unknown) as AgentAction; diff --git a/x-pack/plugins/fleet/server/services/agents/actions.ts b/x-pack/plugins/fleet/server/services/agents/actions.ts index b45b7836eb46f9..8dfeac11dacf3b 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.ts @@ -13,8 +13,9 @@ import { BaseAgentActionSOAttributes, AgentActionSOAttributes, AgentPolicyActionSOAttributes, + FleetServerAgentAction, } from '../../../common/types/models'; -import { AGENT_ACTION_SAVED_OBJECT_TYPE } from '../../../common/constants'; +import { AGENT_ACTION_SAVED_OBJECT_TYPE, AGENT_ACTIONS_INDEX } from '../../../common/constants'; import { isAgentActionSavedObject, isPolicyActionSavedObject, @@ -23,37 +24,45 @@ import { import { appContextService } from '../app_context'; import { nodeTypes } from '../../../../../../src/plugins/data/common'; +const ONE_MONTH_IN_MS = 2592000000; + export async function createAgentAction( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, newAgentAction: Omit ): Promise { - return createAction(soClient, newAgentAction); + return createAction(soClient, esClient, newAgentAction); } export async function bulkCreateAgentActions( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, newAgentActions: Array> ): Promise { - return bulkCreateActions(soClient, newAgentActions); + return bulkCreateActions(soClient, esClient, newAgentActions); } export function createAgentPolicyAction( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, newAgentAction: Omit ): Promise { - return createAction(soClient, newAgentAction); + return createAction(soClient, esClient, newAgentAction); } async function createAction( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, newAgentAction: Omit ): Promise; async function createAction( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, newAgentAction: Omit ): Promise; async function createAction( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, newAgentAction: Omit | Omit ): Promise { const actionSO = await soClient.create( @@ -65,6 +74,27 @@ async function createAction( } ); + if ( + appContextService.getConfig()?.agents?.fleetServerEnabled && + isAgentActionSavedObject(actionSO) + ) { + const body: FleetServerAgentAction = { + '@timestamp': new Date().toISOString(), + expiration: new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), + agents: [actionSO.attributes.agent_id], + action_id: actionSO.id, + data: newAgentAction.data, + type: newAgentAction.type, + }; + + await esClient.create({ + index: AGENT_ACTIONS_INDEX, + id: actionSO.id, + body, + refresh: 'wait_for', + }); + } + if (isAgentActionSavedObject(actionSO)) { const agentAction = savedObjectToAgentAction(actionSO); // Action `data` is encrypted, so is not returned from the saved object @@ -84,14 +114,17 @@ async function createAction( async function bulkCreateActions( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, newAgentActions: Array> ): Promise; async function bulkCreateActions( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, newAgentActions: Array> ): Promise; async function bulkCreateActions( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, newAgentActions: Array | Omit> ): Promise> { const { saved_objects: actionSOs } = await soClient.bulkCreate( @@ -105,6 +138,34 @@ async function bulkCreateActions( })) ); + if (appContextService.getConfig()?.agents?.fleetServerEnabled) { + await esClient.bulk({ + index: AGENT_ACTIONS_INDEX, + body: actionSOs.flatMap((actionSO) => { + if (!isAgentActionSavedObject(actionSO)) { + return []; + } + const body: FleetServerAgentAction = { + '@timestamp': new Date().toISOString(), + expiration: new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), + agents: [actionSO.attributes.agent_id], + action_id: actionSO.id, + data: actionSO.attributes.data ? JSON.parse(actionSO.attributes.data) : undefined, + type: actionSO.type, + }; + + return [ + { + create: { + _id: actionSO.id, + }, + }, + body, + ]; + }), + }); + } + return actionSOs.map((actionSO) => { if (isAgentActionSavedObject(actionSO)) { const agentAction = savedObjectToAgentAction(actionSO); @@ -316,6 +377,7 @@ export interface ActionsService { createAgentAction: ( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, newAgentAction: Omit ) => Promise; } diff --git a/x-pack/plugins/fleet/server/services/agents/authenticate.test.ts b/x-pack/plugins/fleet/server/services/agents/authenticate.test.ts index c59e2decebd992..5a1e86c15c0024 100644 --- a/x-pack/plugins/fleet/server/services/agents/authenticate.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/authenticate.test.ts @@ -6,10 +6,12 @@ */ import { KibanaRequest } from 'kibana/server'; -import { savedObjectsClientMock } from 'src/core/server/mocks'; +import { savedObjectsClientMock, elasticsearchServiceMock } from 'src/core/server/mocks'; import { authenticateAgentWithAccessToken } from './authenticate'; +const mockEsClient = elasticsearchServiceMock.createInternalClient(); + describe('test agent autenticate services', () => { it('should succeed with a valid API key and an active agent', async () => { const mockSavedObjectsClient = savedObjectsClientMock.create(); @@ -32,7 +34,7 @@ describe('test agent autenticate services', () => { ], }) ); - await authenticateAgentWithAccessToken(mockSavedObjectsClient, { + await authenticateAgentWithAccessToken(mockSavedObjectsClient, mockEsClient, { auth: { isAuthenticated: true }, headers: { authorization: 'ApiKey cGVkVHVISUJURUR0OTN3VzBGaHI6TnU1U0JtbHJSeC12Rm9qQWpoSHlUZw==', @@ -62,7 +64,7 @@ describe('test agent autenticate services', () => { }) ); expect( - authenticateAgentWithAccessToken(mockSavedObjectsClient, { + authenticateAgentWithAccessToken(mockSavedObjectsClient, mockEsClient, { auth: { isAuthenticated: false }, headers: { authorization: 'ApiKey cGVkVHVISUJURUR0OTN3VzBGaHI6TnU1U0JtbHJSeC12Rm9qQWpoSHlUZw==', @@ -93,7 +95,7 @@ describe('test agent autenticate services', () => { }) ); expect( - authenticateAgentWithAccessToken(mockSavedObjectsClient, { + authenticateAgentWithAccessToken(mockSavedObjectsClient, mockEsClient, { auth: { isAuthenticated: true }, headers: { authorization: 'aaaa', @@ -124,7 +126,7 @@ describe('test agent autenticate services', () => { }) ); expect( - authenticateAgentWithAccessToken(mockSavedObjectsClient, { + authenticateAgentWithAccessToken(mockSavedObjectsClient, mockEsClient, { auth: { isAuthenticated: true }, headers: { authorization: 'ApiKey cGVkVHVISUJURUR0OTN3VzBGaHI6TnU1U0JtbHJSeC12Rm9qQWpoSHlUZw==', @@ -144,7 +146,7 @@ describe('test agent autenticate services', () => { }) ); expect( - authenticateAgentWithAccessToken(mockSavedObjectsClient, { + authenticateAgentWithAccessToken(mockSavedObjectsClient, mockEsClient, { auth: { isAuthenticated: true }, headers: { authorization: 'ApiKey cGVkVHVISUJURUR0OTN3VzBGaHI6TnU1U0JtbHJSeC12Rm9qQWpoSHlUZw==', diff --git a/x-pack/plugins/fleet/server/services/agents/authenticate.ts b/x-pack/plugins/fleet/server/services/agents/authenticate.ts index a773173b1ddc10..a03c35bdc6e737 100644 --- a/x-pack/plugins/fleet/server/services/agents/authenticate.ts +++ b/x-pack/plugins/fleet/server/services/agents/authenticate.ts @@ -6,13 +6,14 @@ */ import Boom from '@hapi/boom'; -import { KibanaRequest, SavedObjectsClientContract } from 'src/core/server'; +import { KibanaRequest, SavedObjectsClientContract, ElasticsearchClient } from 'src/core/server'; import { Agent } from '../../types'; import * as APIKeyService from '../api_keys'; import { getAgentByAccessAPIKeyId } from './crud'; export async function authenticateAgentWithAccessToken( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, request: KibanaRequest ): Promise { if (!request.auth.isAuthenticated) { @@ -25,7 +26,7 @@ export async function authenticateAgentWithAccessToken( throw Boom.unauthorized(err.message); } - const agent = await getAgentByAccessAPIKeyId(soClient, res.apiKeyId); + const agent = await getAgentByAccessAPIKeyId(soClient, esClient, res.apiKeyId); return agent; } diff --git a/x-pack/plugins/fleet/server/services/agents/checkin/index.ts b/x-pack/plugins/fleet/server/services/agents/checkin/index.ts index 9a60abdc69423d..bcebedae2e07a1 100644 --- a/x-pack/plugins/fleet/server/services/agents/checkin/index.ts +++ b/x-pack/plugins/fleet/server/services/agents/checkin/index.ts @@ -19,9 +19,10 @@ import { AgentEventSOAttributes, } from '../../../types'; -import { AGENT_SAVED_OBJECT_TYPE, AGENT_EVENT_SAVED_OBJECT_TYPE } from '../../../constants'; +import { AGENT_EVENT_SAVED_OBJECT_TYPE } from '../../../constants'; import { agentCheckinState } from './state'; import { getAgentActionsForCheckin } from '../actions'; +import { updateAgent } from '../crud'; export async function agentCheckin( soClient: SavedObjectsClientContract, @@ -35,13 +36,7 @@ export async function agentCheckin( options?: { signal: AbortSignal } ) { const updateData: Partial = {}; - const { updatedErrorEvents } = await processEventsForCheckin(soClient, agent, data.events); - if ( - updatedErrorEvents && - !(updatedErrorEvents.length === 0 && agent.current_error_events.length === 0) - ) { - updateData.current_error_events = JSON.stringify(updatedErrorEvents); - } + await processEventsForCheckin(soClient, agent, data.events); if (data.localMetadata && !deepEqual(data.localMetadata, agent.local_metadata)) { updateData.local_metadata = data.localMetadata; } @@ -50,9 +45,8 @@ export async function agentCheckin( } // Update agent only if something changed if (Object.keys(updateData).length > 0) { - await soClient.update(AGENT_SAVED_OBJECT_TYPE, agent.id, updateData); + await updateAgent(soClient, esClient, agent.id, updateData); } - // Check if some actions are not acknowledged let actions = await getAgentActionsForCheckin(soClient, agent.id); if (actions.length > 0) { diff --git a/x-pack/plugins/fleet/server/services/agents/checkin/state_connected_agents.ts b/x-pack/plugins/fleet/server/services/agents/checkin/state_connected_agents.ts index 83fd139a1e8e82..6156212a632032 100644 --- a/x-pack/plugins/fleet/server/services/agents/checkin/state_connected_agents.ts +++ b/x-pack/plugins/fleet/server/services/agents/checkin/state_connected_agents.ts @@ -5,10 +5,9 @@ * 2.0. */ -import { KibanaRequest, SavedObjectsBulkUpdateObject } from 'src/core/server'; +import { KibanaRequest } from 'src/core/server'; import { appContextService } from '../../app_context'; -import { AgentSOAttributes } from '../../../types'; -import { AGENT_SAVED_OBJECT_TYPE } from '../../../constants'; +import { bulkUpdateAgents } from '../crud'; function getInternalUserSOClient() { const fakeRequest = ({ @@ -57,20 +56,17 @@ export function agentCheckinStateConnectedAgentsFactory() { if (agentToUpdate.size === 0) { return; } + const esClient = appContextService.getInternalUserESClient(); const internalSOClient = getInternalUserSOClient(); const now = new Date().toISOString(); - const updates: Array> = [ - ...agentToUpdate.values(), - ].map((agentId) => ({ - type: AGENT_SAVED_OBJECT_TYPE, - id: agentId, - attributes: { + const updates = [...agentToUpdate.values()].map((agentId) => ({ + agentId, + data: { last_checkin: now, }, })); - agentToUpdate = new Set([...connectedAgentsIds.values()]); - await internalSOClient.bulkUpdate(updates, { refresh: false }); + await bulkUpdateAgents(internalSOClient, esClient, updates); } return { diff --git a/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.test.ts b/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.test.ts index ca8378c117b7df..cd6e0ef61e3f08 100644 --- a/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ElasticsearchClient } from 'kibana/server'; import { savedObjectsClientMock } from 'src/core/server/mocks'; import { take } from 'rxjs/operators'; import { @@ -17,6 +18,7 @@ import { outputType } from '../../../../common/constants'; jest.mock('../../app_context', () => ({ appContextService: { + getConfig: () => ({}), getInternalUserSOClient: () => { return {}; }, @@ -42,6 +44,8 @@ function getMockedNewActionSince() { return getNewActionsSince as jest.MockedFunction; } +const mockedEsClient = {} as ElasticsearchClient; + describe('test agent checkin new action services', () => { describe('newAgetActionObservable', () => { beforeEach(() => { @@ -161,12 +165,18 @@ describe('test agent checkin new action services', () => { ]; expect( - await createAgentActionFromPolicyAction(mockSavedObjectsClient, mockAgent, mockPolicyAction) + await createAgentActionFromPolicyAction( + mockSavedObjectsClient, + mockedEsClient, + mockAgent, + mockPolicyAction + ) ).toEqual(expectedResult); expect( await createAgentActionFromPolicyAction( mockSavedObjectsClient, + mockedEsClient, { ...mockAgent, local_metadata: { elastic: { agent: { version: '7.10.0-SNAPSHOT' } } } }, mockPolicyAction ) @@ -175,6 +185,7 @@ describe('test agent checkin new action services', () => { expect( await createAgentActionFromPolicyAction( mockSavedObjectsClient, + mockedEsClient, { ...mockAgent, local_metadata: { elastic: { agent: { version: '7.10.2' } } } }, mockPolicyAction ) @@ -183,6 +194,7 @@ describe('test agent checkin new action services', () => { expect( await createAgentActionFromPolicyAction( mockSavedObjectsClient, + mockedEsClient, { ...mockAgent, local_metadata: { elastic: { agent: { version: '8.0.0' } } } }, mockPolicyAction ) @@ -191,6 +203,7 @@ describe('test agent checkin new action services', () => { expect( await createAgentActionFromPolicyAction( mockSavedObjectsClient, + mockedEsClient, { ...mockAgent, local_metadata: { elastic: { agent: { version: '8.0.0-SNAPSHOT' } } } }, mockPolicyAction ) @@ -218,6 +231,7 @@ describe('test agent checkin new action services', () => { expect( await createAgentActionFromPolicyAction( mockSavedObjectsClient, + mockedEsClient, { ...mockAgent, local_metadata: { elastic: { agent: { version: '7.9.0' } } } }, mockPolicyAction ) @@ -226,6 +240,7 @@ describe('test agent checkin new action services', () => { expect( await createAgentActionFromPolicyAction( mockSavedObjectsClient, + mockedEsClient, { ...mockAgent, local_metadata: { elastic: { agent: { version: '7.9.3' } } } }, mockPolicyAction ) @@ -234,6 +249,7 @@ describe('test agent checkin new action services', () => { expect( await createAgentActionFromPolicyAction( mockSavedObjectsClient, + mockedEsClient, { ...mockAgent, local_metadata: { elastic: { agent: { version: '7.9.1-SNAPSHOT' } } } }, mockPolicyAction ) @@ -242,6 +258,7 @@ describe('test agent checkin new action services', () => { expect( await createAgentActionFromPolicyAction( mockSavedObjectsClient, + mockedEsClient, { ...mockAgent, local_metadata: { elastic: { agent: { version: '7.8.2' } } } }, mockPolicyAction ) diff --git a/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.ts b/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.ts index 624b7bbcae5721..01759c2015cdf6 100644 --- a/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.ts @@ -45,7 +45,7 @@ import { } from '../actions'; import { appContextService } from '../../app_context'; import { toPromiseAbortable, AbortError, createRateLimiter } from './rxjs_utils'; -import { getAgent } from '../crud'; +import { getAgent, updateAgent } from '../crud'; function getInternalUserSOClient() { const fakeRequest = ({ @@ -106,31 +106,45 @@ function createAgentPolicyActionSharedObservable(agentPolicyId: string) { ); } -async function getOrCreateAgentDefaultOutputAPIKey( +async function getAgentDefaultOutputAPIKey( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, agent: Agent -): Promise { - const { - attributes: { default_api_key: defaultApiKey }, - } = await appContextService - .getEncryptedSavedObjects() - .getDecryptedAsInternalUser(AGENT_SAVED_OBJECT_TYPE, agent.id); +) { + if (appContextService.getConfig()?.agents?.fleetServerEnabled) { + return agent.default_api_key; + } else { + const { + attributes: { default_api_key: defaultApiKey }, + } = await appContextService + .getEncryptedSavedObjects() + .getDecryptedAsInternalUser(AGENT_SAVED_OBJECT_TYPE, agent.id); - if (defaultApiKey) { return defaultApiKey; } +} + +async function getOrCreateAgentDefaultOutputAPIKey( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + agent: Agent +): Promise { + const defaultAPIKey = await getAgentDefaultOutputAPIKey(soClient, esClient, agent); + if (defaultAPIKey) { + return defaultAPIKey; + } const outputAPIKey = await APIKeysService.generateOutputApiKey(soClient, 'default', agent.id); - await soClient.update(AGENT_SAVED_OBJECT_TYPE, agent.id, { + await updateAgent(soClient, esClient, agent.id, { default_api_key: outputAPIKey.key, default_api_key_id: outputAPIKey.id, }); - return outputAPIKey.key; } export async function createAgentActionFromPolicyAction( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, agent: Agent, policyAction: AgentPolicyAction ) { @@ -168,7 +182,7 @@ export async function createAgentActionFromPolicyAction( ); // Mutate the policy to set the api token for this agent - const apiKey = await getOrCreateAgentDefaultOutputAPIKey(soClient, agent); + const apiKey = await getOrCreateAgentDefaultOutputAPIKey(soClient, esClient, agent); if (newAgentAction.data.policy) { newAgentAction.data.policy.outputs.default.api_key = apiKey; } @@ -249,7 +263,9 @@ export function agentCheckinStateNewActionsFactory() { (!agent.policy_revision || action.policy_revision > agent.policy_revision) ), rateLimiter(), - concatMap((policyAction) => createAgentActionFromPolicyAction(soClient, agent, policyAction)), + concatMap((policyAction) => + createAgentActionFromPolicyAction(soClient, esClient, agent, policyAction) + ), merge(newActions$), concatMap((data: AgentAction[] | undefined) => { if (data === undefined) { @@ -274,7 +290,7 @@ export function agentCheckinStateNewActionsFactory() { }), rateLimiter(), concatMap((policyAction) => - createAgentActionFromPolicyAction(soClient, agent, policyAction) + createAgentActionFromPolicyAction(soClient, esClient, agent, policyAction) ) ); } diff --git a/x-pack/plugins/fleet/server/services/agents/crud.ts b/x-pack/plugins/fleet/server/services/agents/crud.ts index 9382a8bb616479..c80fd77fc11ecc 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.ts @@ -5,14 +5,9 @@ * 2.0. */ -import Boom from '@hapi/boom'; import { SavedObjectsClientContract, ElasticsearchClient } from 'src/core/server'; - -import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; import { AgentSOAttributes, Agent, ListWithKuery } from '../../types'; -import { escapeSearchQueryPhrase } from '../saved_object'; -import { savedObjectToAgent } from './saved_objects'; -import { appContextService } from '../../services'; +import { appContextService, agentPolicyService } from '../../services'; import * as crudServiceSO from './crud_so'; import * as crudServiceFleetServer from './crud_fleet_server'; @@ -75,51 +70,68 @@ export async function getAgent( : crudServiceSO.getAgent(soClient, agentId); } -export async function getAgents(soClient: SavedObjectsClientContract, agentIds: string[]) { - const agentSOs = await soClient.bulkGet( - agentIds.map((agentId) => ({ - id: agentId, - type: AGENT_SAVED_OBJECT_TYPE, - })) - ); - const agents = agentSOs.saved_objects.map(savedObjectToAgent); - return agents; +export async function getAgents( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + agentIds: string[] +) { + const fleetServerEnabled = appContextService.getConfig()?.agents?.fleetServerEnabled; + return fleetServerEnabled + ? crudServiceFleetServer.getAgents(esClient, agentIds) + : crudServiceSO.getAgents(soClient, agentIds); } -export async function getAgentByAccessAPIKeyId( +export async function getAgentPolicyForAgent( soClient: SavedObjectsClientContract, - accessAPIKeyId: string -): Promise { - const response = await soClient.find({ - type: AGENT_SAVED_OBJECT_TYPE, - searchFields: ['access_api_key_id'], - search: escapeSearchQueryPhrase(accessAPIKeyId), - }); - const [agent] = response.saved_objects.map(savedObjectToAgent); - - if (!agent) { - throw Boom.notFound('Agent not found'); - } - if (agent.access_api_key_id !== accessAPIKeyId) { - throw new Error('Agent api key id is not matching'); + esClient: ElasticsearchClient, + agentId: string +) { + const agent = await getAgent(soClient, esClient, agentId); + if (!agent.policy_id) { + return; } - if (!agent.active) { - throw Boom.forbidden('Agent inactive'); + + const agentPolicy = await agentPolicyService.get(soClient, agent.policy_id, false); + if (agentPolicy) { + return agentPolicy; } +} - return agent; +export async function getAgentByAccessAPIKeyId( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + accessAPIKeyId: string +): Promise { + const fleetServerEnabled = appContextService.getConfig()?.agents?.fleetServerEnabled; + return fleetServerEnabled + ? crudServiceFleetServer.getAgentByAccessAPIKeyId(esClient, accessAPIKeyId) + : crudServiceSO.getAgentByAccessAPIKeyId(soClient, accessAPIKeyId); } export async function updateAgent( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, agentId: string, - data: { - userProvidedMetatada: any; - } + data: Partial ) { - await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, { - user_provided_metadata: data.userProvidedMetatada, - }); + const fleetServerEnabled = appContextService.getConfig()?.agents?.fleetServerEnabled; + return fleetServerEnabled + ? crudServiceFleetServer.updateAgent(esClient, agentId, data) + : crudServiceSO.updateAgent(soClient, agentId, data); +} + +export async function bulkUpdateAgents( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + data: Array<{ + agentId: string; + data: Partial; + }> +) { + const fleetServerEnabled = appContextService.getConfig()?.agents?.fleetServerEnabled; + return fleetServerEnabled + ? crudServiceFleetServer.bulkUpdateAgents(esClient, data) + : crudServiceSO.bulkUpdateAgents(soClient, data); } export async function deleteAgent( diff --git a/x-pack/plugins/fleet/server/services/agents/crud_fleet_server.ts b/x-pack/plugins/fleet/server/services/agents/crud_fleet_server.ts index c9aa221edf4d29..caff15efff68c2 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud_fleet_server.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud_fleet_server.ts @@ -6,31 +6,46 @@ */ import Boom from '@hapi/boom'; -import { SavedObjectsClientContract, ElasticsearchClient } from 'src/core/server'; +import { SearchResponse } from 'elasticsearch'; +import { ElasticsearchClient } from 'src/core/server'; -import { isAgentUpgradeable, SO_SEARCH_LIMIT } from '../../../common'; +import { FleetServerAgent, isAgentUpgradeable, SO_SEARCH_LIMIT } from '../../../common'; import { AGENT_SAVED_OBJECT_TYPE, AGENTS_INDEX } from '../../constants'; import { ESSearchHit } from '../../../../../typings/elasticsearch'; import { AgentSOAttributes, Agent, ListWithKuery } from '../../types'; import { escapeSearchQueryPhrase, normalizeKuery } from '../saved_object'; -import { savedObjectToAgent } from './saved_objects'; -import { searchHitToAgent } from './helpers'; +import { searchHitToAgent, agentSOAttributesToFleetServerAgentDoc } from './helpers'; import { appContextService } from '../../services'; +import { esKuery, KueryNode } from '../../../../../../src/plugins/data/server'; const ACTIVE_AGENT_CONDITION = 'active:true'; const INACTIVE_AGENT_CONDITION = `NOT (${ACTIVE_AGENT_CONDITION})`; -function _joinFilters(filters: string[], operator = 'AND') { - return filters.reduce((acc: string | undefined, filter) => { - if (acc) { - return `${acc} ${operator} (${filter})`; - } +function _joinFilters(filters: Array): KueryNode | undefined { + return filters + .filter((filter) => filter !== undefined) + .reduce((acc: KueryNode | undefined, kuery: string | KueryNode | undefined): + | KueryNode + | undefined => { + if (kuery === undefined) { + return acc; + } + const kueryNode: KueryNode = + typeof kuery === 'string' ? esKuery.fromKueryExpression(removeSOAttributes(kuery)) : kuery; - return `(${filter})`; - }, undefined); + if (!acc) { + return kueryNode; + } + + return { + type: 'function', + function: 'and', + arguments: [acc, kueryNode], + }; + }, undefined as KueryNode | undefined); } -function removeSOAttributes(kuery: string) { +export function removeSOAttributes(kuery: string) { return kuery.replace(/attributes\./g, '').replace(/fleet-agents\./g, ''); } @@ -57,20 +72,23 @@ export async function listAgents( const filters = []; if (kuery && kuery !== '') { - filters.push(removeSOAttributes(kuery)); + filters.push(kuery); } if (showInactive === false) { filters.push(ACTIVE_AGENT_CONDITION); } + const kueryNode = _joinFilters(filters); + const body = kueryNode ? { query: esKuery.toElasticsearchQuery(kueryNode) } : {}; + const res = await esClient.search({ index: AGENTS_INDEX, from: (page - 1) * perPage, size: perPage, sort: `${sortField}:${sortOrder}`, track_total_hits: true, - q: _joinFilters(filters), + body, }); let agentResults: Agent[] = res.body.hits.hits.map(searchHitToAgent); @@ -121,18 +139,20 @@ export async function countInactiveAgents( filters.push(normalizeKuery(AGENT_SAVED_OBJECT_TYPE, kuery)); } + const kueryNode = _joinFilters(filters); + const body = kueryNode ? { query: esKuery.toElasticsearchQuery(kueryNode) } : {}; + const res = await esClient.search({ index: AGENTS_INDEX, size: 0, track_total_hits: true, - q: _joinFilters(filters), + body, }); - return res.body.hits.total.value; } export async function getAgent(esClient: ElasticsearchClient, agentId: string) { - const agentHit = await esClient.get>({ + const agentHit = await esClient.get>({ index: AGENTS_INDEX, id: agentId, }); @@ -141,27 +161,31 @@ export async function getAgent(esClient: ElasticsearchClient, agentId: string) { return agent; } -export async function getAgents(soClient: SavedObjectsClientContract, agentIds: string[]) { - const agentSOs = await soClient.bulkGet( - agentIds.map((agentId) => ({ - id: agentId, - type: AGENT_SAVED_OBJECT_TYPE, - })) - ); - const agents = agentSOs.saved_objects.map(savedObjectToAgent); +export async function getAgents( + esClient: ElasticsearchClient, + agentIds: string[] +): Promise { + const body = { docs: agentIds.map((_id) => ({ _id })) }; + + const res = await esClient.mget({ + body, + index: AGENTS_INDEX, + }); + + const agents = res.body.docs.map(searchHitToAgent); return agents; } export async function getAgentByAccessAPIKeyId( - soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, accessAPIKeyId: string ): Promise { - const response = await soClient.find({ - type: AGENT_SAVED_OBJECT_TYPE, - searchFields: ['access_api_key_id'], - search: escapeSearchQueryPhrase(accessAPIKeyId), + const res = await esClient.search>({ + index: AGENTS_INDEX, + q: `access_api_key_id:${escapeSearchQueryPhrase(accessAPIKeyId)}`, }); - const [agent] = response.saved_objects.map(savedObjectToAgent); + + const [agent] = res.body.hits.hits.map(searchHitToAgent); if (!agent) { throw Boom.notFound('Agent not found'); @@ -177,15 +201,49 @@ export async function getAgentByAccessAPIKeyId( } export async function updateAgent( - soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, agentId: string, - data: { - userProvidedMetatada: any; - } + data: Partial ) { - await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, { - user_provided_metadata: data.userProvidedMetatada, + await esClient.update({ + id: agentId, + index: AGENTS_INDEX, + body: { doc: agentSOAttributesToFleetServerAgentDoc(data) }, + refresh: 'wait_for', + }); +} + +export async function bulkUpdateAgents( + esClient: ElasticsearchClient, + updateData: Array<{ + agentId: string; + data: Partial; + }> +) { + const body = updateData.flatMap(({ agentId, data }) => [ + { + update: { + _id: agentId, + }, + }, + { + doc: { ...agentSOAttributesToFleetServerAgentDoc(data) }, + }, + ]); + + const res = await esClient.bulk({ + body, + index: AGENTS_INDEX, + refresh: 'wait_for', }); + + return { + items: res.body.items.map((item: { update: { _id: string; error?: Error } }) => ({ + id: item.update._id, + success: !item.update.error, + error: item.update.error, + })), + }; } export async function deleteAgent(esClient: ElasticsearchClient, agentId: string) { @@ -193,7 +251,7 @@ export async function deleteAgent(esClient: ElasticsearchClient, agentId: string id: agentId, index: AGENT_SAVED_OBJECT_TYPE, body: { - active: false, + doc: { active: false }, }, }); } diff --git a/x-pack/plugins/fleet/server/services/agents/crud_so.ts b/x-pack/plugins/fleet/server/services/agents/crud_so.ts index 11991a971829a0..c3ceb4b7502e26 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud_so.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud_so.ts @@ -6,7 +6,7 @@ */ import Boom from '@hapi/boom'; -import { SavedObjectsClientContract } from 'src/core/server'; +import { SavedObjectsBulkUpdateObject, SavedObjectsClientContract } from 'src/core/server'; import { isAgentUpgradeable } from '../../../common'; import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; @@ -197,13 +197,35 @@ export async function getAgentByAccessAPIKeyId( export async function updateAgent( soClient: SavedObjectsClientContract, agentId: string, - data: { - userProvidedMetatada: any; - } + data: Partial ) { - await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, { - user_provided_metadata: data.userProvidedMetatada, - }); + await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, data); +} + +export async function bulkUpdateAgents( + soClient: SavedObjectsClientContract, + updateData: Array<{ + agentId: string; + data: Partial; + }> +) { + const updates: Array> = updateData.map( + ({ agentId, data }) => ({ + type: AGENT_SAVED_OBJECT_TYPE, + id: agentId, + attributes: data, + }) + ); + + const res = await soClient.bulkUpdate(updates); + + return { + items: res.saved_objects.map((so) => ({ + id: so.id, + success: !so.error, + error: so.error, + })), + }; } export async function deleteAgent(soClient: SavedObjectsClientContract, agentId: string) { diff --git a/x-pack/plugins/fleet/server/services/agents/enroll.ts b/x-pack/plugins/fleet/server/services/agents/enroll.ts index b8be02af101b42..c984a84ceea014 100644 --- a/x-pack/plugins/fleet/server/services/agents/enroll.ts +++ b/x-pack/plugins/fleet/server/services/agents/enroll.ts @@ -6,14 +6,15 @@ */ import Boom from '@hapi/boom'; +import uuid from 'uuid/v4'; import semverParse from 'semver/functions/parse'; import semverDiff from 'semver/functions/diff'; import semverLte from 'semver/functions/lte'; import { SavedObjectsClientContract } from 'src/core/server'; -import { AgentType, Agent, AgentSOAttributes } from '../../types'; +import { AgentType, Agent, AgentSOAttributes, FleetServerAgent } from '../../types'; import { savedObjectToAgent } from './saved_objects'; -import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; +import { AGENT_SAVED_OBJECT_TYPE, AGENTS_INDEX } from '../../constants'; import * as APIKeyService from '../api_keys'; import { appContextService } from '../app_context'; @@ -26,6 +27,36 @@ export async function enroll( const agentVersion = metadata?.local?.elastic?.agent?.version; validateAgentVersion(agentVersion); + if (appContextService.getConfig()?.agents?.fleetServerEnabled) { + const esClient = appContextService.getInternalUserESClient(); + + const agentId = uuid(); + const accessAPIKey = await APIKeyService.generateAccessApiKey(soClient, agentId); + const fleetServerAgent: FleetServerAgent = { + active: true, + policy_id: agentPolicyId, + type, + enrolled_at: new Date().toISOString(), + user_provided_metadata: metadata?.userProvided ?? {}, + local_metadata: metadata?.local ?? {}, + access_api_key_id: accessAPIKey.id, + }; + await esClient.create({ + index: AGENTS_INDEX, + body: fleetServerAgent, + id: agentId, + refresh: 'wait_for', + }); + + return { + id: agentId, + current_error_events: [], + packages: [], + ...fleetServerAgent, + access_api_key: accessAPIKey.key, + } as Agent; + } + const agentData: AgentSOAttributes = { active: true, policy_id: agentPolicyId, diff --git a/x-pack/plugins/fleet/server/services/agents/helpers.ts b/x-pack/plugins/fleet/server/services/agents/helpers.ts index 1000a1b1459328..90d85e98ecd679 100644 --- a/x-pack/plugins/fleet/server/services/agents/helpers.ts +++ b/x-pack/plugins/fleet/server/services/agents/helpers.ts @@ -6,17 +6,30 @@ */ import { ESSearchHit } from '../../../../../typings/elasticsearch'; -import { Agent, AgentSOAttributes } from '../../types'; +import { Agent, AgentSOAttributes, FleetServerAgent } from '../../types'; -export function searchHitToAgent(hit: ESSearchHit): Agent { +export function searchHitToAgent(hit: ESSearchHit): Agent { return { id: hit._id, ...hit._source, - current_error_events: hit._source.current_error_events - ? JSON.parse(hit._source.current_error_events) - : [], + policy_revision: hit._source.policy_revision_idx, + current_error_events: [], access_api_key: undefined, status: undefined, packages: hit._source.packages ?? [], }; } + +export function agentSOAttributesToFleetServerAgentDoc( + data: Partial +): Partial> { + const { policy_revision: policyRevison, ...rest } = data; + + const doc: Partial> = { ...rest }; + + if (policyRevison !== undefined) { + doc.policy_revision_idx = policyRevison; + } + + return doc; +} diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.test.ts b/x-pack/plugins/fleet/server/services/agents/reassign.test.ts new file mode 100644 index 00000000000000..466870bead71ce --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agents/reassign.test.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks'; +import type { SavedObject } from 'kibana/server'; +import type { Agent, AgentPolicy } from '../../types'; +import { AgentReassignmentError } from '../../errors'; +import { reassignAgent, reassignAgents } from './reassign'; + +const agentInManagedSO = { + id: 'agent-in-managed-policy', + attributes: { policy_id: 'managed-agent-policy' }, +} as SavedObject; +const agentInManagedSO2 = { + id: 'agent-in-managed-policy2', + attributes: { policy_id: 'managed-agent-policy' }, +} as SavedObject; +const agentInUnmanagedSO = { + id: 'agent-in-unmanaged-policy', + attributes: { policy_id: 'unmanaged-agent-policy' }, +} as SavedObject; +const agentInUnmanagedSO2 = { + id: 'agent-in-unmanaged-policy2', + attributes: { policy_id: 'unmanaged-agent-policy' }, +} as SavedObject; +const unmanagedAgentPolicySO = { + id: 'unmanaged-agent-policy', + attributes: { is_managed: false }, +} as SavedObject; +const managedAgentPolicySO = { + id: 'managed-agent-policy', + attributes: { is_managed: true }, +} as SavedObject; + +describe('reassignAgent (singular)', () => { + it('can reassign from unmanaged policy to unmanaged', async () => { + const soClient = createClientMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await reassignAgent(soClient, esClient, agentInUnmanagedSO.id, agentInUnmanagedSO2.id); + + // calls ES update with correct values + expect(soClient.update).toBeCalledTimes(1); + const calledWith = soClient.update.mock.calls[0]; + expect(calledWith[1]).toBe(agentInUnmanagedSO.id); + expect(calledWith[2]).toHaveProperty('policy_id', agentInUnmanagedSO2.id); + }); + + it('cannot reassign from unmanaged policy to managed', async () => { + const soClient = createClientMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await expect( + reassignAgent( + soClient, + esClient, + agentInUnmanagedSO.id, + agentInManagedSO.attributes.policy_id! + ) + ).rejects.toThrowError(AgentReassignmentError); + + // does not call ES update + expect(soClient.update).toBeCalledTimes(0); + }); + + it('cannot reassign from managed policy', async () => { + const soClient = createClientMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await expect( + reassignAgent(soClient, esClient, agentInManagedSO.id, agentInManagedSO2.id) + ).rejects.toThrowError(AgentReassignmentError); + // does not call ES update + expect(soClient.update).toBeCalledTimes(0); + + await expect( + reassignAgent(soClient, esClient, agentInManagedSO.id, agentInUnmanagedSO.id) + ).rejects.toThrowError(AgentReassignmentError); + // does not call ES update + expect(soClient.update).toBeCalledTimes(0); + }); +}); + +describe('reassignAgents (plural)', () => { + it('agents in managed policies are not updated', async () => { + const soClient = createClientMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const idsToReassign = [agentInUnmanagedSO.id, agentInManagedSO.id, agentInUnmanagedSO.id]; + await reassignAgents(soClient, esClient, { agentIds: idsToReassign }, agentInUnmanagedSO.id); + + // calls ES update with correct values + const calledWith = soClient.bulkUpdate.mock.calls[0][0]; + const expectedResults = [agentInUnmanagedSO.id, agentInUnmanagedSO.id]; + expect(calledWith.length).toBe(expectedResults.length); // only 2 are unmanaged + expect(calledWith.map(({ id }) => id)).toEqual(expectedResults); + }); +}); + +function createClientMock() { + const soClientMock = savedObjectsClientMock.create(); + + // need to mock .create & bulkCreate due to (bulk)createAgentAction(s) in reassignAgent(s) + soClientMock.create.mockResolvedValue(agentInUnmanagedSO); + soClientMock.bulkCreate.mockImplementation(async ([{ type, attributes }]) => { + return { + saved_objects: [await soClientMock.create(type, attributes)], + }; + }); + soClientMock.bulkUpdate.mockResolvedValue({ + saved_objects: [], + }); + + soClientMock.get.mockImplementation(async (_, id) => { + switch (id) { + case unmanagedAgentPolicySO.id: + return unmanagedAgentPolicySO; + case managedAgentPolicySO.id: + return managedAgentPolicySO; + case agentInManagedSO.id: + return agentInManagedSO; + case agentInUnmanagedSO.id: + default: + return agentInUnmanagedSO; + } + }); + + soClientMock.bulkGet.mockImplementation(async (options) => { + return { + saved_objects: await Promise.all(options!.map(({ type, id }) => soClientMock.get(type, id))), + }; + }); + + return soClientMock; +} diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.ts b/x-pack/plugins/fleet/server/services/agents/reassign.ts index fbd91c05dfb4ac..62d59aada3b7ba 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign.ts @@ -5,12 +5,18 @@ * 2.0. */ -import { SavedObjectsClientContract, ElasticsearchClient } from 'kibana/server'; +import type { SavedObjectsClientContract, ElasticsearchClient } from 'kibana/server'; import Boom from '@hapi/boom'; -import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; -import { AgentSOAttributes } from '../../types'; import { agentPolicyService } from '../agent_policy'; -import { getAgents, listAllAgents } from './crud'; +import { + getAgents, + getAgentPolicyForAgent, + listAllAgents, + updateAgent, + bulkUpdateAgents, +} from './crud'; +import { AgentReassignmentError } from '../../errors'; + import { createAgentAction, bulkCreateAgentActions } from './actions'; export async function reassignAgent( @@ -19,23 +25,48 @@ export async function reassignAgent( agentId: string, newAgentPolicyId: string ) { - const agentPolicy = await agentPolicyService.get(soClient, newAgentPolicyId); - if (!agentPolicy) { + const newAgentPolicy = await agentPolicyService.get(soClient, newAgentPolicyId); + if (!newAgentPolicy) { throw Boom.notFound(`Agent policy not found: ${newAgentPolicyId}`); } - await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, { + await reassignAgentIsAllowed(soClient, esClient, agentId, newAgentPolicyId); + + await updateAgent(soClient, esClient, agentId, { policy_id: newAgentPolicyId, policy_revision: null, }); - await createAgentAction(soClient, { + await createAgentAction(soClient, esClient, { agent_id: agentId, created_at: new Date().toISOString(), type: 'INTERNAL_POLICY_REASSIGN', }); } +export async function reassignAgentIsAllowed( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + agentId: string, + newAgentPolicyId: string +) { + const agentPolicy = await getAgentPolicyForAgent(soClient, esClient, agentId); + if (agentPolicy?.is_managed) { + throw new AgentReassignmentError( + `Cannot reassign an agent from managed agent policy ${agentPolicy.id}` + ); + } + + const newAgentPolicy = await agentPolicyService.get(soClient, newAgentPolicyId); + if (newAgentPolicy?.is_managed) { + throw new AgentReassignmentError( + `Cannot reassign an agent to managed agent policy ${newAgentPolicy.id}` + ); + } + + return true; +} + export async function reassignAgents( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, @@ -47,7 +78,7 @@ export async function reassignAgents( kuery: string; }, newAgentPolicyId: string -) { +): Promise<{ items: Array<{ id: string; sucess: boolean; error?: Error }> }> { const agentPolicy = await agentPolicyService.get(soClient, newAgentPolicyId); if (!agentPolicy) { throw Boom.notFound(`Agent policy not found: ${newAgentPolicyId}`); @@ -56,29 +87,39 @@ export async function reassignAgents( // Filter to agents that do not already use the new agent policy ID const agents = 'agentIds' in options - ? await getAgents(soClient, options.agentIds) + ? await getAgents(soClient, esClient, options.agentIds) : ( await listAllAgents(soClient, esClient, { kuery: options.kuery, showInactive: false, }) ).agents; - const agentsToUpdate = agents.filter((agent) => agent.policy_id !== newAgentPolicyId); + // And which are allowed to unenroll + const settled = await Promise.allSettled( + agents.map((agent) => + reassignAgentIsAllowed(soClient, esClient, agent.id, newAgentPolicyId).then((_) => agent) + ) + ); + const agentsToUpdate = agents.filter( + (agent, index) => settled[index].status === 'fulfilled' && agent.policy_id !== newAgentPolicyId + ); - // Update the necessary agents - const res = await soClient.bulkUpdate( + const res = await bulkUpdateAgents( + soClient, + esClient, agentsToUpdate.map((agent) => ({ - type: AGENT_SAVED_OBJECT_TYPE, - id: agent.id, - attributes: { + agentId: agent.id, + data: { policy_id: newAgentPolicyId, policy_revision: null, }, })) ); + const now = new Date().toISOString(); await bulkCreateAgentActions( soClient, + esClient, agentsToUpdate.map((agent) => ({ agent_id: agent.id, created_at: now, diff --git a/x-pack/plugins/fleet/server/services/agents/status.ts b/x-pack/plugins/fleet/server/services/agents/status.ts index c75b91b3fbd11c..42d3aff2b0d702 100644 --- a/x-pack/plugins/fleet/server/services/agents/status.ts +++ b/x-pack/plugins/fleet/server/services/agents/status.ts @@ -14,6 +14,8 @@ import { AgentStatus } from '../../types'; import { AgentStatusKueryHelper } from '../../../common/services'; import { esKuery, KueryNode } from '../../../../../../src/plugins/data/server'; import { normalizeKuery } from '../saved_object'; +import { appContextService } from '../app_context'; +import { removeSOAttributes } from './crud_fleet_server'; export async function getAgentStatusById( soClient: SavedObjectsClientContract, @@ -27,6 +29,8 @@ export async function getAgentStatusById( export const getAgentStatus = AgentStatusKueryHelper.getAgentStatus; function joinKuerys(...kuerys: Array) { + const isFleetServerEnabled = appContextService.getConfig()?.agents?.fleetServerEnabled; + return kuerys .filter((kuery) => kuery !== undefined) .reduce((acc: KueryNode | undefined, kuery: string | undefined): KueryNode | undefined => { @@ -34,7 +38,9 @@ function joinKuerys(...kuerys: Array) { return acc; } const normalizedKuery: KueryNode = esKuery.fromKueryExpression( - normalizeKuery(AGENT_SAVED_OBJECT_TYPE, kuery || '') + isFleetServerEnabled + ? removeSOAttributes(kuery || '') + : normalizeKuery(AGENT_SAVED_OBJECT_TYPE, kuery || '') ); if (!acc) { diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts new file mode 100644 index 00000000000000..cd46cff0f8a174 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts @@ -0,0 +1,129 @@ +/* + * 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 { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks'; +import type { SavedObject } from 'kibana/server'; +import type { Agent, AgentPolicy } from '../../types'; +import { AgentUnenrollmentError } from '../../errors'; +import { unenrollAgent, unenrollAgents } from './unenroll'; + +const agentInManagedSO = { + id: 'agent-in-managed-policy', + attributes: { policy_id: 'managed-agent-policy' }, +} as SavedObject; +const agentInUnmanagedSO = { + id: 'agent-in-unmanaged-policy', + attributes: { policy_id: 'unmanaged-agent-policy' }, +} as SavedObject; +const agentInUnmanagedSO2 = { + id: 'agent-in-unmanaged-policy2', + attributes: { policy_id: 'unmanaged-agent-policy' }, +} as SavedObject; +const unmanagedAgentPolicySO = { + id: 'unmanaged-agent-policy', + attributes: { is_managed: false }, +} as SavedObject; +const managedAgentPolicySO = { + id: 'managed-agent-policy', + attributes: { is_managed: true }, +} as SavedObject; + +describe('unenrollAgent (singular)', () => { + it('can unenroll from unmanaged policy', async () => { + const soClient = createClientMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await unenrollAgent(soClient, esClient, agentInUnmanagedSO.id); + + // calls ES update with correct values + expect(soClient.update).toBeCalledTimes(1); + const calledWith = soClient.update.mock.calls[0]; + expect(calledWith[1]).toBe(agentInUnmanagedSO.id); + expect(calledWith[2]).toHaveProperty('unenrollment_started_at'); + }); + + it('cannot unenroll from managed policy', async () => { + const soClient = createClientMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await expect(unenrollAgent(soClient, esClient, agentInManagedSO.id)).rejects.toThrowError( + AgentUnenrollmentError + ); + // does not call ES update + expect(soClient.update).toBeCalledTimes(0); + }); +}); + +describe('unenrollAgents (plural)', () => { + it('can unenroll from an unmanaged policy', async () => { + const soClient = createClientMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const idsToUnenroll = [agentInUnmanagedSO.id, agentInUnmanagedSO2.id]; + await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll }); + + // calls ES update with correct values + const calledWith = soClient.bulkUpdate.mock.calls[0][0]; + expect(calledWith.length).toBe(idsToUnenroll.length); + expect(calledWith.map(({ id }) => id)).toEqual(idsToUnenroll); + for (const params of calledWith) { + expect(params.attributes).toHaveProperty('unenrollment_started_at'); + } + }); + it('cannot unenroll from a managed policy', async () => { + const soClient = createClientMock(); + + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const idsToUnenroll = [agentInUnmanagedSO.id, agentInManagedSO.id, agentInUnmanagedSO2.id]; + await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll }); + + // calls ES update with correct values + const calledWith = soClient.bulkUpdate.mock.calls[0][0]; + const onlyUnmanaged = [agentInUnmanagedSO.id, agentInUnmanagedSO2.id]; + expect(calledWith.length).toBe(onlyUnmanaged.length); + expect(calledWith.map(({ id }) => id)).toEqual(onlyUnmanaged); + for (const params of calledWith) { + expect(params.attributes).toHaveProperty('unenrollment_started_at'); + } + }); +}); + +function createClientMock() { + const soClientMock = savedObjectsClientMock.create(); + + // need to mock .create & bulkCreate due to (bulk)createAgentAction(s) in unenrollAgent(s) + soClientMock.create.mockResolvedValue(agentInUnmanagedSO); + soClientMock.bulkCreate.mockImplementation(async ([{ type, attributes }]) => { + return { + saved_objects: [await soClientMock.create(type, attributes)], + }; + }); + soClientMock.bulkUpdate.mockResolvedValue({ + saved_objects: [], + }); + + soClientMock.get.mockImplementation(async (_, id) => { + switch (id) { + case unmanagedAgentPolicySO.id: + return unmanagedAgentPolicySO; + case managedAgentPolicySO.id: + return managedAgentPolicySO; + case agentInManagedSO.id: + return agentInManagedSO; + case agentInUnmanagedSO2.id: + return agentInUnmanagedSO2; + case agentInUnmanagedSO.id: + default: + return agentInUnmanagedSO; + } + }); + + soClientMock.bulkGet.mockImplementation(async (options) => { + return { + saved_objects: await Promise.all(options!.map(({ type, id }) => soClientMock.get(type, id))), + }; + }); + + return soClientMock; +} diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.ts index a20b742d1425e6..72d551a1229801 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.ts @@ -6,21 +6,47 @@ */ import { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; -import { AgentSOAttributes } from '../../types'; -import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; -import { getAgent } from './crud'; import * as APIKeyService from '../api_keys'; import { createAgentAction, bulkCreateAgentActions } from './actions'; -import { getAgents, listAllAgents } from './crud'; +import { + getAgent, + updateAgent, + getAgentPolicyForAgent, + getAgents, + listAllAgents, + bulkUpdateAgents, +} from './crud'; +import { AgentUnenrollmentError } from '../../errors'; + +async function unenrollAgentIsAllowed( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + agentId: string +) { + const agentPolicy = await getAgentPolicyForAgent(soClient, esClient, agentId); + if (agentPolicy?.is_managed) { + throw new AgentUnenrollmentError( + `Cannot unenroll ${agentId} from a managed agent policy ${agentPolicy.id}` + ); + } + + return true; +} + +export async function unenrollAgent( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + agentId: string +) { + await unenrollAgentIsAllowed(soClient, esClient, agentId); -export async function unenrollAgent(soClient: SavedObjectsClientContract, agentId: string) { const now = new Date().toISOString(); - await createAgentAction(soClient, { + await createAgentAction(soClient, esClient, { agent_id: agentId, created_at: now, type: 'UNENROLL', }); - await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, { + await updateAgent(soClient, esClient, agentId, { unenrollment_started_at: now, }); } @@ -36,24 +62,34 @@ export async function unenrollAgents( kuery: string; } ) { - // Filter to agents that do not already unenrolled, or unenrolling const agents = 'agentIds' in options - ? await getAgents(soClient, options.agentIds) + ? await getAgents(soClient, esClient, options.agentIds) : ( await listAllAgents(soClient, esClient, { kuery: options.kuery, showInactive: false, }) ).agents; - const agentsToUpdate = agents.filter( + + // Filter to agents that are not already unenrolled, or unenrolling + const agentsEnrolled = agents.filter( (agent) => !agent.unenrollment_started_at && !agent.unenrolled_at ); + // And which are allowed to unenroll + const settled = await Promise.allSettled( + agentsEnrolled.map((agent) => + unenrollAgentIsAllowed(soClient, esClient, agent.id).then((_) => agent) + ) + ); + const agentsToUpdate = agentsEnrolled.filter((_, index) => settled[index].status === 'fulfilled'); + const now = new Date().toISOString(); // Create unenroll action for each agent await bulkCreateAgentActions( soClient, + esClient, agentsToUpdate.map((agent) => ({ agent_id: agent.id, created_at: now, @@ -62,11 +98,12 @@ export async function unenrollAgents( ); // Update the necessary agents - return await soClient.bulkUpdate( + return bulkUpdateAgents( + soClient, + esClient, agentsToUpdate.map((agent) => ({ - type: AGENT_SAVED_OBJECT_TYPE, - id: agent.id, - attributes: { + agentId: agent.id, + data: { unenrollment_started_at: now, }, })) @@ -89,7 +126,7 @@ export async function forceUnenrollAgent( : undefined, ]); - await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, { + await updateAgent(soClient, esClient, agentId, { active: false, unenrolled_at: new Date().toISOString(), }); @@ -109,7 +146,7 @@ export async function forceUnenrollAgents( // Filter to agents that are not already unenrolled const agents = 'agentIds' in options - ? await getAgents(soClient, options.agentIds) + ? await getAgents(soClient, esClient, options.agentIds) : ( await listAllAgents(soClient, esClient, { kuery: options.kuery, @@ -134,13 +171,13 @@ export async function forceUnenrollAgents( if (apiKeys.length) { APIKeyService.invalidateAPIKeys(soClient, apiKeys); } - // Update the necessary agents - return await soClient.bulkUpdate( + return bulkUpdateAgents( + soClient, + esClient, agentsToUpdate.map((agent) => ({ - type: AGENT_SAVED_OBJECT_TYPE, - id: agent.id, - attributes: { + agentId: agent.id, + data: { active: false, unenrolled_at: now, }, diff --git a/x-pack/plugins/fleet/server/services/agents/update.ts b/x-pack/plugins/fleet/server/services/agents/update.ts index f6b4b440047613..21087be392bcd0 100644 --- a/x-pack/plugins/fleet/server/services/agents/update.ts +++ b/x-pack/plugins/fleet/server/services/agents/update.ts @@ -29,7 +29,7 @@ export async function unenrollForAgentPolicyId( hasMore = false; } for (const agent of agents) { - await unenrollAgent(soClient, agent.id); + await unenrollAgent(soClient, esClient, agent.id); } } } diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 7475ad49681427..5105e145309827 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -6,20 +6,22 @@ */ import { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; -import { AgentSOAttributes, AgentAction, AgentActionSOAttributes } from '../../types'; -import { AGENT_ACTION_SAVED_OBJECT_TYPE, AGENT_SAVED_OBJECT_TYPE } from '../../constants'; +import { AgentAction, AgentActionSOAttributes } from '../../types'; +import { AGENT_ACTION_SAVED_OBJECT_TYPE } from '../../constants'; import { bulkCreateAgentActions, createAgentAction } from './actions'; -import { getAgents, listAllAgents } from './crud'; +import { getAgents, listAllAgents, updateAgent, bulkUpdateAgents } from './crud'; import { isAgentUpgradeable } from '../../../common/services'; import { appContextService } from '../app_context'; export async function sendUpgradeAgentAction({ soClient, + esClient, agentId, version, sourceUri, }: { soClient: SavedObjectsClientContract; + esClient: ElasticsearchClient; agentId: string; version: string; sourceUri: string | undefined; @@ -29,21 +31,22 @@ export async function sendUpgradeAgentAction({ version, source_uri: sourceUri, }; - await createAgentAction(soClient, { + await createAgentAction(soClient, esClient, { agent_id: agentId, created_at: now, data, ack_data: data, type: 'UPGRADE', }); - await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, { - upgraded_at: undefined, + await updateAgent(soClient, esClient, agentId, { + upgraded_at: null, upgrade_started_at: now, }); } export async function ackAgentUpgraded( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, agentAction: AgentAction ) { const { @@ -52,9 +55,9 @@ export async function ackAgentUpgraded( if (!ackData) throw new Error('data missing from UPGRADE action'); const { version } = JSON.parse(ackData); if (!version) throw new Error('version missing from UPGRADE action'); - await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentAction.agent_id, { + await updateAgent(soClient, esClient, agentAction.agent_id, { upgraded_at: new Date().toISOString(), - upgrade_started_at: undefined, + upgrade_started_at: null, }); } @@ -79,7 +82,7 @@ export async function sendUpgradeAgentsActions( // Filter out agents currently unenrolling, agents unenrolled, and agents not upgradeable const agents = 'agentIds' in options - ? await getAgents(soClient, options.agentIds) + ? await getAgents(soClient, esClient, options.agentIds) : ( await listAllAgents(soClient, esClient, { kuery: options.kuery, @@ -97,6 +100,7 @@ export async function sendUpgradeAgentsActions( // Create upgrade action for each agent await bulkCreateAgentActions( soClient, + esClient, agentsToUpdate.map((agent) => ({ agent_id: agent.id, created_at: now, @@ -106,12 +110,13 @@ export async function sendUpgradeAgentsActions( })) ); - return await soClient.bulkUpdate( + return await bulkUpdateAgents( + soClient, + esClient, agentsToUpdate.map((agent) => ({ - type: AGENT_SAVED_OBJECT_TYPE, - id: agent.id, - attributes: { - upgraded_at: undefined, + agentId: agent.id, + data: { + upgraded_at: null, upgrade_started_at: now, }, })) diff --git a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts index 97d48702cf4c6e..85812fee3885c8 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts @@ -79,3 +79,15 @@ export async function generateEnrollmentAPIKey( return enrollmentApiKeyServiceSO.generateEnrollmentAPIKey(soClient, data); } } + +export async function getEnrollmentAPIKeyById( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + apiKeyId: string +) { + if (appContextService.getConfig()?.agents?.fleetServerEnabled === true) { + return enrollmentApiKeyServiceFleetServer.getEnrollmentAPIKeyById(esClient, apiKeyId); + } else { + return enrollmentApiKeyServiceSO.getEnrollmentAPIKeyById(soClient, apiKeyId); + } +} diff --git a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key_fleet_server.ts b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key_fleet_server.ts index d42cb19a340bdd..f5d0015297daa0 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key_fleet_server.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key_fleet_server.ts @@ -7,41 +7,15 @@ import uuid from 'uuid'; import Boom from '@hapi/boom'; +import { GetResponse } from 'elasticsearch'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { SavedObjectsClientContract, ElasticsearchClient } from 'src/core/server'; +import { ESSearchResponse as SearchResponse } from '../../../../../typings/elasticsearch'; import { EnrollmentAPIKey, FleetServerEnrollmentAPIKey } from '../../types'; import { ENROLLMENT_API_KEYS_INDEX } from '../../constants'; import { createAPIKey, invalidateAPIKeys } from './security'; import { agentPolicyService } from '../agent_policy'; - -// TODO Move these types to another file -interface SearchResponse { - took: number; - timed_out: boolean; - _scroll_id?: string; - hits: { - total: { - value: number; - relation: string; - }; - max_score: number; - hits: Array<{ - _index: string; - _type: string; - _id: string; - _score: number; - _source: T; - _version?: number; - fields?: any; - highlight?: any; - inner_hits?: any; - matched_queries?: string[]; - sort?: string[]; - }>; - }; -} - -type SearchHit = SearchResponse['hits']['hits'][0]; +import { escapeSearchQueryPhrase } from '../saved_object'; export async function listEnrollmentApiKeys( esClient: ElasticsearchClient, @@ -54,7 +28,7 @@ export async function listEnrollmentApiKeys( ): Promise<{ items: EnrollmentAPIKey[]; total: any; page: any; perPage: any }> { const { page = 1, perPage = 20, kuery } = options; - const res = await esClient.search>({ + const res = await esClient.search>({ index: ENROLLMENT_API_KEYS_INDEX, from: (page - 1) * perPage, size: perPage, @@ -78,7 +52,7 @@ export async function getEnrollmentAPIKey( id: string ): Promise { try { - const res = await esClient.get>({ + const res = await esClient.get>({ index: ENROLLMENT_API_KEYS_INDEX, id, }); @@ -185,6 +159,21 @@ export async function generateEnrollmentAPIKey( }; } +export async function getEnrollmentAPIKeyById(esClient: ElasticsearchClient, apiKeyId: string) { + const res = await esClient.search>({ + index: ENROLLMENT_API_KEYS_INDEX, + q: `api_key_id:${escapeSearchQueryPhrase(apiKeyId)}`, + }); + + const [enrollmentAPIKey] = res.body.hits.hits.map(esDocToEnrollmentApiKey); + + if (enrollmentAPIKey?.api_key_id !== apiKeyId) { + throw new Error('find enrollmentKeyById returned an incorrect key'); + } + + return enrollmentAPIKey; +} + async function validateAgentPolicyId(soClient: SavedObjectsClientContract, agentPolicyId: string) { try { await agentPolicyService.get(soClient, agentPolicyId); @@ -196,7 +185,10 @@ async function validateAgentPolicyId(soClient: SavedObjectsClientContract, agent } } -function esDocToEnrollmentApiKey(doc: SearchHit): EnrollmentAPIKey { +function esDocToEnrollmentApiKey(doc: { + _id: string; + _source: FleetServerEnrollmentAPIKey; +}): EnrollmentAPIKey { return { id: doc._id, ...doc._source, diff --git a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key_so.ts b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key_so.ts index b3beab546c811b..014bc58e747ea4 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key_so.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key_so.ts @@ -13,7 +13,7 @@ import { ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE } from '../../constants'; import { createAPIKey, invalidateAPIKeys } from './security'; import { agentPolicyService } from '../agent_policy'; import { appContextService } from '../app_context'; -import { normalizeKuery } from '../saved_object'; +import { normalizeKuery, escapeSearchQueryPhrase } from '../saved_object'; export async function listEnrollmentApiKeys( soClient: SavedObjectsClientContract, @@ -159,6 +159,25 @@ async function validateAgentPolicyId(soClient: SavedObjectsClientContract, agent } } +export async function getEnrollmentAPIKeyById( + soClient: SavedObjectsClientContract, + apiKeyId: string +) { + const [enrollmentAPIKey] = ( + await soClient.find({ + type: ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, + searchFields: ['api_key_id'], + search: escapeSearchQueryPhrase(apiKeyId), + }) + ).saved_objects.map(savedObjectToEnrollmentApiKey); + + if (enrollmentAPIKey?.api_key_id !== apiKeyId) { + throw new Error('find enrollmentKeyById returned an incorrect key'); + } + + return enrollmentAPIKey; +} + function savedObjectToEnrollmentApiKey({ error, attributes, diff --git a/x-pack/plugins/fleet/server/services/api_keys/index.ts b/x-pack/plugins/fleet/server/services/api_keys/index.ts index 5cdadeb0c82d84..65051163c78c3a 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/index.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/index.ts @@ -5,11 +5,8 @@ * 2.0. */ -import { SavedObjectsClientContract, SavedObject, KibanaRequest } from 'src/core/server'; -import { ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE } from '../../constants'; -import { EnrollmentAPIKeySOAttributes, EnrollmentAPIKey } from '../../types'; +import { SavedObjectsClientContract, KibanaRequest } from 'src/core/server'; import { createAPIKey } from './security'; -import { escapeSearchQueryPhrase } from '../saved_object'; export { invalidateAPIKeys } from './security'; export * from './enrollment_api_key'; @@ -70,25 +67,6 @@ export async function generateAccessApiKey(soClient: SavedObjectsClientContract, return { id: key.id, key: Buffer.from(`${key.id}:${key.api_key}`).toString('base64') }; } -export async function getEnrollmentAPIKeyById( - soClient: SavedObjectsClientContract, - apiKeyId: string -) { - const [enrollmentAPIKey] = ( - await soClient.find({ - type: ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, - searchFields: ['api_key_id'], - search: escapeSearchQueryPhrase(apiKeyId), - }) - ).saved_objects.map(_savedObjectToEnrollmentApiKey); - - if (enrollmentAPIKey?.api_key_id !== apiKeyId) { - throw new Error('find enrollmentKeyById returned an incorrect key'); - } - - return enrollmentAPIKey; -} - export function parseApiKeyFromHeaders(headers: KibanaRequest['headers']) { const authorizationHeader = headers.authorization; @@ -117,18 +95,3 @@ export function parseApiKey(apiKey: string) { apiKeyId, }; } - -function _savedObjectToEnrollmentApiKey({ - error, - attributes, - id, -}: SavedObject): EnrollmentAPIKey { - if (error) { - throw new Error(error.message); - } - - return { - id, - ...attributes, - }; -} diff --git a/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts b/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts index 3e1d3d57bbf71e..7ab904b2f15e13 100644 --- a/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts @@ -22,10 +22,23 @@ password: {{password}} {{#if password}} hidden_password: {{password}} {{/if}} +{{#if optional_field}} +optional_field: {{optional_field}} +{{/if}} +foo: {{bar}} +some_text_field: {{should_be_text}} +multi_text_field: +{{#each multi_text}} + - {{this}} +{{/each}} `; const vars = { paths: { value: ['/usr/local/var/log/nginx/access.log'] }, password: { type: 'password', value: '' }, + optional_field: { type: 'text', value: undefined }, + bar: { type: 'text', value: 'bar' }, + should_be_text: { type: 'text', value: '1234' }, + multi_text: { type: 'text', value: ['1234', 'foo', 'bar'] }, }; const output = compileTemplate(vars, streamTemplate); @@ -35,6 +48,9 @@ hidden_password: {{password}} exclude_files: ['.gz$'], processors: [{ add_locale: null }], password: '', + foo: 'bar', + some_text_field: '1234', + multi_text_field: ['1234', 'foo', 'bar'], }); }); diff --git a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts index 6b1d84ea28b0a0..4f39da5b0b70d7 100644 --- a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts +++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts @@ -58,6 +58,10 @@ function replaceVariablesInYaml(yamlVariables: { [k: string]: any }, yaml: any) return yaml; } +const maybeEscapeNumericString = (value: string) => { + return value.length && !isNaN(+value) ? `"${value}"` : value; +}; + function buildTemplateVariables(variables: PackagePolicyConfigRecord, templateStr: string) { const yamlValues: { [k: string]: any } = {}; const vars = Object.entries(variables).reduce((acc, [key, recordEntry]) => { @@ -84,6 +88,14 @@ function buildTemplateVariables(variables: PackagePolicyConfigRecord, templateSt const yamlKeyPlaceholder = `##${key}##`; varPart[lastKeyPart] = `"${yamlKeyPlaceholder}"`; yamlValues[yamlKeyPlaceholder] = recordEntry.value ? safeLoad(recordEntry.value) : null; + } else if (recordEntry.type && recordEntry.type === 'text' && recordEntry.value?.length) { + if (Array.isArray(recordEntry.value)) { + varPart[lastKeyPart] = recordEntry.value.map((value: string) => + maybeEscapeNumericString(value) + ); + } else { + varPart[lastKeyPart] = maybeEscapeNumericString(recordEntry.value); + } } else { varPart[lastKeyPart] = recordEntry.value; } diff --git a/x-pack/plugins/fleet/server/services/fleet_server_migration.ts b/x-pack/plugins/fleet/server/services/fleet_server_migration.ts index f982332886e943..170bec54983c0e 100644 --- a/x-pack/plugins/fleet/server/services/fleet_server_migration.ts +++ b/x-pack/plugins/fleet/server/services/fleet_server_migration.ts @@ -5,11 +5,18 @@ * 2.0. */ +import { isBoom } from '@hapi/boom'; import { KibanaRequest } from 'src/core/server'; import { ENROLLMENT_API_KEYS_INDEX, ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, + AGENT_POLICY_INDEX, + AGENTS_INDEX, FleetServerEnrollmentAPIKey, + AGENT_SAVED_OBJECT_TYPE, + AgentSOAttributes, + FleetServerAgent, + SO_SEARCH_LIMIT, FLEET_SERVER_PACKAGE, FLEET_SERVER_INDICES, } from '../../common'; @@ -17,6 +24,9 @@ import { listEnrollmentApiKeys, getEnrollmentAPIKey } from './api_keys/enrollmen import { appContextService } from './app_context'; import { getInstallation } from './epm/packages'; +import { isAgentsSetup } from './agents'; +import { agentPolicyService } from './agent_policy'; + export async function isFleetServerSetup() { const pkgInstall = await getInstallation({ savedObjectsClient: getInternalUserSOClient(), @@ -28,7 +38,6 @@ export async function isFleetServerSetup() { } const esClient = appContextService.getInternalUserESClient(); - const exists = await Promise.all( FLEET_SERVER_INDICES.map(async (index) => { const res = await esClient.indices.exists({ @@ -42,7 +51,11 @@ export async function isFleetServerSetup() { } export async function runFleetServerMigration() { - await migrateEnrollmentApiKeys(); + // If Agents are not setup skip as there is nothing to migrate + if (!(await isAgentsSetup(getInternalUserSOClient()))) { + return; + } + await Promise.all([migrateEnrollmentApiKeys(), migrateAgentPolicies(), migrateAgents()]); } function getInternalUserSOClient() { @@ -64,6 +77,65 @@ function getInternalUserSOClient() { return appContextService.getInternalUserSOClient(fakeRequest); } +async function migrateAgents() { + const esClient = appContextService.getInternalUserESClient(); + const soClient = getInternalUserSOClient(); + let hasMore = true; + while (hasMore) { + const res = await soClient.find({ + type: AGENT_SAVED_OBJECT_TYPE, + page: 1, + perPage: 100, + }); + + if (res.total === 0) { + hasMore = false; + } + for (const so of res.saved_objects) { + try { + const { + attributes, + } = await appContextService + .getEncryptedSavedObjects() + .getDecryptedAsInternalUser(AGENT_SAVED_OBJECT_TYPE, so.id); + + const body: FleetServerAgent = { + type: attributes.type, + active: attributes.active, + enrolled_at: attributes.enrolled_at, + unenrolled_at: attributes.unenrolled_at, + unenrollment_started_at: attributes.unenrollment_started_at, + upgraded_at: attributes.upgraded_at, + upgrade_started_at: attributes.upgrade_started_at, + access_api_key_id: attributes.access_api_key_id, + user_provided_metadata: attributes.user_provided_metadata, + local_metadata: attributes.local_metadata, + policy_id: attributes.policy_id, + policy_revision_idx: attributes.policy_revision || undefined, + last_checkin: attributes.last_checkin, + last_checkin_status: attributes.last_checkin_status, + default_api_key_id: attributes.default_api_key_id, + default_api_key: attributes.default_api_key, + packages: attributes.packages, + }; + await esClient.create({ + index: AGENTS_INDEX, + body, + id: so.id, + refresh: 'wait_for', + }); + + await soClient.delete(AGENT_SAVED_OBJECT_TYPE, so.id); + } catch (error) { + // swallow 404 error has multiple Kibana can run the migration at the same time + if (!is404Error(error)) { + throw error; + } + } + } + } +} + async function migrateEnrollmentApiKeys() { const esClient = appContextService.getInternalUserESClient(); const soClient = getInternalUserSOClient(); @@ -77,24 +149,61 @@ async function migrateEnrollmentApiKeys() { hasMore = false; } for (const item of res.items) { - const key = await getEnrollmentAPIKey(soClient, item.id); - - const body: FleetServerEnrollmentAPIKey = { - api_key: key.api_key, - api_key_id: key.api_key_id, - active: key.active, - created_at: key.created_at, - name: key.name, - policy_id: key.policy_id, - }; - await esClient.create({ - index: ENROLLMENT_API_KEYS_INDEX, - body, - id: key.id, - refresh: 'wait_for', - }); + try { + const key = await getEnrollmentAPIKey(soClient, item.id); + + const body: FleetServerEnrollmentAPIKey = { + api_key: key.api_key, + api_key_id: key.api_key_id, + active: key.active, + created_at: key.created_at, + name: key.name, + policy_id: key.policy_id, + }; + await esClient.create({ + index: ENROLLMENT_API_KEYS_INDEX, + body, + id: key.id, + refresh: 'wait_for', + }); - await soClient.delete(ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, key.id); + await soClient.delete(ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, key.id); + } catch (error) { + // swallow 404 error has multiple Kibana can run the migration at the same time + if (!is404Error(error)) { + throw error; + } + } } } } + +async function migrateAgentPolicies() { + const esClient = appContextService.getInternalUserESClient(); + const soClient = getInternalUserSOClient(); + const { items: agentPolicies } = await agentPolicyService.list(soClient, { + perPage: SO_SEARCH_LIMIT, + }); + + await Promise.all( + agentPolicies.map(async (agentPolicy) => { + const res = await esClient.search({ + index: AGENT_POLICY_INDEX, + q: `policy_id:${agentPolicy.id}`, + track_total_hits: true, + }); + + if (res.body.hits.total.value === 0) { + return agentPolicyService.createFleetPolicyChangeFleetServer( + soClient, + esClient, + agentPolicy.id + ); + } + }) + ); +} + +function is404Error(error: any) { + return isBoom(error) && error.output.statusCode === 404; +} diff --git a/x-pack/plugins/fleet/server/services/index.ts b/x-pack/plugins/fleet/server/services/index.ts index 9999ab91e31b22..77ce882275b6bf 100644 --- a/x-pack/plugins/fleet/server/services/index.ts +++ b/x-pack/plugins/fleet/server/services/index.ts @@ -49,6 +49,7 @@ export interface AgentService { */ authenticateAgentWithAccessToken( soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, request: KibanaRequest ): Promise; /** diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index ab24c26e0cdfae..f19ad4e7fe417d 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -93,6 +93,16 @@ async function createSetupSideEffects( await runFleetServerMigration(); } + if (appContextService.getConfig()?.agents?.fleetServerEnabled) { + await ensureInstalledPackage({ + savedObjectsClient: soClient, + pkgName: FLEET_SERVER_PACKAGE, + callCluster, + }); + await ensureFleetServerIndicesCreated(esClient); + await runFleetServerMigration(); + } + // If we just created the default policy, ensure default packages are added to it if (defaultAgentPolicyCreated) { const agentPolicyWithPackagePolicies = await agentPolicyService.get( diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index 0c35fc29e01cd7..fda1568c56e0e6 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -81,6 +81,9 @@ export { dataTypes, // Fleet Server types FleetServerEnrollmentAPIKey, + FleetServerAgent, + FleetServerAgentAction, + FleetServerPolicy, } from '../../common'; export type CallESAsCurrentUser = LegacyScopedClusterClient['callAsCurrentUser']; diff --git a/x-pack/plugins/fleet/server/types/models/agent_policy.ts b/x-pack/plugins/fleet/server/types/models/agent_policy.ts index 209bfb4b7398a8..5891320c2544bc 100644 --- a/x-pack/plugins/fleet/server/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/server/types/models/agent_policy.ts @@ -13,6 +13,7 @@ const AgentPolicyBaseSchema = { name: schema.string({ minLength: 1 }), namespace: NamespaceSchema, description: schema.maybe(schema.string()), + is_managed: schema.maybe(schema.boolean()), monitoring_enabled: schema.maybe( schema.arrayOf( schema.oneOf([schema.literal(dataTypes.Logs), schema.literal(dataTypes.Metrics)]) @@ -27,6 +28,7 @@ export const NewAgentPolicySchema = schema.object({ export const AgentPolicySchema = schema.object({ ...AgentPolicyBaseSchema, id: schema.string(), + is_managed: schema.boolean(), status: schema.oneOf([ schema.literal(agentPolicyStatuses.Active), schema.literal(agentPolicyStatuses.Inactive), diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap index a4eb99a972b9b6..d69af298018e74 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap +++ b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap @@ -93,6 +93,15 @@ exports[`DatatableComponent it renders actions column when there are row actions "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "eyeClosed", + "isDisabled": false, + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, @@ -121,6 +130,15 @@ exports[`DatatableComponent it renders actions column when there are row actions "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "eyeClosed", + "isDisabled": false, + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, @@ -149,6 +167,15 @@ exports[`DatatableComponent it renders actions column when there are row actions "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "eyeClosed", + "isDisabled": false, + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, @@ -288,6 +315,15 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "eyeClosed", + "isDisabled": false, + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, @@ -316,6 +352,15 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "eyeClosed", + "isDisabled": false, + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, @@ -344,6 +389,15 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "eyeClosed", + "isDisabled": false, + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx index 366e002f50cd81..5ff1e84276ba77 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { EuiDataGridColumn, EuiDataGridColumnCellActionProps } from '@elastic/eui'; import type { Datatable, DatatableColumnMeta } from 'src/plugins/expressions'; import type { FormatFactory } from '../../types'; -import type { DatatableColumns } from './types'; +import { ColumnConfig } from './table_basic'; export const createGridColumns = ( bucketColumns: string[], @@ -23,10 +23,11 @@ export const createGridColumns = ( negate?: boolean ) => void, isReadOnly: boolean, - columnConfig: DatatableColumns & { type: 'lens_datatable_columns' }, + columnConfig: ColumnConfig, visibleColumns: string[], formatFactory: FormatFactory, - onColumnResize: (eventData: { columnId: string; width: number | undefined }) => void + onColumnResize: (eventData: { columnId: string; width: number | undefined }) => void, + onColumnHide: (eventData: { columnId: string }) => void ) => { const columnsReverseLookup = table.columns.reduce< Record @@ -134,8 +135,9 @@ export const createGridColumns = ( ] : undefined; - const initialWidth = columnConfig.columnWidth?.find(({ columnId }) => columnId === field) - ?.width; + const column = columnConfig.columns.find(({ columnId }) => columnId === field); + const initialWidth = column?.width; + const isHidden = column?.hidden; const columnDefinition: EuiDataGridColumn = { id: field, @@ -174,6 +176,17 @@ export const createGridColumns = ( 'data-test-subj': 'lensDatatableResetWidth', isDisabled: initialWidth == null, }, + { + color: 'text', + size: 'xs', + onClick: () => onColumnHide({ columnId: field }), + iconType: 'eyeClosed', + label: i18n.translate('xpack.lens.table.hide.hideLabel', { + defaultMessage: 'Hide', + }), + 'data-test-subj': 'lensDatatableHide', + isDisabled: !isHidden && visibleColumns.length <= 1, + }, ], }, }; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts b/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts index db72f8a4e4a92f..84ee4f0e8a18e5 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts @@ -7,3 +7,4 @@ export const LENS_EDIT_SORT_ACTION = 'sort'; export const LENS_EDIT_RESIZE_ACTION = 'resize'; +export const LENS_TOGGLE_ACTION = 'toggle'; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx new file mode 100644 index 00000000000000..008b805bc8fed3 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSwitch, EuiFormRow } from '@elastic/eui'; +import { VisualizationDimensionEditorProps } from '../../types'; +import { DatatableVisualizationState } from '../visualization'; + +export function TableDimensionEditor( + props: VisualizationDimensionEditorProps +) { + const { state, setState, accessor } = props; + const column = state.columns.find((c) => c.columnId === accessor); + + const visibleColumnsCount = state.columns.filter((c) => !c.hidden).length; + + if (!column) { + return null; + } + + return ( + + ); +} diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts index b0b7d46e4c3b7f..68416ac9a60aa7 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts @@ -14,17 +14,19 @@ import { createGridFilterHandler, createGridResizeHandler, createGridSortingConfig, + createGridHideHandler, } from './table_actions'; -import { DatatableColumns, LensGridDirection } from './types'; +import { LensGridDirection } from './types'; +import { ColumnConfig } from './table_basic'; -function getDefaultConfig(): DatatableColumns & { - type: 'lens_datatable_columns'; -} { +function getDefaultConfig(): ColumnConfig { return { - columnIds: [], - sortBy: '', - sortDirection: 'none', - type: 'lens_datatable_columns', + columns: [ + { columnId: 'a', type: 'lens_datatable_column' }, + { columnId: 'b', type: 'lens_datatable_column' }, + ], + sortingColumnId: '', + sortingDirection: 'none', }; } @@ -207,7 +209,13 @@ describe('Table actions', () => { expect(setColumnConfig).toHaveBeenCalledWith({ ...columnConfig, - columnWidth: [{ columnId: 'a', width: 100, type: 'lens_datatable_column_width' }], + columns: [ + { columnId: 'a', width: 100, type: 'lens_datatable_column' }, + { + columnId: 'b', + type: 'lens_datatable_column', + }, + ], }); expect(onEditAction).toHaveBeenCalledWith({ action: 'resize', columnId: 'a', width: 100 }); @@ -215,16 +223,14 @@ describe('Table actions', () => { it('should pull out the table custom width from the local state when passing undefined', () => { const columnConfig = getDefaultConfig(); - columnConfig.columnWidth = [ - { columnId: 'a', width: 100, type: 'lens_datatable_column_width' }, - ]; + columnConfig.columns = [{ columnId: 'a', width: 100, type: 'lens_datatable_column' }]; const resizer = createGridResizeHandler(columnConfig, setColumnConfig, onEditAction); resizer({ columnId: 'a', width: undefined }); expect(setColumnConfig).toHaveBeenCalledWith({ ...columnConfig, - columnWidth: [], + columns: [{ columnId: 'a', width: undefined, type: 'lens_datatable_column' }], }); expect(onEditAction).toHaveBeenCalledWith({ @@ -234,4 +240,23 @@ describe('Table actions', () => { }); }); }); + describe('Column hiding', () => { + const setColumnConfig = jest.fn(); + + it('should allow to hide column', () => { + const columnConfig = getDefaultConfig(); + const hiding = createGridHideHandler(columnConfig, setColumnConfig, onEditAction); + hiding({ columnId: 'a' }); + + expect(setColumnConfig).toHaveBeenCalledWith({ + ...columnConfig, + columns: [ + { columnId: 'a', hidden: true, type: 'lens_datatable_column' }, + { columnId: 'b', type: 'lens_datatable_column' }, + ], + }); + + expect(onEditAction).toHaveBeenCalledWith({ action: 'toggle', columnId: 'a' }); + }); + }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts index ca4ec7f3a8d0c1..4f0271b758ffb0 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts @@ -9,43 +9,30 @@ import type { EuiDataGridSorting } from '@elastic/eui'; import type { Datatable } from 'src/plugins/expressions'; import type { LensFilterEvent } from '../../types'; import type { - DatatableColumns, LensGridDirection, LensResizeAction, LensSortAction, + LensToggleAction, } from './types'; +import { ColumnConfig } from './table_basic'; import { desanitizeFilterContext } from '../../utils'; export const createGridResizeHandler = ( - columnConfig: DatatableColumns & { - type: 'lens_datatable_columns'; - }, - setColumnConfig: React.Dispatch< - React.SetStateAction< - DatatableColumns & { - type: 'lens_datatable_columns'; - } - > - >, + columnConfig: ColumnConfig, + setColumnConfig: React.Dispatch>, onEditAction: (data: LensResizeAction['data']) => void ) => (eventData: { columnId: string; width: number | undefined }) => { // directly set the local state of the component to make sure the visualization re-renders immediately, // re-layouting and taking up all of the available space. setColumnConfig({ ...columnConfig, - columnWidth: [ - ...(columnConfig.columnWidth || []).filter(({ columnId }) => columnId !== eventData.columnId), - ...(eventData.width !== undefined - ? [ - { - columnId: eventData.columnId, - width: eventData.width, - type: 'lens_datatable_column_width' as const, - }, - ] - : []), - ], + columns: columnConfig.columns.map((column) => { + if (column.columnId === eventData.columnId) { + return { ...column, width: eventData.width }; + } + return column; + }), }); return onEditAction({ action: 'resize', @@ -54,6 +41,27 @@ export const createGridResizeHandler = ( }); }; +export const createGridHideHandler = ( + columnConfig: ColumnConfig, + setColumnConfig: React.Dispatch>, + onEditAction: (data: LensToggleAction['data']) => void +) => (eventData: { columnId: string }) => { + // directly set the local state of the component to make sure the visualization re-renders immediately + setColumnConfig({ + ...columnConfig, + columns: columnConfig.columns.map((column) => { + if (column.columnId === eventData.columnId) { + return { ...column, hidden: true }; + } + return column; + }), + }); + return onEditAction({ + action: 'toggle', + columnId: eventData.columnId, + }); +}; + export const createGridFilterHandler = ( tableRef: React.MutableRefObject, onClickValue: (data: LensFilterEvent['data']) => void @@ -85,7 +93,7 @@ export const createGridFilterHandler = ( }; export const createGridSortingConfig = ( - sortBy: string, + sortBy: string | undefined, sortDirection: LensGridDirection, onEditAction: (data: LensSortAction['data']) => void ): EuiDataGridSorting => ({ diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx index 6935e8313afb05..588340fbe97fa9 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx @@ -65,12 +65,13 @@ function sampleArgs() { const args: DatatableProps['args'] = { title: 'My fanci metric chart', - columns: { - columnIds: ['a', 'b', 'c'], - sortBy: '', - sortDirection: 'none', - type: 'lens_datatable_columns', - }, + columns: [ + { columnId: 'a', type: 'lens_datatable_column' }, + { columnId: 'b', type: 'lens_datatable_column' }, + { columnId: 'c', type: 'lens_datatable_column' }, + ], + sortingColumnId: '', + sortingDirection: 'none', }; return { data, args }; @@ -160,6 +161,8 @@ describe('DatatableComponent', () => { /> ); + wrapper.find('[data-test-subj="dataGridRowCell"]').first().simulate('focus'); + wrapper.find('[data-test-subj="lensDatatableFilterOut"]').first().simulate('click'); expect(onDispatchEvent).toHaveBeenCalledWith({ @@ -199,7 +202,9 @@ describe('DatatableComponent', () => { /> ); - wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(3).simulate('click'); + wrapper.find('[data-test-subj="dataGridRowCell"]').at(1).simulate('focus'); + + wrapper.find('[data-test-subj="lensDatatableFilterFor"]').first().simulate('click'); expect(onDispatchEvent).toHaveBeenCalledWith({ name: 'filter', @@ -252,12 +257,12 @@ describe('DatatableComponent', () => { const args: DatatableProps['args'] = { title: '', - columns: { - columnIds: ['a', 'b'], - sortBy: '', - sortDirection: 'none', - type: 'lens_datatable_columns', - }, + columns: [ + { columnId: 'a', type: 'lens_datatable_column' }, + { columnId: 'b', type: 'lens_datatable_column' }, + ], + sortingColumnId: '', + sortingDirection: 'none', }; const wrapper = mountWithIntl( @@ -277,7 +282,9 @@ describe('DatatableComponent', () => { /> ); - wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(1).simulate('click'); + wrapper.find('[data-test-subj="dataGridRowCell"]').at(0).simulate('focus'); + + wrapper.find('[data-test-subj="lensDatatableFilterFor"]').first().simulate('click'); expect(onDispatchEvent).toHaveBeenCalledWith({ name: 'filter', @@ -331,11 +338,8 @@ describe('DatatableComponent', () => { data={data} args={{ ...args, - columns: { - ...args.columns, - sortBy: 'b', - sortDirection: 'desc', - }, + sortingColumnId: 'b', + sortingDirection: 'desc', }} formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} dispatchEvent={onDispatchEvent} @@ -382,11 +386,8 @@ describe('DatatableComponent', () => { data={data} args={{ ...args, - columns: { - ...args.columns, - sortBy: 'b', - sortDirection: 'desc', - }, + sortingColumnId: 'b', + sortingDirection: 'desc', }} formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} dispatchEvent={onDispatchEvent} @@ -400,6 +401,32 @@ describe('DatatableComponent', () => { ]); }); + test('it does not render a hidden column', () => { + const { data, args } = sampleArgs(); + + const wrapper = mountWithIntl( + ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="display" + /> + ); + + expect(wrapper.find(EuiDataGrid).prop('columns')!.length).toEqual(2); + }); + test('it should refresh the table header when the datatable data changes', () => { const { data, args } = sampleArgs(); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx index b4852895a1e20e..f685990f12dd26 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx @@ -22,17 +22,20 @@ import { FormatFactory, LensFilterEvent, LensTableRowContextMenuEvent } from '.. import { VisualizationContainer } from '../../visualization_container'; import { EmptyPlaceholder } from '../../shared_components'; import { LensIconChartDatatable } from '../../assets/chart_datatable'; +import { ColumnState } from '../visualization'; import { DataContextType, DatatableRenderProps, LensSortAction, LensResizeAction, LensGridDirection, + LensToggleAction, } from './types'; import { createGridColumns } from './columns'; import { createGridCell } from './cell_value'; import { createGridFilterHandler, + createGridHideHandler, createGridResizeHandler, createGridSortingConfig, } from './table_actions'; @@ -44,15 +47,33 @@ const gridStyle: EuiDataGridStyle = { header: 'underline', }; +export interface ColumnConfig { + columns: Array< + ColumnState & { + type: 'lens_datatable_column'; + } + >; + sortingColumnId: string | undefined; + sortingDirection: LensGridDirection; +} + export const DatatableComponent = (props: DatatableRenderProps) => { const [firstTable] = Object.values(props.data.tables); - const [columnConfig, setColumnConfig] = useState(props.args.columns); + const [columnConfig, setColumnConfig] = useState({ + columns: props.args.columns, + sortingColumnId: props.args.sortingColumnId, + sortingDirection: props.args.sortingDirection, + }); const [firstLocalTable, updateTable] = useState(firstTable); useDeepCompareEffect(() => { - setColumnConfig(props.args.columns); - }, [props.args.columns]); + setColumnConfig({ + columns: props.args.columns, + sortingColumnId: props.args.sortingColumnId, + sortingDirection: props.args.sortingDirection, + }); + }, [props.args.columns, props.args.sortingColumnId, props.args.sortingDirection]); useDeepCompareEffect(() => { updateTable(firstTable); @@ -85,7 +106,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { ); const onEditAction = useCallback( - (data: LensSortAction['data'] | LensResizeAction['data']) => { + (data: LensSortAction['data'] | LensResizeAction['data'] | LensToggleAction['data']) => { if (renderMode === 'edit') { dispatchEvent({ name: 'edit', data }); } @@ -106,13 +127,15 @@ export const DatatableComponent = (props: DatatableRenderProps) => { const bucketColumns = useMemo( () => - columnConfig.columnIds.filter((_colId, index) => { - const col = firstTableRef.current.columns[index]; - return ( - col?.meta?.sourceParams?.type && - getType(col.meta.sourceParams.type as string)?.type === 'buckets' - ); - }), + columnConfig.columns + .filter((_col, index) => { + const col = firstTableRef.current.columns[index]; + return ( + col?.meta?.sourceParams?.type && + getType(col.meta.sourceParams.type as string)?.type === 'buckets' + ); + }) + .map((col) => col.columnId), [firstTableRef, columnConfig, getType] ); @@ -121,11 +144,15 @@ export const DatatableComponent = (props: DatatableRenderProps) => { (bucketColumns.length && firstTable.rows.every((row) => bucketColumns.every((col) => row[col] == null))); - const visibleColumns = useMemo(() => columnConfig.columnIds.filter((field) => !!field), [ - columnConfig, - ]); + const visibleColumns = useMemo( + () => + columnConfig.columns + .filter((col) => !!col.columnId && !col.hidden) + .map((col) => col.columnId), + [columnConfig] + ); - const { sortBy, sortDirection } = columnConfig; + const { sortingColumnId: sortBy, sortingDirection: sortDirection } = props.args; const isReadOnlySorted = renderMode !== 'edit'; @@ -134,6 +161,11 @@ export const DatatableComponent = (props: DatatableRenderProps) => { [onEditAction, setColumnConfig, columnConfig] ); + const onColumnHide = useMemo( + () => createGridHideHandler(columnConfig, setColumnConfig, onEditAction), + [onEditAction, setColumnConfig, columnConfig] + ); + const columns: EuiDataGridColumn[] = useMemo( () => createGridColumns( @@ -144,7 +176,8 @@ export const DatatableComponent = (props: DatatableRenderProps) => { columnConfig, visibleColumns, formatFactory, - onColumnResize + onColumnResize, + onColumnHide ), [ bucketColumns, @@ -155,6 +188,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { visibleColumns, formatFactory, onColumnResize, + onColumnHide, ] ); @@ -184,7 +218,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { onRowContextMenuClick({ rowIndex, table: firstTableRef.current, - columns: columnConfig.columnIds, + columns: columnConfig.columns.map((col) => col.columnId), }); }} /> diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/types.ts b/x-pack/plugins/lens/public/datatable_visualization/components/types.ts index e2cc1daf0f900f..8a280b3d15bca5 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/types.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/types.ts @@ -10,7 +10,7 @@ import type { IAggType } from 'src/plugins/data/public'; import type { Datatable, RenderMode } from 'src/plugins/expressions'; import type { FormatFactory, ILensInterpreterRenderHandlers, LensEditEvent } from '../../types'; import type { DatatableProps } from '../expression'; -import { LENS_EDIT_SORT_ACTION, LENS_EDIT_RESIZE_ACTION } from './constants'; +import { LENS_EDIT_SORT_ACTION, LENS_EDIT_RESIZE_ACTION, LENS_TOGGLE_ACTION } from './constants'; export type LensGridDirection = 'none' | Direction; @@ -24,24 +24,13 @@ export interface LensResizeActionData { width: number | undefined; } -export type LensSortAction = LensEditEvent; -export type LensResizeAction = LensEditEvent; - -export interface DatatableColumns { - columnIds: string[]; - sortBy: string; - sortDirection: string; - columnWidth?: DatatableColumnWidthResult[]; -} - -export interface DatatableColumnWidth { +export interface LensToggleActionData { columnId: string; - width: number; } -export type DatatableColumnWidthResult = DatatableColumnWidth & { - type: 'lens_datatable_column_width'; -}; +export type LensSortAction = LensEditEvent; +export type LensResizeAction = LensEditEvent; +export type LensToggleAction = LensEditEvent; export type DatatableRenderProps = DatatableProps & { formatFactory: FormatFactory; diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx index 5e51cb2c93c7c8..3ee41d4e9aeed7 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx @@ -59,12 +59,22 @@ function sampleArgs() { const args: DatatableProps['args'] = { title: 'My fanci metric chart', - columns: { - columnIds: ['a', 'b', 'c'], - sortBy: '', - sortDirection: 'none', - type: 'lens_datatable_columns', - }, + columns: [ + { + columnId: 'a', + type: 'lens_datatable_column', + }, + { + columnId: 'b', + type: 'lens_datatable_column', + }, + { + columnId: 'c', + type: 'lens_datatable_column', + }, + ], + sortingColumnId: '', + sortingDirection: 'none', }; return { data, args }; diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 82964a03e29e56..7ead7be67947c9 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -19,19 +19,17 @@ import type { import { getSortingCriteria } from './sorting'; import { DatatableComponent } from './components/table_basic'; +import { ColumnState } from './visualization'; import type { FormatFactory, ILensInterpreterRenderHandlers, LensMultiTable } from '../types'; -import type { - DatatableRender, - DatatableColumns, - DatatableColumnWidth, - DatatableColumnWidthResult, -} from './components/types'; +import type { DatatableRender } from './components/types'; interface Args { title: string; description?: string; - columns: DatatableColumns & { type: 'lens_datatable_columns' }; + columns: Array; + sortingColumnId: string | undefined; + sortingDirection: 'asc' | 'desc' | 'none'; } export interface DatatableProps { @@ -66,7 +64,16 @@ export const getDatatable = ({ help: '', }, columns: { - types: ['lens_datatable_columns'], + types: ['lens_datatable_column'], + help: '', + multi: true, + }, + sortingColumnId: { + types: ['string'], + help: '', + }, + sortingDirection: { + types: ['string'], help: '', }, }, @@ -79,7 +86,7 @@ export const getDatatable = ({ firstTable.columns.forEach((column) => { formatters[column.id] = formatFactory(column.meta?.params); }); - const { sortBy, sortDirection } = args.columns; + const { sortingColumnId: sortBy, sortingDirection: sortDirection } = args; const columnsReverseLookup = firstTable.columns.reduce< Record @@ -116,65 +123,27 @@ export const getDatatable = ({ }, }); -type DatatableColumnsResult = DatatableColumns & { type: 'lens_datatable_columns' }; +type DatatableColumnResult = ColumnState & { type: 'lens_datatable_column' }; -export const datatableColumns: ExpressionFunctionDefinition< - 'lens_datatable_columns', +export const datatableColumn: ExpressionFunctionDefinition< + 'lens_datatable_column', null, - DatatableColumns, - DatatableColumnsResult + ColumnState, + DatatableColumnResult > = { - name: 'lens_datatable_columns', + name: 'lens_datatable_column', aliases: [], - type: 'lens_datatable_columns', + type: 'lens_datatable_column', help: '', inputTypes: ['null'], args: { - sortBy: { types: ['string'], help: '' }, - sortDirection: { types: ['string'], help: '' }, - columnIds: { - types: ['string'], - multi: true, - help: '', - }, - columnWidth: { - types: ['lens_datatable_column_width'], - multi: true, - help: '', - }, - }, - fn: function fn(input: unknown, args: DatatableColumns) { - return { - type: 'lens_datatable_columns', - ...args, - }; - }, -}; - -export const datatableColumnWidth: ExpressionFunctionDefinition< - 'lens_datatable_column_width', - null, - DatatableColumnWidth, - DatatableColumnWidthResult -> = { - name: 'lens_datatable_column_width', - aliases: [], - type: 'lens_datatable_column_width', - help: '', - inputTypes: ['null'], - args: { - columnId: { - types: ['string'], - help: '', - }, - width: { - types: ['number'], - help: '', - }, + columnId: { types: ['string'], help: '' }, + hidden: { types: ['boolean'], help: '' }, + width: { types: ['number'], help: '' }, }, - fn: function fn(input: unknown, args: DatatableColumnWidth) { + fn: function fn(input: unknown, args: ColumnState) { return { - type: 'lens_datatable_column_width', + type: 'lens_datatable_column', ...args, }; }, @@ -213,7 +182,7 @@ export const getDatatableRenderer = (dependencies: { data: { rowIndex, table, - columns: config.args.columns.columnIds, + columns: config.args.columns.map((column) => column.columnId), }, }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/index.ts b/x-pack/plugins/lens/public/datatable_visualization/index.ts index 23e0a2b7918a45..f0939f61952294 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/index.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/index.ts @@ -29,15 +29,13 @@ export class DatatableVisualization { editorFrame.registerVisualization(async () => { const { getDatatable, - datatableColumns, - datatableColumnWidth, + datatableColumn, getDatatableRenderer, datatableVisualization, } = await import('../async_services'); const resolvedFormatFactory = await formatFactory; - expressions.registerFunction(() => datatableColumns); - expressions.registerFunction(() => datatableColumnWidth); + expressions.registerFunction(() => datatableColumn); expressions.registerFunction(() => getDatatable({ formatFactory: resolvedFormatFactory })); expressions.registerRenderer(() => getDatatableRenderer({ diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index 0627effa30be7a..25275ba8e2249b 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -30,23 +30,15 @@ describe('Datatable Visualization', () => { describe('#initialize', () => { it('should initialize from the empty state', () => { expect(datatableVisualization.initialize(mockFrame(), undefined)).toEqual({ - layers: [ - { - layerId: 'aaa', - columns: [], - }, - ], + layerId: 'aaa', + columns: [], }); }); it('should initialize from a persisted state', () => { const expectedState: DatatableVisualizationState = { - layers: [ - { - layerId: 'foo', - columns: ['saved'], - }, - ], + layerId: 'foo', + columns: [{ columnId: 'saved' }], }; expect(datatableVisualization.initialize(mockFrame(), expectedState)).toEqual(expectedState); }); @@ -55,12 +47,8 @@ describe('Datatable Visualization', () => { describe('#getLayerIds', () => { it('return the layer ids', () => { const state: DatatableVisualizationState = { - layers: [ - { - layerId: 'baz', - columns: ['a', 'b', 'c'], - }, - ], + layerId: 'baz', + columns: [{ columnId: 'a' }, { columnId: 'b' }, { columnId: 'c' }], }; expect(datatableVisualization.getLayerIds(state)).toEqual(['baz']); }); @@ -69,20 +57,12 @@ describe('Datatable Visualization', () => { describe('#clearLayer', () => { it('should reset the layer', () => { const state: DatatableVisualizationState = { - layers: [ - { - layerId: 'baz', - columns: ['a', 'b', 'c'], - }, - ], + layerId: 'baz', + columns: [{ columnId: 'a' }, { columnId: 'b' }, { columnId: 'c' }], }; expect(datatableVisualization.clearLayer(state, 'baz')).toMatchObject({ - layers: [ - { - layerId: 'baz', - columns: [], - }, - ], + layerId: 'baz', + columns: [], }); }); }); @@ -113,7 +93,8 @@ describe('Datatable Visualization', () => { it('should accept a single-layer suggestion', () => { const suggestions = datatableVisualization.getSuggestions({ state: { - layers: [{ layerId: 'first', columns: ['col1'] }], + layerId: 'first', + columns: [{ columnId: 'col1' }], }, table: { isMultiRow: true, @@ -130,7 +111,8 @@ describe('Datatable Visualization', () => { it('should not make suggestions when the table is unchanged', () => { const suggestions = datatableVisualization.getSuggestions({ state: { - layers: [{ layerId: 'first', columns: ['col1'] }], + layerId: 'first', + columns: [{ columnId: 'col1' }], }, table: { isMultiRow: true, @@ -147,7 +129,8 @@ describe('Datatable Visualization', () => { it('should not make suggestions when multiple layers are involved', () => { const suggestions = datatableVisualization.getSuggestions({ state: { - layers: [{ layerId: 'first', columns: ['col1'] }], + layerId: 'first', + columns: [{ columnId: 'col1' }], }, table: { isMultiRow: true, @@ -164,7 +147,8 @@ describe('Datatable Visualization', () => { it('should not make suggestions when the suggestion keeps a different layer', () => { const suggestions = datatableVisualization.getSuggestions({ state: { - layers: [{ layerId: 'older', columns: ['col1'] }], + layerId: 'older', + columns: [{ columnId: 'col1' }], }, table: { isMultiRow: true, @@ -203,7 +187,8 @@ describe('Datatable Visualization', () => { datatableVisualization.getConfiguration({ layerId: 'first', state: { - layers: [{ layerId: 'first', columns: [] }], + layerId: 'first', + columns: [], }, frame, }).groups @@ -218,7 +203,8 @@ describe('Datatable Visualization', () => { const filterOperations = datatableVisualization.getConfiguration({ layerId: 'first', state: { - layers: [{ layerId: 'first', columns: [] }], + layerId: 'first', + columns: [], }, frame, }).groups[0].filterOperations; @@ -249,7 +235,8 @@ describe('Datatable Visualization', () => { const filterOperations = datatableVisualization.getConfiguration({ layerId: 'first', state: { - layers: [{ layerId: 'first', columns: [] }], + layerId: 'first', + columns: [], }, frame, }).groups[1].filterOperations; @@ -274,7 +261,6 @@ describe('Datatable Visualization', () => { it('reorders the rendered colums based on the order from the datasource', () => { const datasource = createMockDatasource('test'); - const layer = { layerId: 'a', columns: ['b', 'c'] }; const frame = mockFrame(); frame.datasourceLayers = { a: datasource.publicAPIMock }; datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]); @@ -282,7 +268,10 @@ describe('Datatable Visualization', () => { expect( datatableVisualization.getConfiguration({ layerId: 'a', - state: { layers: [layer] }, + state: { + layerId: 'a', + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }, frame, }).groups[1].accessors ).toEqual([{ columnId: 'c' }, { columnId: 'b' }]); @@ -291,95 +280,75 @@ describe('Datatable Visualization', () => { describe('#removeDimension', () => { it('allows columns to be removed', () => { - const layer = { layerId: 'layer1', columns: ['b', 'c'] }; expect( datatableVisualization.removeDimension({ - prevState: { layers: [layer] }, + prevState: { + layerId: 'layer1', + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }, layerId: 'layer1', columnId: 'b', }) ).toEqual({ - layers: [ - { - layerId: 'layer1', - columns: ['c'], - }, - ], + layerId: 'layer1', + columns: [{ columnId: 'c' }], }); }); it('should handle correctly the sorting state on removing dimension', () => { - const layer = { layerId: 'layer1', columns: ['b', 'c'] }; + const state = { layerId: 'layer1', columns: [{ columnId: 'b' }, { columnId: 'c' }] }; expect( datatableVisualization.removeDimension({ - prevState: { layers: [layer], sorting: { columnId: 'b', direction: 'asc' } }, + prevState: { ...state, sorting: { columnId: 'b', direction: 'asc' } }, layerId: 'layer1', columnId: 'b', }) ).toEqual({ sorting: undefined, - layers: [ - { - layerId: 'layer1', - columns: ['c'], - }, - ], + layerId: 'layer1', + columns: [{ columnId: 'c' }], }); expect( datatableVisualization.removeDimension({ - prevState: { layers: [layer], sorting: { columnId: 'c', direction: 'asc' } }, + prevState: { ...state, sorting: { columnId: 'c', direction: 'asc' } }, layerId: 'layer1', columnId: 'b', }) ).toEqual({ sorting: { columnId: 'c', direction: 'asc' }, - layers: [ - { - layerId: 'layer1', - columns: ['c'], - }, - ], + layerId: 'layer1', + columns: [{ columnId: 'c' }], }); }); }); describe('#setDimension', () => { it('allows columns to be added', () => { - const layer = { layerId: 'layer1', columns: ['b', 'c'] }; expect( datatableVisualization.setDimension({ - prevState: { layers: [layer] }, + prevState: { layerId: 'layer1', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, layerId: 'layer1', columnId: 'd', groupId: '', }) ).toEqual({ - layers: [ - { - layerId: 'layer1', - columns: ['b', 'c', 'd'], - }, - ], + layerId: 'layer1', + columns: [{ columnId: 'b' }, { columnId: 'c' }, { columnId: 'd' }], }); }); it('does not set a duplicate dimension', () => { - const layer = { layerId: 'layer1', columns: ['b', 'c'] }; expect( datatableVisualization.setDimension({ - prevState: { layers: [layer] }, + prevState: { layerId: 'layer1', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, layerId: 'layer1', columnId: 'b', groupId: '', }) ).toEqual({ - layers: [ - { - layerId: 'layer1', - columns: ['b', 'c'], - }, - ], + layerId: 'layer1', + columns: [{ columnId: 'b' }, { columnId: 'c' }], }); }); }); @@ -387,7 +356,6 @@ describe('Datatable Visualization', () => { describe('#toExpression', () => { it('reorders the rendered colums based on the order from the datasource', () => { const datasource = createMockDatasource('test'); - const layer = { layerId: 'a', columns: ['b', 'c'] }; const frame = mockFrame(); frame.datasourceLayers = { a: datasource.publicAPIMock }; datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]); @@ -398,24 +366,35 @@ describe('Datatable Visualization', () => { }); const expression = datatableVisualization.toExpression( - { layers: [layer] }, + { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, frame.datasourceLayers ) as Ast; - const tableArgs = buildExpression(expression).findFunction('lens_datatable_columns'); + const tableArgs = buildExpression(expression).findFunction('lens_datatable'); expect(tableArgs).toHaveLength(1); - expect(tableArgs[0].arguments).toEqual({ - columnIds: ['c', 'b'], - sortBy: [''], - sortDirection: ['none'], - columnWidth: [], + expect(tableArgs[0].arguments).toEqual( + expect.objectContaining({ + sortingColumnId: [''], + sortingDirection: ['none'], + }) + ); + const columnArgs = buildExpression(expression).findFunction('lens_datatable_column'); + expect(columnArgs).toHaveLength(2); + expect(columnArgs[0].arguments).toEqual({ + columnId: ['c'], + hidden: [], + width: [], + }); + expect(columnArgs[1].arguments).toEqual({ + columnId: ['b'], + hidden: [], + width: [], }); }); it('returns no expression if the metric dimension is not defined', () => { const datasource = createMockDatasource('test'); - const layer = { layerId: 'a', columns: ['b', 'c'] }; const frame = mockFrame(); frame.datasourceLayers = { a: datasource.publicAPIMock }; datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]); @@ -426,7 +405,7 @@ describe('Datatable Visualization', () => { }); const expression = datatableVisualization.toExpression( - { layers: [layer] }, + { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, frame.datasourceLayers ); @@ -437,7 +416,6 @@ describe('Datatable Visualization', () => { describe('#getErrorMessages', () => { it('returns undefined if the datasource is missing a metric dimension', () => { const datasource = createMockDatasource('test'); - const layer = { layerId: 'a', columns: ['b', 'c'] }; const frame = mockFrame(); frame.datasourceLayers = { a: datasource.publicAPIMock }; datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]); @@ -447,14 +425,16 @@ describe('Datatable Visualization', () => { label: 'label', }); - const error = datatableVisualization.getErrorMessages({ layers: [layer] }, frame); + const error = datatableVisualization.getErrorMessages( + { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, + frame + ); expect(error).toBeUndefined(); }); it('returns undefined if the metric dimension is defined', () => { const datasource = createMockDatasource('test'); - const layer = { layerId: 'a', columns: ['b', 'c'] }; const frame = mockFrame(); frame.datasourceLayers = { a: datasource.publicAPIMock }; datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]); @@ -464,7 +444,10 @@ describe('Datatable Visualization', () => { label: 'label', }); - const error = datatableVisualization.getErrorMessages({ layers: [layer] }, frame); + const error = datatableVisualization.getErrorMessages( + { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, + frame + ); expect(error).toBeUndefined(); }); @@ -473,12 +456,8 @@ describe('Datatable Visualization', () => { describe('#onEditAction', () => { it('should add a sort column to the state', () => { const currentState: DatatableVisualizationState = { - layers: [ - { - layerId: 'foo', - columns: ['saved'], - }, - ], + layerId: 'foo', + columns: [{ columnId: 'saved' }], }; expect( datatableVisualization.onEditAction!(currentState, { @@ -496,12 +475,8 @@ describe('Datatable Visualization', () => { it('should add a custom width to a column in the state', () => { const currentState: DatatableVisualizationState = { - layers: [ - { - layerId: 'foo', - columns: ['saved'], - }, - ], + layerId: 'foo', + columns: [{ columnId: 'saved' }], }; expect( datatableVisualization.onEditAction!(currentState, { @@ -510,29 +485,14 @@ describe('Datatable Visualization', () => { }) ).toEqual({ ...currentState, - columnWidth: [ - { - columnId: 'saved', - width: 500, - }, - ], + columns: [{ columnId: 'saved', width: 500 }], }); }); it('should clear custom width value for the column from the state', () => { const currentState: DatatableVisualizationState = { - layers: [ - { - layerId: 'foo', - columns: ['saved'], - }, - ], - columnWidth: [ - { - columnId: 'saved', - width: 500, - }, - ], + layerId: 'foo', + columns: [{ columnId: 'saved', width: 5000 }], }; expect( datatableVisualization.onEditAction!(currentState, { @@ -541,7 +501,7 @@ describe('Datatable Visualization', () => { }) ).toEqual({ ...currentState, - columnWidth: [], + columns: [{ columnId: 'saved', width: undefined }], }); }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 6a221396b8a849..77fda43c37fef9 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -5,37 +5,35 @@ * 2.0. */ +import React from 'react'; +import { render } from 'react-dom'; import { Ast } from '@kbn/interpreter/common'; +import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import type { SuggestionRequest, Visualization, VisualizationSuggestion, - Operation, DatasourcePublicAPI, } from '../types'; -import type { DatatableColumnWidth } from './components/types'; import { LensIconChartDatatable } from '../assets/chart_datatable'; +import { TableDimensionEditor } from './components/dimension_editor'; -export interface DatatableLayerState { - layerId: string; - columns: string[]; +export interface ColumnState { + columnId: string; + width?: number; + hidden?: boolean; } -export interface DatatableVisualizationState { - layers: DatatableLayerState[]; - sorting?: { - columnId: string | undefined; - direction: 'asc' | 'desc' | 'none'; - }; - columnWidth?: DatatableColumnWidth[]; +export interface SortingState { + columnId: string | undefined; + direction: 'asc' | 'desc' | 'none'; } -function newLayerState(layerId: string): DatatableLayerState { - return { - layerId, - columns: [], - }; +export interface DatatableVisualizationState { + columns: ColumnState[]; + layerId: string; + sorting?: SortingState; } export const datatableVisualization: Visualization = { @@ -56,12 +54,13 @@ export const datatableVisualization: Visualization }, getLayerIds(state) { - return state.layers.map((l) => l.layerId); + return [state.layerId]; }, clearLayer(state) { return { - layers: state.layers.map((l) => newLayerState(l.layerId)), + ...state, + columns: [], }; }, @@ -79,7 +78,8 @@ export const datatableVisualization: Visualization initialize(frame, state) { return ( state || { - layers: [newLayerState(frame.addNewLayer())], + columns: [], + layerId: frame.addNewLayer(), } ); }, @@ -126,12 +126,8 @@ export const datatableVisualization: Visualization // table with >= 10 columns will have a score of 0.4, fewer columns reduce score score: (Math.min(table.columns.length, 10) / 10) * 0.4, state: { - layers: [ - { - layerId: table.layerId, - columns: table.columns.map((col) => col.columnId), - }, - ], + layerId: table.layerId, + columns: table.columns.map((col) => ({ columnId: col.columnId })), }, previewIcon: LensIconChartDatatable, // tables are hidden from suggestion bar, but used for drag & drop and chart switching @@ -144,6 +140,11 @@ export const datatableVisualization: Visualization const { sortedColumns, datasource } = getDataSourceAndSortedColumns(state, frame.datasourceLayers, layerId) || {}; + const columnMap: Record = {}; + state.columns.forEach((column) => { + columnMap[column.columnId] = column; + }); + if (!sortedColumns) { return { groups: [] }; } @@ -155,61 +156,68 @@ export const datatableVisualization: Visualization groupLabel: i18n.translate('xpack.lens.datatable.breakdown', { defaultMessage: 'Break down by', }), - layerId: state.layers[0].layerId, + layerId: state.layerId, accessors: sortedColumns .filter((c) => datasource!.getOperationForColumnId(c)?.isBucketed) - .map((accessor) => ({ columnId: accessor })), + .map((accessor) => ({ + columnId: accessor, + triggerIcon: columnMap[accessor].hidden ? 'invisible' : undefined, + })), supportsMoreColumns: true, filterOperations: (op) => op.isBucketed, dataTestSubj: 'lnsDatatable_column', + enableDimensionEditor: true, }, { groupId: 'metrics', groupLabel: i18n.translate('xpack.lens.datatable.metrics', { defaultMessage: 'Metrics', }), - layerId: state.layers[0].layerId, + layerId: state.layerId, accessors: sortedColumns .filter((c) => !datasource!.getOperationForColumnId(c)?.isBucketed) - .map((accessor) => ({ columnId: accessor })), + .map((accessor) => ({ + columnId: accessor, + triggerIcon: columnMap[accessor].hidden ? 'invisible' : undefined, + })), supportsMoreColumns: true, filterOperations: (op) => !op.isBucketed, required: true, dataTestSubj: 'lnsDatatable_metrics', + enableDimensionEditor: true, }, ], }; }, - setDimension({ prevState, layerId, columnId }) { + setDimension({ prevState, columnId }) { + if (prevState.columns.some((column) => column.columnId === columnId)) { + return prevState; + } return { ...prevState, - layers: prevState.layers.map((l) => { - if (l.layerId !== layerId || l.columns.includes(columnId)) { - return l; - } - return { ...l, columns: [...l.columns, columnId] }; - }), + columns: [...prevState.columns, { columnId }], }; }, - removeDimension({ prevState, layerId, columnId }) { + removeDimension({ prevState, columnId }) { return { ...prevState, - layers: prevState.layers.map((l) => - l.layerId === layerId - ? { - ...l, - columns: l.columns.filter((c) => c !== columnId), - } - : l - ), + columns: prevState.columns.filter((column) => column.columnId !== columnId), sorting: prevState.sorting?.columnId === columnId ? undefined : prevState.sorting, }; }, + renderDimensionEditor(domElement, props) { + render( + + + , + domElement + ); + }, toExpression(state, datasourceLayers, { title, description } = {}): Ast | null { const { sortedColumns, datasource } = - getDataSourceAndSortedColumns(state, datasourceLayers, state.layers[0].layerId) || {}; + getDataSourceAndSortedColumns(state, datasourceLayers, state.layerId) || {}; if ( sortedColumns?.length && @@ -218,9 +226,14 @@ export const datatableVisualization: Visualization return null; } - const operations = sortedColumns! - .map((columnId) => ({ columnId, operation: datasource!.getOperationForColumnId(columnId) })) - .filter((o): o is { columnId: string; operation: Operation } => !!o.operation); + const columnMap: Record = {}; + state.columns.forEach((column) => { + columnMap[column.columnId] = column; + }); + + const columns = sortedColumns! + .filter((columnId) => datasource!.getOperationForColumnId(columnId)) + .map((columnId) => columnMap[columnId]); return { type: 'expression', @@ -231,35 +244,22 @@ export const datatableVisualization: Visualization arguments: { title: [title || ''], description: [description || ''], - columns: [ - { - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_datatable_columns', - arguments: { - columnIds: operations.map((o) => o.columnId), - sortBy: [state.sorting?.columnId || ''], - sortDirection: [state.sorting?.direction || 'none'], - columnWidth: (state.columnWidth || []).map((columnWidth) => ({ - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_datatable_column_width', - arguments: { - columnId: [columnWidth.columnId], - width: [columnWidth.width], - }, - }, - ], - })), - }, + columns: columns.map((column) => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_datatable_column', + arguments: { + columnId: [column.columnId], + hidden: typeof column.hidden === 'undefined' ? [] : [column.hidden], + width: typeof column.width === 'undefined' ? [] : [column.width], }, - ], - }, - ], + }, + ], + })), + sortingColumnId: [state.sorting?.columnId || ''], + sortingDirection: [state.sorting?.direction || 'none'], }, }, ], @@ -280,15 +280,34 @@ export const datatableVisualization: Visualization direction: event.data.direction, }, }; + case 'toggle': + return { + ...state, + columns: state.columns.map((column) => { + if (column.columnId === event.data.columnId) { + return { + ...column, + hidden: !column.hidden, + }; + } else { + return column; + } + }), + }; case 'resize': + const targetWidth = event.data.width; return { ...state, - columnWidth: [ - ...(state.columnWidth || []).filter(({ columnId }) => columnId !== event.data.columnId), - ...(event.data.width !== undefined - ? [{ columnId: event.data.columnId, width: event.data.width }] - : []), - ], + columns: state.columns.map((column) => { + if (column.columnId === event.data.columnId) { + return { + ...column, + width: targetWidth, + }; + } else { + return column; + } + }), }; default: return state; @@ -301,13 +320,11 @@ function getDataSourceAndSortedColumns( datasourceLayers: Record, layerId: string ) { - const layer = state.layers.find((l: DatatableLayerState) => l.layerId === layerId); - if (!layer) { - return undefined; - } - const datasource = datasourceLayers[layer.layerId]; + const datasource = datasourceLayers[state.layerId]; const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); // When we add a column it could be empty, and therefore have no order - const sortedColumns = Array.from(new Set(originalOrder.concat(layer.columns))); + const sortedColumns = Array.from( + new Set(originalOrder.concat(state.columns.map(({ columnId }) => columnId))) + ); return { datasource, sortedColumns }; } diff --git a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap index 6423a9f6190a7b..b3b695b22ad716 100644 --- a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap +++ b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`DragDrop droppable is reflected in the className 1`] = ` +exports[`DragDrop defined dropType is reflected in the className 1`] = ` ); @@ -46,10 +48,10 @@ describe('DragDrop', () => { expect(component).toMatchSnapshot(); }); - test('dragover calls preventDefault if droppable is true', () => { + test('dragover calls preventDefault if dropType is defined', () => { const preventDefault = jest.fn(); const component = mount( - + ); @@ -59,10 +61,10 @@ describe('DragDrop', () => { expect(preventDefault).toBeCalled(); }); - test('dragover does not call preventDefault if droppable is false', () => { + test('dragover does not call preventDefault if dropType is undefined', () => { const preventDefault = jest.fn(); const component = mount( - + ); @@ -75,9 +77,15 @@ describe('DragDrop', () => { test('dragstart sets dragging in the context', async () => { const setDragging = jest.fn(); + const setA11yMessage = jest.fn(); const component = mount( - - + + @@ -87,8 +95,9 @@ describe('DragDrop', () => { jest.runAllTimers(); - expect(dataTransfer.setData).toBeCalledWith('text', 'drag label'); + expect(dataTransfer.setData).toBeCalledWith('text', 'hello'); expect(setDragging).toBeCalledWith(value); + expect(setA11yMessage).toBeCalledWith('Lifted hello'); }); test('drop resets all the things', async () => { @@ -100,10 +109,10 @@ describe('DragDrop', () => { const component = mount( - + @@ -116,18 +125,22 @@ describe('DragDrop', () => { expect(preventDefault).toBeCalled(); expect(stopPropagation).toBeCalled(); expect(setDragging).toBeCalledWith(undefined); - expect(onDrop).toBeCalledWith({ id: '2', label: 'hi' }, { id: '1', label: 'hello' }); + expect(onDrop).toBeCalledWith({ id: '2', humanData: { label: 'label1' } }, 'field_add'); }); - test('drop function is not called on droppable=false', async () => { + test('drop function is not called on dropType undefined', async () => { const preventDefault = jest.fn(); const stopPropagation = jest.fn(); const setDragging = jest.fn(); const onDrop = jest.fn(); const component = mount( - - + + @@ -143,14 +156,15 @@ describe('DragDrop', () => { expect(onDrop).not.toHaveBeenCalled(); }); - test('droppable is reflected in the className', () => { + test('defined dropType is reflected in the className', () => { const component = render( { throw x; }} - droppable + dropType="field_add" value={value} + order={[2, 0, 1, 0]} > @@ -159,13 +173,18 @@ describe('DragDrop', () => { expect(component).toMatchSnapshot(); }); - test('items that have droppable=false get special styling when another item is dragged', () => { + test('items that has dropType=undefined get special styling when another item is dragged', () => { const component = mount( - + - {}} droppable={false} value={{ id: '2' }}> + {}} + dropType={undefined} + value={{ id: '2', humanData: { label: 'label2' } }} + > @@ -175,30 +194,39 @@ describe('DragDrop', () => { }); test('additional styles are reflected in the className until drop', () => { - let dragging: { id: '1' } | undefined; - const getAdditionalClasses = jest.fn().mockReturnValue('additional'); + let dragging: { id: '1'; humanData: { label: 'label1' } } | undefined; + const getAdditionalClassesOnEnter = jest.fn().mockReturnValue('additional'); + const getAdditionalClassesOnDroppable = jest.fn().mockReturnValue('droppable'); + const setA11yMessage = jest.fn(); let activeDropTarget; const component = mount( { - dragging = { id: '1' }; + dragging = { id: '1', humanData: { label: 'label1' } }; }} setActiveDropTarget={(val) => { activeDropTarget = { activeDropTarget: val }; }} activeDropTarget={activeDropTarget} > - + {}} - droppable - getAdditionalClassesOnEnter={getAdditionalClasses} + dropType="field_add" + getAdditionalClassesOnEnter={getAdditionalClassesOnEnter} + getAdditionalClassesOnDroppable={getAdditionalClassesOnDroppable} > @@ -210,6 +238,7 @@ describe('DragDrop', () => { .first() .simulate('dragstart', { dataTransfer }); jest.runAllTimers(); + expect(setA11yMessage).toBeCalledWith('Lifted ignored'); component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover'); component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('drop'); @@ -217,8 +246,9 @@ describe('DragDrop', () => { }); test('additional enter styles are reflected in the className until dragleave', () => { - let dragging: { id: '1' } | undefined; + let dragging: { id: '1'; humanData: { label: 'label1' } } | undefined; const getAdditionalClasses = jest.fn().mockReturnValue('additional'); + const getAdditionalClassesOnDroppable = jest.fn().mockReturnValue('droppable'); const setActiveDropTarget = jest.fn(); const component = mount( @@ -226,7 +256,7 @@ describe('DragDrop', () => { setA11yMessage={jest.fn()} dragging={dragging} setDragging={() => { - dragging = { id: '1' }; + dragging = { id: '1', humanData: { label: 'label1' } }; }} setActiveDropTarget={setActiveDropTarget} activeDropTarget={ @@ -234,15 +264,22 @@ describe('DragDrop', () => { } keyboardMode={false} setKeyboardMode={(keyboardMode) => true} + registerDropTarget={jest.fn()} > - + {}} - droppable + dropType="field_add" getAdditionalClassesOnEnter={getAdditionalClasses} + getAdditionalClassesOnDroppable={getAdditionalClassesOnDroppable} > @@ -257,19 +294,137 @@ describe('DragDrop', () => { component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover'); expect(component.find('.additional')).toHaveLength(1); - component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragleave'); expect(setActiveDropTarget).toBeCalledWith(undefined); }); + test('Keyboard navigation: User receives proper drop Targets highlighted when pressing arrow keys', () => { + const onDrop = jest.fn(); + const setActiveDropTarget = jest.fn(); + const setA11yMessage = jest.fn(); + const items = [ + { + draggable: true, + value: { + id: '1', + humanData: { label: 'label1', position: 1 }, + }, + children: '1', + order: [2, 0, 0, 0], + }, + { + draggable: true, + dragType: 'move' as 'copy' | 'move', + + value: { + id: '2', + + humanData: { label: 'label2', position: 1 }, + }, + onDrop, + dropType: 'move_compatible' as DropType, + order: [2, 0, 1, 0], + }, + { + draggable: true, + dragType: 'move' as 'copy' | 'move', + value: { + id: '3', + humanData: { label: 'label3', position: 1 }, + }, + onDrop, + dropType: 'replace_compatible' as DropType, + order: [2, 0, 2, 0], + }, + { + draggable: true, + dragType: 'move' as 'copy' | 'move', + value: { + id: '4', + humanData: { label: 'label4', position: 2 }, + }, + order: [2, 0, 2, 1], + }, + ]; + const component = mount( + + {items.map((props) => ( + +
    + + ))} + + ); + const keyboardHandler = component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('focus'); + act(() => { + keyboardHandler.simulate('keydown', { key: 'ArrowRight' }); + expect(setActiveDropTarget).toBeCalledWith({ + ...items[2].value, + onDrop, + dropType: items[2].dropType, + }); + keyboardHandler.simulate('keydown', { key: 'Enter' }); + expect(setA11yMessage).toBeCalledWith( + 'Selected label3 in group at position 1. Press space or enter to replace label3 with label1.' + ); + expect(setActiveDropTarget).toBeCalledWith(undefined); + expect(onDrop).toBeCalledWith( + { humanData: { label: 'label1', position: 1 }, id: '1' }, + 'move_compatible' + ); + }); + }); + describe('reordering', () => { + const onDrop = jest.fn(); + const items = [ + { + id: '1', + humanData: { label: 'label1', position: 1 }, + onDrop, + dropType: 'reorder' as DropType, + }, + { + id: '2', + humanData: { label: 'label2', position: 2 }, + onDrop, + dropType: 'reorder' as DropType, + }, + { + id: '3', + humanData: { label: 'label3', position: 3 }, + onDrop, + dropType: 'reorder' as DropType, + }, + ]; const mountComponent = ( dragContext: Partial | undefined, - onDrop: DropHandler = jest.fn() + onDropHandler?: () => void ) => { let dragging = dragContext?.dragging; let keyboardMode = !!dragContext?.keyboardMode; let activeDropTarget = dragContext?.activeDropTarget; + + const setA11yMessage = jest.fn(); + const registerDropTarget = jest.fn(); const baseContext = { dragging, setDragging: (val?: DragDropIdentifier) => { @@ -280,70 +435,51 @@ describe('DragDrop', () => { keyboardMode = mode; }), setActiveDropTarget: (target?: DragDropIdentifier) => { - activeDropTarget = { activeDropTarget: target } as ActiveDropTarget; + activeDropTarget = { activeDropTarget: target } as DropTargets; }, activeDropTarget, - setA11yMessage: jest.fn(), + setA11yMessage, + registerDropTarget, + }; + + const dragDropSharedProps = { + draggable: true, + dragType: 'move' as 'copy' | 'move', + dropType: 'reorder' as DropType, + reorderableGroup: items.map(({ id }) => ({ id })), + onDrop: onDropHandler || onDrop, }; + return mount( 1 - + 2 - + 3 ); }; - test(`Inactive reorderable group renders properly`, () => { - const component = mountComponent(undefined, jest.fn()); - expect(component.find('.lnsDragDrop-reorderable')).toHaveLength(3); + test(`Inactive group renders properly`, () => { + const component = mountComponent(undefined); + expect(component.find('[data-test-subj="lnsDragDrop"]')).toHaveLength(3); }); test(`Reorderable group with lifted element renders properly`, () => { - const setDragging = jest.fn(); const setA11yMessage = jest.fn(); - const component = mountComponent( - { dragging: { id: '1' }, setA11yMessage, setDragging }, - jest.fn() - ); + const setDragging = jest.fn(); + const component = mountComponent({ dragging: items[0], setDragging, setA11yMessage }); act(() => { component .find('[data-test-subj="lnsDragDrop"]') @@ -352,8 +488,8 @@ describe('DragDrop', () => { jest.runAllTimers(); }); - expect(setDragging).toBeCalledWith({ id: '1' }); - expect(setA11yMessage).toBeCalledWith('You have lifted an item 1 in position 1'); + expect(setDragging).toBeCalledWith(items[0]); + expect(setA11yMessage).toBeCalledWith('Lifted label1'); expect( component .find('[data-test-subj="lnsDragDrop-reorderableGroup"]') @@ -362,7 +498,7 @@ describe('DragDrop', () => { }); test(`Reordered elements get extra styles to show the reorder effect when dragging`, () => { - const component = mountComponent({ dragging: { id: '1' } }, jest.fn()); + const component = mountComponent({ dragging: items[0] }); act(() => { component @@ -403,16 +539,13 @@ describe('DragDrop', () => { }); test(`Dropping an item runs onDrop function`, () => { - const setDragging = jest.fn(); - const setA11yMessage = jest.fn(); const preventDefault = jest.fn(); const stopPropagation = jest.fn(); - const onDrop = jest.fn(); - const component = mountComponent( - { dragging: { id: '1' }, setA11yMessage, setDragging }, - onDrop - ); + const setA11yMessage = jest.fn(); + const setDragging = jest.fn(); + + const component = mountComponent({ dragging: items[0], setDragging, setA11yMessage }); component .find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]') @@ -421,23 +554,58 @@ describe('DragDrop', () => { jest.runAllTimers(); expect(setA11yMessage).toBeCalledWith( - 'You have dropped the item. You have moved the item from position 1 to positon 3' + 'You have dropped the item label1. You have moved the item from position 1 to positon 3' ); expect(preventDefault).toBeCalled(); expect(stopPropagation).toBeCalled(); - expect(onDrop).toBeCalledWith({ id: '1' }, { id: '3' }); + expect(onDrop).toBeCalledWith(items[0], 'reorder'); }); - test(`Keyboard navigation: user can drop element to an activeDropTarget`, () => { - const onDrop = jest.fn(); - const component = mountComponent( - { - dragging: { id: '1' }, - activeDropTarget: { activeDropTarget: { id: '3' } } as ActiveDropTarget, - keyboardMode: true, + test(`Keyboard Navigation: User cannot move an element outside of the group`, () => { + const setA11yMessage = jest.fn(); + const setActiveDropTarget = jest.fn(); + const component = mountComponent({ + dragging: items[0], + keyboardMode: true, + activeDropTarget: { + activeDropTarget: undefined, + dropTargetsByOrder: { + '2,0,0': undefined, + '2,0,1': { ...items[1], onDrop, dropType: 'reorder' }, + '2,0,2': { ...items[2], onDrop, dropType: 'reorder' }, + }, }, - onDrop + setActiveDropTarget, + setA11yMessage, + }); + const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); + + keyboardHandler.simulate('keydown', { key: 'Space' }); + keyboardHandler.simulate('keydown', { key: 'ArrowUp' }); + expect(setActiveDropTarget).not.toHaveBeenCalled(); + + keyboardHandler.simulate('keydown', { key: 'Space' }); + keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); + + expect(setActiveDropTarget).toBeCalledWith(items[1]); + expect(setA11yMessage).toBeCalledWith( + 'You have moved the item label1 from position 1 to position 2' ); + }); + test(`Keyboard navigation: user can drop element to an activeDropTarget`, () => { + const component = mountComponent({ + dragging: items[0], + activeDropTarget: { + activeDropTarget: { ...items[2], dropType: 'reorder', onDrop }, + dropTargetsByOrder: { + '2,0,0': { ...items[0], onDrop, dropType: 'reorder' }, + '2,0,1': { ...items[1], onDrop, dropType: 'reorder' }, + '2,0,2': { ...items[2], onDrop, dropType: 'reorder' }, + }, + }, + + keyboardMode: true, + }); const keyboardHandler = component .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') .simulate('focus'); @@ -447,15 +615,43 @@ describe('DragDrop', () => { keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); keyboardHandler.simulate('keydown', { key: 'Enter' }); }); - expect(onDrop).toBeCalledWith({ id: '1' }, { id: '3' }); + expect(onDrop).toBeCalledWith(items[0], 'reorder'); + }); + + test(`Keyboard Navigation: Doesn't call onDrop when movement is cancelled`, () => { + const setA11yMessage = jest.fn(); + const onDropHandler = jest.fn(); + const component = mountComponent({ dragging: items[0], setA11yMessage }, onDropHandler); + const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); + keyboardHandler.simulate('keydown', { key: 'Space' }); + keyboardHandler.simulate('keydown', { key: 'Escape' }); + jest.runAllTimers(); + + expect(onDropHandler).not.toHaveBeenCalled(); + expect(setA11yMessage).toBeCalledWith('Movement cancelled'); + keyboardHandler.simulate('keydown', { key: 'Space' }); + keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); + keyboardHandler.simulate('blur'); + + expect(onDropHandler).not.toHaveBeenCalled(); + expect(setA11yMessage).toBeCalledWith('Movement cancelled'); }); test(`Keyboard Navigation: Reordered elements get extra styles to show the reorder effect`, () => { const setA11yMessage = jest.fn(); - const component = mountComponent( - { dragging: { id: '1' }, keyboardMode: true, setA11yMessage }, - jest.fn() - ); + const component = mountComponent({ + dragging: items[0], + keyboardMode: true, + activeDropTarget: { + activeDropTarget: undefined, + dropTargetsByOrder: { + '2,0,0': undefined, + '2,0,1': { ...items[1], onDrop, dropType: 'reorder' }, + '2,0,2': { ...items[2], onDrop, dropType: 'reorder' }, + }, + }, + setA11yMessage, + }); const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); keyboardHandler.simulate('keydown', { key: 'Space' }); @@ -475,7 +671,7 @@ describe('DragDrop', () => { component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(1).prop('style') ).toEqual(undefined); expect(setA11yMessage).toBeCalledWith( - 'You have moved the item 1 from position 1 to position 2' + 'You have moved the item label1 from position 1 to position 2' ); component @@ -490,63 +686,45 @@ describe('DragDrop', () => { ).toEqual(undefined); }); - test(`Keyboard Navigation: User cannot move an element outside of the group`, () => { - const onDrop = jest.fn(); - const setActiveDropTarget = jest.fn(); - const setA11yMessage = jest.fn(); - const component = mountComponent( - { dragging: { id: '1' }, keyboardMode: true, setActiveDropTarget, setA11yMessage }, - onDrop - ); - const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); - - keyboardHandler.simulate('keydown', { key: 'Space' }); - keyboardHandler.simulate('keydown', { key: 'ArrowUp' }); - expect(setActiveDropTarget).not.toHaveBeenCalled(); - - keyboardHandler.simulate('keydown', { key: 'Space' }); - keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); - - expect(setActiveDropTarget).toBeCalledWith({ id: '2' }); - expect(setA11yMessage).toBeCalledWith( - 'You have moved the item 1 from position 1 to position 2' - ); - }); - test(`Keyboard Navigation: User cannot drop element to itself`, () => { - const setActiveDropTarget = jest.fn(); const setA11yMessage = jest.fn(); + const setActiveDropTarget = jest.fn(); const component = mount( 1 2 @@ -557,33 +735,8 @@ describe('DragDrop', () => { keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'ArrowUp' }); - expect(setActiveDropTarget).toBeCalledWith({ id: '1' }); - expect(setA11yMessage).toBeCalledWith('You have moved back the item 1 to position 1'); - }); - - test(`Keyboard Navigation: Doesn't call onDrop when movement is cancelled`, () => { - const setA11yMessage = jest.fn(); - const onDrop = jest.fn(); - - const component = mountComponent({ dragging: { id: '1' }, setA11yMessage }, onDrop); - const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); - keyboardHandler.simulate('keydown', { key: 'Space' }); - keyboardHandler.simulate('keydown', { key: 'Escape' }); - - jest.runAllTimers(); - - expect(onDrop).not.toHaveBeenCalled(); - expect(setA11yMessage).toBeCalledWith( - 'Movement cancelled. The item has returned to its starting position 1' - ); - keyboardHandler.simulate('keydown', { key: 'Space' }); - keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); - keyboardHandler.simulate('blur'); - - expect(onDrop).not.toHaveBeenCalled(); - expect(setA11yMessage).toBeCalledWith( - 'Movement cancelled. The item has returned to its starting position 1' - ); + expect(setActiveDropTarget).toBeCalledWith(undefined); + expect(setA11yMessage).toBeCalledWith('You have moved the item label1 back to position 1'); }); }); }); 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 e006e4f5af49e7..898071e85ea79a 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -9,23 +9,23 @@ import './drag_drop.scss'; import React, { useContext, useEffect, memo } from 'react'; import classNames from 'classnames'; import { keys, EuiScreenReaderOnly } from '@elastic/eui'; +import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect'; import { DragDropIdentifier, + DropIdentifier, DragContext, DragContextState, + nextValidDropTarget, ReorderContext, ReorderState, - reorderAnnouncements, + DropHandler, } from './providers'; +import { announce } from './announcements'; import { trackUiEvent } from '../lens_ui_telemetry'; +import { DropType } from '../types'; export type DroppableEvent = React.DragEvent; -/** - * A function that handles a drop event. - */ -export type DropHandler = (dropped: DragDropIdentifier, dropTarget: DragDropIdentifier) => void; - /** * The base props to the DragDrop component. */ @@ -34,10 +34,6 @@ interface BaseProps { * The CSS class(es) for the root element. */ className?: string; - /** - * The label for accessibility - */ - label?: string; /** * The event handler that fires when an item @@ -62,16 +58,15 @@ interface BaseProps { * Indicates whether or not this component is draggable. */ draggable?: boolean; - /** - * Indicates whether or not the currently dragged item - * can be dropped onto this component. - */ - droppable?: boolean; /** * Additional class names to apply when another element is over the drop target */ - getAdditionalClassesOnEnter?: () => string; + getAdditionalClassesOnEnter?: (dropType?: DropType) => string | undefined; + /** + * Additional class names to apply when another element is droppable for a currently dragged item + */ + getAdditionalClassesOnDroppable?: (dropType?: DropType) => string | undefined; /** * The optional test subject associated with this DOM element. @@ -81,35 +76,29 @@ interface BaseProps { /** * items belonging to the same group that can be reordered */ - reorderableGroup?: DragDropIdentifier[]; + reorderableGroup?: Array<{ id: string }>; /** * Indicates to the user whether the currently dragged item * will be moved or copied */ - dragType?: 'copy' | 'move' | 'reorder'; + dragType?: 'copy' | 'move'; /** - * Indicates to the user whether the drop action will - * replace something that is existing or add a new one + * Indicates the type of a drop - when undefined, the currently dragged item + * cannot be dropped onto this component. */ - dropType?: 'add' | 'replace' | 'reorder'; - + dropType?: DropType; /** - * temporary flag to exclude the draggable elements that don't have keyboard nav yet. To be removed along with the feature development + * Order for keyboard dragging. This takes an array of numbers which will be used to order hierarchically */ - noKeyboardSupportYet?: boolean; + order: number[]; } /** * The props for a draggable instance of that component. */ interface DragInnerProps extends BaseProps { - /** - * The label, which should be attached to the drag event, and which will e.g. - * be used if the element will be dropped into a text field. - */ - label?: string; isDragging: boolean; keyboardMode: boolean; setKeyboardMode: DragContextState['setKeyboardMode']; @@ -124,6 +113,7 @@ interface DragInnerProps extends BaseProps { ) => void; onDragEnd?: () => void; extraKeyboardHandler?: (e: React.KeyboardEvent) => void; + ariaDescribedBy?: string; } /** @@ -131,23 +121,16 @@ interface DragInnerProps extends BaseProps { */ interface DropInnerProps extends BaseProps, DragContextState { isDragging: boolean; - isNotDroppable: boolean; } -/** - * A draggable / droppable item. Items can be both draggable and droppable at - * the same time. - * - * @param props - */ - const lnsLayerPanelDimensionMargin = 8; export const DragDrop = (props: BaseProps) => { const { dragging, setDragging, + registerDropTarget, keyboardMode, setKeyboardMode, activeDropTarget, @@ -155,8 +138,7 @@ export const DragDrop = (props: BaseProps) => { setA11yMessage, } = useContext(DragContext); - const { value, draggable, droppable, reorderableGroup } = props; - + const { value, draggable, dropType, reorderableGroup } = props; const isDragging = !!(draggable && value.id === dragging?.id); const dragProps = { @@ -178,16 +160,17 @@ export const DragDrop = (props: BaseProps) => { setDragging, activeDropTarget, setActiveDropTarget, + registerDropTarget, isDragging, setA11yMessage, isNotDroppable: // If the configuration has provided a droppable flag, but this particular item is not // droppable, then it should be less prominent. Ignores items that are both // draggable and drop targets - !!(droppable === false && dragging && value.id !== dragging.id), + !!(!dropType && dragging && value.id !== dragging.id), }; - if (draggable && !droppable) { + if (draggable && !dropType) { if (reorderableGroup && reorderableGroup.length > 1) { return ( { if ( reorderableGroup && reorderableGroup.length > 1 && - reorderableGroup?.some((i) => i.id === value.id) + reorderableGroup?.some((i) => i.id === dragging?.id) ) { - return ; + return ; } return ; }; -const DragInner = memo(function DragDropInner({ +const DragInner = memo(function DragInner({ dataTestSubj, className, value, @@ -219,16 +202,16 @@ const DragInner = memo(function DragDropInner({ setDragging, setKeyboardMode, setActiveDropTarget, - label = '', + order, keyboardMode, isDragging, activeDropTarget, - onDrop, dragType, onDragStart, onDragEnd, extraKeyboardHandler, - noKeyboardSupportYet, + ariaDescribedBy, + setA11yMessage, }: DragInnerProps) { const dragStart = (e?: DroppableEvent | React.KeyboardEvent) => { // Setting stopPropgagation causes Chrome failures, so @@ -241,7 +224,7 @@ const DragInner = memo(function DragDropInner({ // We only can reach the dragStart method if the element is draggable, // so we know we have DraggableProps if we reach this code. if (e && 'dataTransfer' in e) { - e.dataTransfer.setData('text', label); + e.dataTransfer.setData('text', value.humanData.label); } // Chrome causes issues if you try to render from within a @@ -250,6 +233,7 @@ const DragInner = memo(function DragDropInner({ const currentTarget = e?.currentTarget; setTimeout(() => { setDragging(value); + setA11yMessage(announce.lifted(value.humanData)); if (onDragStart) { onDragStart(currentTarget); } @@ -261,53 +245,78 @@ const DragInner = memo(function DragDropInner({ setDragging(undefined); setActiveDropTarget(undefined); setKeyboardMode(false); + setA11yMessage(announce.cancelled()); if (onDragEnd) { onDragEnd(); } }; - const dropToActiveDropTarget = () => { if (isDragging && activeDropTarget?.activeDropTarget) { trackUiEvent('drop_total'); - if (onDrop) { - onDrop(value, activeDropTarget.activeDropTarget); - } + const { dropType, humanData, onDrop: onTargetDrop } = activeDropTarget.activeDropTarget; + setTimeout(() => setA11yMessage(announce.dropped(value.humanData, humanData, dropType))); + onTargetDrop(value, dropType); } }; + const setNextTarget = (reversed = false) => { + if (!order) { + return; + } + + const nextTarget = nextValidDropTarget( + activeDropTarget, + [order.join(',')], + (el) => el?.dropType !== 'reorder', + reversed + ); + + setActiveDropTarget(nextTarget); + setA11yMessage( + nextTarget + ? announce.selectedTarget(value.humanData, nextTarget?.humanData, nextTarget?.dropType) + : announce.noTarget() + ); + }; return ( -
    - {!noKeyboardSupportYet && ( - -
    ); }); const ReorderableDrop = memo(function ReorderableDrop( - props: DropInnerProps & { reorderableGroup: DragDropIdentifier[] } + props: DropInnerProps & { reorderableGroup: Array<{ id: string }> } ) { const { onDrop, value, - droppable, dragging, setDragging, setKeyboardMode, @@ -595,6 +606,7 @@ const ReorderableDrop = memo(function ReorderableDrop( setActiveDropTarget, reorderableGroup, setA11yMessage, + dropType, } = props; const currentIndex = reorderableGroup.findIndex((i) => i.id === value.id); @@ -628,15 +640,14 @@ const ReorderableDrop = memo(function ReorderableDrop( }, [isReordered, setReorderState, value.id]); const onReorderableDragOver = (e: DroppableEvent) => { - if (!droppable) { + if (!dropType) { return; } e.preventDefault(); // An optimization to prevent a bunch of React churn. - // todo: replace with custom function ? - if (!activeDropTargetMatches) { - setActiveDropTarget(value); + if (!activeDropTargetMatches && dropType && onDrop) { + setActiveDropTarget({ ...value, dropType, onDrop }); } const draggingIndex = reorderableGroup.findIndex((i) => i.id === dragging?.id); @@ -675,14 +686,12 @@ const ReorderableDrop = memo(function ReorderableDrop( setDragging(undefined); setKeyboardMode(false); - if (onDrop && droppable && dragging) { + if (onDrop && dropType && dragging) { trackUiEvent('drop_total'); - - onDrop(dragging, value); - const draggingIndex = reorderableGroup.findIndex((i) => i.id === dragging.id); + onDrop(dragging, 'reorder'); // setTimeout ensures it will run after dragEnd messaging setTimeout(() => - setA11yMessage(reorderAnnouncements.dropped(currentIndex + 1, draggingIndex + 1)) + setA11yMessage(announce.dropped(dragging.humanData, value.humanData, 'reorder')) ); } }; @@ -707,7 +716,7 @@ const ReorderableDrop = memo(function ReorderableDrop(
    void; export type DragDropIdentifier = Record & { id: string; + /** + * The data for accessibility, consists of required label and not required groupLabel and position in group + */ + humanData: HumanData; }; -export interface ActiveDropTarget { - activeDropTarget?: DragDropIdentifier; +export type DropIdentifier = DragDropIdentifier & { + dropType: DropType; + onDrop: DropHandler; +}; + +export interface DropTargets { + activeDropTarget?: DropIdentifier; + dropTargetsByOrder: Record; } /** * The shape of the drag / drop context. @@ -39,11 +56,12 @@ export interface DragContextState { */ setDragging: (dragging?: DragDropIdentifier) => void; - activeDropTarget?: ActiveDropTarget; + activeDropTarget?: DropTargets; - setActiveDropTarget: (newTarget?: DragDropIdentifier) => void; + setActiveDropTarget: (newTarget?: DropIdentifier) => void; setA11yMessage: (message: string) => void; + registerDropTarget: (order: number[], dropTarget?: DropIdentifier) => void; } /** @@ -59,6 +77,7 @@ export const DragContext = React.createContext({ activeDropTarget: undefined, setActiveDropTarget: () => {}, setA11yMessage: () => {}, + registerDropTarget: () => {}, }); /** @@ -89,10 +108,13 @@ export interface ProviderProps { setDragging: (dragging?: DragDropIdentifier) => void; activeDropTarget?: { - activeDropTarget?: DragDropIdentifier; + activeDropTarget?: DropIdentifier; + dropTargetsByOrder: Record; }; - setActiveDropTarget: (newTarget?: DragDropIdentifier) => void; + setActiveDropTarget: (newTarget?: DropIdentifier) => void; + + registerDropTarget: (order: number[], dropTarget?: DropIdentifier) => void; /** * The React children. @@ -116,9 +138,11 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode } const [keyboardModeState, setKeyboardModeState] = useState(false); const [a11yMessageState, setA11yMessageState] = useState(''); const [activeDropTargetState, setActiveDropTargetState] = useState<{ - activeDropTarget?: DragDropIdentifier; + activeDropTarget?: DropIdentifier; + dropTargetsByOrder: Record; }>({ activeDropTarget: undefined, + dropTargetsByOrder: {}, }); const setDragging = useMemo( @@ -131,11 +155,26 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode } ]); const setActiveDropTarget = useMemo( - () => (activeDropTarget?: DragDropIdentifier) => + () => (activeDropTarget?: DropIdentifier) => setActiveDropTargetState((s) => ({ ...s, activeDropTarget })), [setActiveDropTargetState] ); + const registerDropTarget = useMemo( + () => (order: number[], dropTarget?: DropIdentifier) => { + return setActiveDropTargetState((s) => { + return { + ...s, + dropTargetsByOrder: { + ...s.dropTargetsByOrder, + [order.join(',')]: dropTarget, + }, + }; + }); + }, + [setActiveDropTargetState] + ); + return (
    {children} @@ -155,9 +195,14 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode }

    {a11yMessageState}

    +

    + {i18n.translate('xpack.lens.dragDrop.keyboardInstructionsReorder', { + defaultMessage: `Press enter or space to dragging. When dragging, use the up/down arrow keys to reorder items in the group and left/right arrow keys to choose drop targets outside of the group. Press enter or space again to finish.`, + })} +

    {i18n.translate('xpack.lens.dragDrop.keyboardInstructions', { - defaultMessage: `Press enter or space to start reordering the dimension group. When dragging, use arrow keys to reorder. Press enter or space again to finish.`, + defaultMessage: `Press enter or space to start dragging. When dragging, use the left/right arrow keys to move between drop targets. Press enter or space again to finish.`, })}

    @@ -167,6 +212,45 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode } ); } +export function nextValidDropTarget( + activeDropTarget: DropTargets | undefined, + draggingOrder: [string], + filterElements: (el: DragDropIdentifier) => boolean = () => true, + reverse = false +) { + if (!activeDropTarget) { + return; + } + + const filteredTargets = [...Object.entries(activeDropTarget.dropTargetsByOrder)].filter( + ([, dropTarget]) => dropTarget && filterElements(dropTarget) + ); + + const nextDropTargets = [...filteredTargets, draggingOrder].sort(([orderA], [orderB]) => { + const parsedOrderA = orderA.split(',').map((v) => Number(v)); + const parsedOrderB = orderB.split(',').map((v) => Number(v)); + + const relevantLevel = parsedOrderA.findIndex((v, i) => parsedOrderA[i] !== parsedOrderB[i]); + return parsedOrderA[relevantLevel] - parsedOrderB[relevantLevel]; + }); + + let currentActiveDropIndex = nextDropTargets.findIndex( + ([_, dropTarget]) => dropTarget?.id === activeDropTarget?.activeDropTarget?.id + ); + + if (currentActiveDropIndex === -1) { + currentActiveDropIndex = nextDropTargets.findIndex( + ([targetOrder]) => targetOrder === draggingOrder[0] + ); + } + + const previousElement = + (nextDropTargets.length + currentActiveDropIndex - 1) % nextDropTargets.length; + const nextElement = (currentActiveDropIndex + 1) % nextDropTargets.length; + + return nextDropTargets[reverse ? previousElement : nextElement][1]; +} + /** * A React drag / drop provider that derives its state from a RootDragDropProvider. If * part of a React application is rendered separately from the root, this provider can @@ -182,6 +266,7 @@ export function ChildDragDropProvider({ activeDropTarget, setActiveDropTarget, setA11yMessage, + registerDropTarget, children, }: ProviderProps) { const value = useMemo( @@ -193,6 +278,7 @@ export function ChildDragDropProvider({ activeDropTarget, setActiveDropTarget, setA11yMessage, + registerDropTarget, }), [ setDragging, @@ -202,6 +288,7 @@ export function ChildDragDropProvider({ setKeyboardMode, keyboardMode, setA11yMessage, + registerDropTarget, ] ); return {children}; @@ -211,7 +298,7 @@ export interface ReorderState { /** * Ids of the elements that are translated up or down */ - reorderedItems: DragDropIdentifier[]; + reorderedItems: Array<{ id: string; height?: number }>; /** * Direction of the move of dragged element in the reordered list @@ -282,51 +369,3 @@ export function ReorderProvider({
    ); } - -export const reorderAnnouncements = { - moved: (itemLabel: string, position: number, prevPosition: number) => { - return prevPosition === position - ? i18n.translate('xpack.lens.dragDrop.elementMovedBack', { - defaultMessage: `You have moved back the item {itemLabel} to position {prevPosition}`, - values: { - itemLabel, - prevPosition, - }, - }) - : i18n.translate('xpack.lens.dragDrop.elementMoved', { - defaultMessage: `You have moved the item {itemLabel} from position {prevPosition} to position {position}`, - values: { - itemLabel, - position, - prevPosition, - }, - }); - }, - - lifted: (itemLabel: string, position: number) => - i18n.translate('xpack.lens.dragDrop.elementLifted', { - defaultMessage: `You have lifted an item {itemLabel} in position {position}`, - values: { - itemLabel, - position, - }, - }), - - cancelled: (position: number) => - i18n.translate('xpack.lens.dragDrop.abortMessageReorder', { - defaultMessage: - 'Movement cancelled. The item has returned to its starting position {position}', - values: { - position, - }, - }), - dropped: (position: number, prevPosition: number) => - i18n.translate('xpack.lens.dragDrop.dropMessageReorder', { - defaultMessage: - 'You have dropped the item. You have moved the item from position {prevPosition} to positon {position}', - values: { - position, - prevPosition, - }, - }), -}; diff --git a/x-pack/plugins/lens/public/drag_drop/readme.md b/x-pack/plugins/lens/public/drag_drop/readme.md index e48564a0749869..55a9e3157c2471 100644 --- a/x-pack/plugins/lens/public/drag_drop/readme.md +++ b/x-pack/plugins/lens/public/drag_drop/readme.md @@ -56,7 +56,7 @@ const { dragging } = useContext(DragContext); return ( onChange([...items, item])} > {items.map((x) => ( @@ -86,11 +86,14 @@ The children `DragDrop` components must have props defined as in the example: key={f.id} draggable droppable - dragType="reorder" + dragType="move" dropType="reorder" reorderableGroup={fields} // consists all reorderable elements in the group, eg. [{id:'3'}, {id:'5'}, {id:'1'}] value={{ id: f.id, + humanData: { + label: 'Label' + } }} onDrop={/*handler*/} > diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx index e3a30883a2209c..a3d5c6fd22fcd5 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx @@ -49,6 +49,16 @@ export function ColorIndicator({ })} /> )} + {accessorConfig.triggerIcon === 'invisible' && ( + + )} {accessorConfig.triggerIcon === 'colorBy' && ( - el2 && isDraggedOperation(el2) && el1.groupId === el2.groupId && el1.columnId !== el2.columnId; +const getAdditionalClassesOnEnter = (dropType?: string) => { + if ( + dropType === 'field_replace' || + dropType === 'replace_compatible' || + dropType === 'replace_incompatible' + ) { + return 'lnsDragDrop-isReplacing'; + } +}; -const isSelf = (el1: DragDropIdentifier, el2?: DragDropIdentifier) => - isDraggedOperation(el2) && el1.columnId === el2.columnId; +const getAdditionalClassesOnDroppable = (dropType?: string) => { + if (dropType === 'move_incompatible' || dropType === 'replace_incompatible') { + return 'lnsDragDrop-notCompatible'; + } +}; export function DraggableDimensionButton({ layerId, @@ -34,7 +49,11 @@ export function DraggableDimensionButton({ layerId: string; groupIndex: number; layerIndex: number; - onDrop: (droppedItem: DragDropIdentifier, dropTarget: DragDropIdentifier) => void; + onDrop: ( + droppedItem: DragDropIdentifier, + dropTarget: DragDropIdentifier, + dropType?: DropType + ) => void; group: VisualizationDimensionGroupConfig; label: string; children: React.ReactElement; @@ -43,66 +62,52 @@ export function DraggableDimensionButton({ accessorIndex: number; columnId: string; }) { - const value = useMemo(() => { - return { + const dropType = layerDatasource.getDropTypes({ + ...layerDatasourceDropProps, + columnId, + filterOperations: group.filterOperations, + groupId: group.groupId, + }); + + const value = useMemo( + () => ({ columnId, groupId: group.groupId, layerId, id: columnId, - }; - }, [columnId, group.groupId, layerId]); - - const { dragging } = dragDropContext; - - const isCurrentGroup = group.groupId === dragging?.groupId; - const isOperationDragged = isDraggedOperation(dragging); - const canHandleDrop = - Boolean(dragDropContext.dragging) && - layerDatasource.canHandleDrop({ - ...layerDatasourceDropProps, - columnId, - filterOperations: group.filterOperations, - }); - - const dragType = isSelf(value, dragging) - ? 'move' - : isOperationDragged && isCurrentGroup - ? 'reorder' - : 'copy'; - - const dropType = isOperationDragged ? (!isCurrentGroup ? 'replace' : 'reorder') : 'add'; - - const isCompatibleFromOtherGroup = !isCurrentGroup && canHandleDrop; - - const isDroppable = isOperationDragged - ? dragType === 'reorder' - ? isFromTheSameGroup(value, dragging) - : isCompatibleFromOtherGroup - : canHandleDrop; + dropType, + humanData: { + label, + groupLabel: group.groupLabel, + position: accessorIndex + 1, + }, + }), + [columnId, group.groupId, accessorIndex, layerId, dropType, label, group.groupLabel] + ); + // todo: simplify by id and use drop targets? const reorderableGroup = useMemo( () => - group.accessors.map((a) => ({ - columnId: a.columnId, - id: a.columnId, - groupId: group.groupId, - layerId, + group.accessors.map((g) => ({ + id: g.columnId, })), - [group, layerId] + [group.accessors] ); return (
    1 ? reorderableGroup : undefined} value={value} - label={label} - droppable={dragging && isDroppable} - onDrop={onDrop} + onDrop={(drag: DragDropIdentifier, selectedDropType?: DropType) => + onDrop(drag, value, selectedDropType) + } > {children} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx index 1116cef1aa3ef2..a83d4bde0383c6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx @@ -5,17 +5,26 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useMemo, useState, useEffect } from 'react'; import { EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { generateId } from '../../../id_generator'; -import { DragDrop, DragDropIdentifier, DragContextState } from '../../../drag_drop'; -import { Datasource, VisualizationDimensionGroupConfig, isDraggedOperation } from '../../../types'; +import { DragDrop, DragDropIdentifier } from '../../../drag_drop'; +import { Datasource, VisualizationDimensionGroupConfig, DropType } from '../../../types'; import { LayerDatasourceDropProps } from './types'; +const label = i18n.translate('xpack.lens.indexPattern.emptyDimensionButton', { + defaultMessage: 'Empty dimension', +}); + +const getAdditionalClassesOnDroppable = (dropType?: string) => { + if (dropType === 'move_incompatible' || dropType === 'replace_incompatible') { + return 'lnsDragDrop-notCompatible'; + } +}; + export function EmptyDimensionButton({ - dragDropContext, group, layerDatasource, layerDatasourceDropProps, @@ -25,48 +34,58 @@ export function EmptyDimensionButton({ onClick, onDrop, }: { - dragDropContext: DragContextState; layerId: string; groupIndex: number; layerIndex: number; onClick: (id: string) => void; - onDrop: (droppedItem: DragDropIdentifier, dropTarget: DragDropIdentifier) => void; + onDrop: ( + droppedItem: DragDropIdentifier, + dropTarget: DragDropIdentifier, + dropType?: DropType + ) => void; group: VisualizationDimensionGroupConfig; - layerDatasource: Datasource; layerDatasourceDropProps: LayerDatasourceDropProps; }) { - const handleDrop = (droppedItem: DragDropIdentifier) => onDrop(droppedItem, value); + const itemIndex = group.accessors.length; - const value = useMemo(() => { - const newId = generateId(); - return { - columnId: newId, + const [newColumnId, setNewColumnId] = useState(generateId()); + useEffect(() => { + setNewColumnId(generateId()); + }, [itemIndex]); + + const dropType = layerDatasource.getDropTypes({ + ...layerDatasourceDropProps, + columnId: newColumnId, + filterOperations: group.filterOperations, + groupId: group.groupId, + }); + + const value = useMemo( + () => ({ + columnId: newColumnId, groupId: group.groupId, layerId, - isNew: true, - id: newId, - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [group.accessors.length, group.groupId, layerId]); + id: newColumnId, + dropType, + humanData: { + label, + groupLabel: group.groupLabel, + position: itemIndex + 1, + }, + }), + [dropType, newColumnId, group.groupId, layerId, group.groupLabel, itemIndex] + ); return (
    onDrop(droppedItem, value, selectedDropType)} + dropType={dropType} >
    {}, setA11yMessage: jest.fn(), + registerDropTarget: jest.fn(), }; describe('LayerPanel', () => { @@ -224,7 +225,7 @@ describe('LayerPanel', () => { }); it('should not update the visualization if the datasource is incomplete', () => { - (generateId as jest.Mock).mockReturnValueOnce(`newid`); + (generateId as jest.Mock).mockReturnValue(`newid`); const updateAll = jest.fn(); const updateDatasource = jest.fn(); @@ -439,9 +440,14 @@ describe('LayerPanel', () => { ], }); - mockDatasource.canHandleDrop.mockReturnValue(true); + mockDatasource.getDropTypes.mockReturnValue('field_add'); - const draggingField = { field: { name: 'dragged' }, indexPatternId: 'a', id: '1' }; + const draggingField = { + field: { name: 'dragged' }, + indexPatternId: 'a', + id: '1', + humanData: { label: 'Label' }, + }; const component = mountWithIntl( @@ -449,7 +455,7 @@ describe('LayerPanel', () => { ); - expect(mockDatasource.canHandleDrop).toHaveBeenCalledWith( + expect(mockDatasource.getDropTypes).toHaveBeenCalledWith( expect.objectContaining({ dragDropContext: expect.objectContaining({ dragging: draggingField, @@ -482,9 +488,16 @@ describe('LayerPanel', () => { ], }); - mockDatasource.canHandleDrop.mockImplementation(({ columnId }) => columnId !== 'a'); + mockDatasource.getDropTypes.mockImplementation(({ columnId }) => + columnId !== 'a' ? 'field_replace' : undefined + ); - const draggingField = { field: { name: 'dragged' }, indexPatternId: 'a', id: '1' }; + const draggingField = { + field: { name: 'dragged' }, + indexPatternId: 'a', + id: '1', + humanData: { label: 'Label' }, + }; const component = mountWithIntl( @@ -492,13 +505,13 @@ describe('LayerPanel', () => { ); - expect(mockDatasource.canHandleDrop).toHaveBeenCalledWith( + expect(mockDatasource.getDropTypes).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'a' }) ); expect( - component.find('[data-test-subj="lnsGroup"] DragDrop').first().prop('droppable') - ).toEqual(false); + component.find('[data-test-subj="lnsGroup"] DragDrop').first().prop('dropType') + ).toEqual(undefined); component .find('[data-test-subj="lnsGroup"] DragDrop') @@ -533,9 +546,15 @@ describe('LayerPanel', () => { ], }); - mockDatasource.canHandleDrop.mockReturnValue(true); + mockDatasource.getDropTypes.mockReturnValue('replace_compatible'); - const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a', id: 'a' }; + const draggingOperation = { + layerId: 'first', + columnId: 'a', + groupId: 'a', + id: 'a', + humanData: { label: 'Label' }, + }; const component = mountWithIntl( @@ -543,7 +562,7 @@ describe('LayerPanel', () => { ); - expect(mockDatasource.canHandleDrop).toHaveBeenCalledWith( + expect(mockDatasource.getDropTypes).toHaveBeenCalledWith( expect.objectContaining({ dragDropContext: expect.objectContaining({ dragging: draggingOperation, @@ -588,7 +607,13 @@ describe('LayerPanel', () => { ], }); - const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a', id: 'a' }; + const draggingOperation = { + layerId: 'first', + columnId: 'a', + groupId: 'a', + id: 'a', + humanData: { label: 'Label' }, + }; const component = mountWithIntl( @@ -596,15 +621,10 @@ describe('LayerPanel', () => { ); - component.find(DragDrop).at(1).prop('onDrop')!(draggingOperation, { - layerId: 'first', - columnId: 'b', - groupId: 'a', - id: 'b', - }); + component.find(DragDrop).at(1).prop('onDrop')!(draggingOperation, 'reorder'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ - groupId: 'a', + dropType: 'reorder', droppedItem: draggingOperation, }) ); @@ -624,22 +644,24 @@ describe('LayerPanel', () => { ], }); - const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a', id: 'a' }; + const draggingOperation = { + layerId: 'first', + columnId: 'a', + groupId: 'a', + id: 'a', + humanData: { label: 'Label' }, + }; const component = mountWithIntl( ); - - component.find('[data-test-subj="lnsGroup"] DragDrop').at(2).prop('onDrop')!( - (draggingOperation as unknown) as DroppableEvent - ); + component.find(DragDrop).at(2).prop('onDrop')!(draggingOperation, 'duplicate_in_group'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ - groupId: 'a', + dropType: 'duplicate_in_group', droppedItem: draggingOperation, - isNew: true, }) ); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index bfdd3ec3bb59ae..80e9ed05b982d0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -11,7 +11,7 @@ import React, { useContext, useState, useEffect, useMemo, useCallback } from 're import { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { NativeRenderer } from '../../../native_renderer'; -import { StateSetter, Visualization } from '../../../types'; +import { StateSetter, Visualization, DraggedOperation, DropType } from '../../../types'; import { DragContext, DragDropIdentifier, @@ -115,13 +115,19 @@ export function LayerPanel( const layerDatasourceOnDrop = layerDatasource.onDrop; const onDrop = useMemo(() => { - return (droppedItem: DragDropIdentifier, targetItem: DragDropIdentifier) => { - const { columnId, groupId, layerId: targetLayerId, isNew } = (targetItem as unknown) as { - groupId: string; - columnId: string; - layerId: string; - isNew?: boolean; - }; + return ( + droppedItem: DragDropIdentifier, + targetItem: DragDropIdentifier, + dropType?: DropType + ) => { + if (!dropType) { + return; + } + const { + columnId, + groupId, + layerId: targetLayerId, + } = (targetItem as unknown) as DraggedOperation; // TODO: correct misleading name const filterOperations = groups.find(({ groupId: gId }) => gId === targetItem.groupId)?.filterOperations || @@ -131,10 +137,9 @@ export function LayerPanel( ...layerDatasourceDropProps, droppedItem, columnId, - groupId, layerId: targetLayerId, - isNew, filterOperations, + dropType, }); if (dropResult) { updateVisualization( @@ -317,7 +322,6 @@ export function LayerPanel( {group.supportsMoreColumns ? ( { getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()], renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => { if (!dragging || dragging.id !== 'draggedField') { - setDragging({ id: 'draggedField' }); + setDragging({ id: 'draggedField', humanData: { label: 'draggedField' } }); } }, }, @@ -1344,8 +1344,9 @@ describe('editor_frame', () => { indexPatternId: '1', field: {}, id: '1', + humanData: { label: 'draggedField' }, }, - { id: 'lnsWorkspace' } + 'field_replace' ); }); @@ -1424,7 +1425,7 @@ describe('editor_frame', () => { getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()], renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => { if (!dragging || dragging.id !== 'draggedField') { - setDragging({ id: 'draggedField' }); + setDragging({ id: 'draggedField', humanData: { label: '1' } }); } }, }, @@ -1445,8 +1446,11 @@ describe('editor_frame', () => { indexPatternId: '1', field: {}, id: '1', + humanData: { + label: 'label', + }, }, - { id: 'lnsWorkspace' } + 'field_replace' ); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts index bc2abb694eefe6..0e8c9b962b9959 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts @@ -532,7 +532,7 @@ describe('suggestion helpers', () => { { mockindexpattern: { state: mockDatasourceState, isLoading: false }, }, - { id: 'myfield' }, + { id: 'myfield', humanData: { label: 'myfieldLabel' } }, ]; }); @@ -543,6 +543,9 @@ describe('suggestion helpers', () => { mockDatasourceState, { id: 'myfield', + humanData: { + label: 'myfieldLabel', + }, } ); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx index e3385f504763c7..48aa56efdb3cc2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx @@ -775,7 +775,7 @@ describe('workspace_panel', () => { let mockGetSuggestionForField: jest.Mock; let frame: jest.Mocked; - const draggedField = { id: 'field' }; + const draggedField = { id: 'field', humanData: { label: 'Label' } }; beforeEach(() => { frame = createMockFramePublicAPI(); @@ -793,6 +793,7 @@ describe('workspace_panel', () => { keyboardMode={false} setKeyboardMode={() => {}} setA11yMessage={() => {}} + registerDropTarget={jest.fn()} > { }); initComponent(); - instance.find(DragDrop).prop('onDrop')!(draggedField, { id: 'lnsWorkspace' }); + instance.find(DragDrop).prop('onDrop')!(draggedField, 'field_replace'); expect(mockDispatch).toHaveBeenCalledWith({ type: 'SWITCH_VISUALIZATION', @@ -850,12 +851,12 @@ describe('workspace_panel', () => { visualizationState: {}, }); initComponent(); - expect(instance.find(DragDrop).prop('droppable')).toBeTruthy(); + expect(instance.find(DragDrop).prop('dropType')).toBeTruthy(); }); it('should refuse to drop if there are no suggestions', () => { initComponent(); - expect(instance.find(DragDrop).prop('droppable')).toBeFalsy(); + expect(instance.find(DragDrop).prop('dropType')).toBeFalsy(); }); }); }); 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 208dc823c314c9..2c4cecd356cedc 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 @@ -84,7 +84,17 @@ interface WorkspaceState { expandError: boolean; } -const workspaceDropValue = { id: 'lnsWorkspace' }; +const dropProps = { + value: { + id: 'lnsWorkspace', + humanData: { + label: i18n.translate('xpack.lens.editorFrame.workspaceLabel', { + defaultMessage: 'Workspace', + }), + }, + }, + order: [1, 0, 0, 0], +}; // Exported for testing purposes only. export const WorkspacePanel = React.memo(function WorkspacePanel({ @@ -302,9 +312,10 @@ export const WorkspacePanel = React.memo(function WorkspacePanel({ className="lnsWorkspacePanel__dragDrop" dataTestSubj="lnsWorkspace" draggable={false} - droppable={Boolean(suggestionForDraggedField)} + dropType={suggestionForDraggedField ? 'field_add' : undefined} onDrop={onDrop} - value={workspaceDropValue} + value={dropProps.value} + order={dropProps.order} >
    {renderVisualization()} diff --git a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx index 9bc4e5401f0701..61404dd1b71be0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx @@ -88,7 +88,7 @@ export function createMockDatasource(id: string): DatasourceMock { uniqueLabels: jest.fn((_state) => ({})), renderDimensionTrigger: jest.fn(), renderDimensionEditor: jest.fn(), - canHandleDrop: jest.fn(), + getDropTypes: jest.fn(), onDrop: jest.fn(), // this is an additional property which doesn't exist on real datasources diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index cd18c66437da83..fa5a9f9289e92a 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -25,10 +25,7 @@ export type { PieLayerState, SharedPieLayerState, } from './pie_visualization/types'; -export type { - DatatableVisualizationState, - DatatableLayerState, -} from './datatable_visualization/visualization'; +export type { DatatableVisualizationState } from './datatable_visualization/visualization'; export type { MetricState } from './metric_visualization/types'; export type { IndexPatternPersistedState, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index e062c152f8ec49..03f281e90f6b5a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -281,7 +281,7 @@ describe('IndexPattern Data Panel', () => { setState={setStateSpy} dragDropContext={{ ...createMockedDragDropContext(), - dragging: { id: '1' }, + dragging: { id: '1', humanData: { label: 'Label' } }, }} /> ); @@ -303,7 +303,7 @@ describe('IndexPattern Data Panel', () => { setState={jest.fn()} dragDropContext={{ ...createMockedDragDropContext(), - dragging: { id: '1' }, + dragging: { id: '1', humanData: { label: 'Label' } }, }} changeIndexPattern={jest.fn()} /> @@ -338,7 +338,7 @@ describe('IndexPattern Data Panel', () => { setState, dragDropContext: { ...createMockedDragDropContext(), - dragging: { id: '1' }, + dragging: { id: '1', humanData: { label: 'Label' } }, }, dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' }, state: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 3273cdbfe17428..c26d35c4d9a5d1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -106,9 +106,6 @@ const bytesColumn: IndexPatternColumn = { * * - Dimension trigger: Not tested here * - Dimension editor component: First half of the tests - * - * - canHandleDrop: Tests for dropping of fields or other dimensions - * - onDrop: Correct application of drop logic */ describe('IndexPatternDimensionEditorPanel', () => { let state: IndexPatternPrivateState; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts index 8c411aa3a5a6c1..b374be98748f0a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts @@ -7,14 +7,14 @@ import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; import { IndexPatternDimensionEditorProps } from './dimension_panel'; -import { onDrop, canHandleDrop } from './droppable'; +import { onDrop, getDropTypes } from './droppable'; import { DragContextState } from '../../drag_drop'; import { createMockedDragDropContext } from '../mocks'; import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreSetup } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { IndexPatternPrivateState } from '../types'; import { documentField } from '../document_field'; -import { OperationMetadata } from '../../types'; +import { OperationMetadata, DropType } from '../../types'; import { IndexPatternColumn } from '../operations'; import { getFieldByNameFactory } from '../pure_helpers'; @@ -66,6 +66,23 @@ const expectedIndexPatterns = { }, }; +const defaultDragging = { + columnId: 'col2', + groupId: 'a', + layerId: 'first', + id: 'col2', + humanData: { + label: 'Column 2', + }, +}; + +const draggingField = { + field: { type: 'number', name: 'bytes', aggregatable: true }, + indexPatternId: 'foo', + id: 'bar', + humanData: { label: 'Label' }, +}; + /** * The datasource exposes four main pieces of code which are tested at * an integration test level. The main reason for this fairly high level @@ -75,7 +92,7 @@ const expectedIndexPatterns = { * - Dimension trigger: Not tested here * - Dimension editor component: First half of the tests * - * - canHandleDrop: Tests for dropping of fields or other dimensions + * - getDropTypes: Returns drop types that are possible for the current dragging field or other dimension * - onDrop: Correct application of drop logic */ describe('IndexPatternDimensionEditorPanel', () => { @@ -157,522 +174,671 @@ describe('IndexPatternDimensionEditorPanel', () => { jest.clearAllMocks(); }); - it('is not droppable if no drag is happening', () => { - expect(canHandleDrop({ ...defaultProps, dragDropContext })).toBe(false); - }); + const groupId = 'a'; + describe('getDropTypes', () => { + it('returns undefined if no drag is happening', () => { + expect(getDropTypes({ ...defaultProps, groupId, dragDropContext })).toBe(undefined); + }); - it('is not droppable if the dragged item has no field', () => { - expect( - canHandleDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: { name: 'bar', id: 'bar' }, - }, - }) - ).toBe(false); - }); + it('returns undefined if the dragged item has no field', () => { + expect( + getDropTypes({ + ...defaultProps, + groupId, + dragDropContext: { + ...dragDropContext, + dragging: { name: 'bar', id: 'bar', humanData: { label: 'Label' } }, + }, + }) + ).toBe(undefined); + }); - it('is not droppable if field is not supported by filterOperations', () => { - expect( - canHandleDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: { - indexPatternId: 'foo', - field: { type: 'string', name: 'mystring', aggregatable: true }, - id: 'mystring', + it('returns undefined if field is not supported by filterOperations', () => { + expect( + getDropTypes({ + ...defaultProps, + groupId, + dragDropContext: { + ...dragDropContext, + dragging: { + indexPatternId: 'foo', + field: { type: 'string', name: 'mystring', aggregatable: true }, + id: 'mystring', + humanData: { label: 'Label' }, + }, + }, + filterOperations: () => false, + }) + ).toBe(undefined); + }); + + it('returns remove_add if the field is supported by filterOperations and the dropTarget is an existing column', () => { + expect( + getDropTypes({ + ...defaultProps, + groupId, + dragDropContext: { + ...dragDropContext, + dragging: draggingField, + }, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + }) + ).toBe('field_replace'); + }); + + it('returns undefined if the field belongs to another index pattern', () => { + expect( + getDropTypes({ + ...defaultProps, + groupId, + dragDropContext: { + ...dragDropContext, + dragging: { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo2', + id: 'bar', + humanData: { label: 'Label' }, + }, + }, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + }) + ).toBe(undefined); + }); + + it('returns undefined if the dragged field is already in use by this operation', () => { + expect( + getDropTypes({ + ...defaultProps, + groupId, + dragDropContext: { + ...dragDropContext, + dragging: { + field: { + name: 'timestamp', + displayName: 'timestampLabel', + type: 'date', + aggregatable: true, + searchable: true, + exists: true, + }, + indexPatternId: 'foo', + id: 'bar', + humanData: { label: 'Label' }, + }, + }, + }) + ).toBe(undefined); + }); + + it('returns move if the dragged column is compatible', () => { + expect( + getDropTypes({ + ...defaultProps, + groupId, + dragDropContext: { + ...dragDropContext, + dragging: { + columnId: 'col1', + groupId: 'b', + layerId: 'first', + id: 'col1', + humanData: { label: 'Label' }, + }, + }, + columnId: 'col2', + }) + ).toBe('move_compatible'); + }); + + it('returns undefined if the dragged column from different group uses the same field as the dropTarget', () => { + const testState = { ...state }; + testState.layers.first = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + + col2: { + label: 'Date histogram of timestamp (1)', + customLabel: true, + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', }, }, - filterOperations: () => false, - }) - ).toBe(false); - }); + }; - it('is droppable if the field is supported by filterOperations', () => { - expect( - canHandleDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: { - field: { type: 'number', name: 'bytes', aggregatable: true }, - indexPatternId: 'foo', - id: 'bar', + expect( + getDropTypes({ + ...defaultProps, + groupId, + dragDropContext: { + ...dragDropContext, + dragging: { + columnId: 'col1', + groupId: 'b', + layerId: 'first', + id: 'col1', + humanData: { label: 'Label' }, + }, + }, + columnId: 'col2', + }) + ).toEqual(undefined); + }); + + it('returns replace_incompatible if dropping column to existing incompatible column', () => { + const testState = { ...state }; + testState.layers.first = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + + col2: { + label: 'Sum of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'sum', + sourceField: 'bytes', }, }, - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - }) - ).toBe(true); - }); + }; - it('is not droppable if the field belongs to another index pattern', () => { - expect( - canHandleDrop({ + expect( + getDropTypes({ + ...defaultProps, + groupId, + dragDropContext: { + ...dragDropContext, + dragging: { + columnId: 'col1', + groupId: 'b', + layerId: 'first', + id: 'col1', + humanData: { label: 'Label' }, + }, + }, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed === false, + }) + ).toEqual('replace_incompatible'); + }); + }); + describe('onDrop', () => { + it('appends the dropped column when a field is dropped', () => { + onDrop({ ...defaultProps, dragDropContext: { ...dragDropContext, - dragging: { - field: { type: 'number', name: 'bar', aggregatable: true }, - indexPatternId: 'foo2', - id: 'bar', - }, + dragging: draggingField, }, + droppedItem: draggingField, + dropType: 'field_replace', + columnId: 'col2', filterOperations: (op: OperationMetadata) => op.dataType === 'number', - }) - ).toBe(false); - }); + }); - it('is not droppable if the dragged field is already in use by this operation', () => { - expect( - canHandleDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: { - field: { - name: 'timestamp', - displayName: 'timestampLabel', - type: 'date', - aggregatable: true, - searchable: true, - exists: true, + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columnOrder: ['col1', 'col2'], + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + dataType: 'number', + sourceField: 'bytes', + }), }, - indexPatternId: 'foo', - id: 'bar', }, }, - }) - ).toBe(false); - }); + }); + }); - it('is droppable if the dragged column is compatible', () => { - expect( - canHandleDrop({ + it('selects the specific operation that was valid on drop', () => { + onDrop({ ...defaultProps, dragDropContext: { ...dragDropContext, - dragging: { - columnId: 'col1', - groupId: 'a', - layerId: 'first', - id: 'col1', - }, + dragging: draggingField, }, + droppedItem: draggingField, columnId: 'col2', - }) - ).toBe(true); - }); + filterOperations: (op: OperationMetadata) => op.isBucketed, + dropType: 'field_replace', + }); - it('is not droppable if the dragged column is the same as the current column', () => { - expect( - canHandleDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: { - columnId: 'col1', - groupId: 'a', - layerId: 'first', - id: 'bar', + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columnOrder: ['col1', 'col2'], + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + dataType: 'number', + sourceField: 'bytes', + }), + }, }, }, - }) - ).toBe(false); - }); + }); + }); - it('is not droppable if the dragged column is incompatible', () => { - expect( - canHandleDrop({ + it('updates a column when a field is dropped', () => { + onDrop({ ...defaultProps, dragDropContext: { ...dragDropContext, - dragging: { - columnId: 'col1', - groupId: 'a', - layerId: 'first', - id: 'bar', - }, + dragging: draggingField, }, - columnId: 'col2', + droppedItem: draggingField, filterOperations: (op: OperationMetadata) => op.dataType === 'number', - }) - ).toBe(false); - }); - - it('appends the dropped column when a field is dropped', () => { - const dragging = { - field: { type: 'number', name: 'bytes', aggregatable: true }, - indexPatternId: 'foo', - id: 'bar', - }; - - onDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging, - }, - droppedItem: dragging, - columnId: 'col2', - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - groupId: '1', - }); + dropType: 'field_replace', + }); - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columnOrder: ['col1', 'col2'], - columns: { - ...state.layers.first.columns, - col2: expect.objectContaining({ - dataType: 'number', - sourceField: 'bytes', + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: expect.objectContaining({ + columns: expect.objectContaining({ + col1: expect.objectContaining({ + dataType: 'number', + sourceField: 'bytes', + }), }), - }, + }), }, - }, + }); }); - }); - it('selects the specific operation that was valid on drop', () => { - const dragging = { - field: { type: 'string', name: 'source', aggregatable: true }, - indexPatternId: 'foo', - id: 'bar', - }; - onDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging, - }, - droppedItem: dragging, - columnId: 'col2', - filterOperations: (op: OperationMetadata) => op.isBucketed, - groupId: '1', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columnOrder: ['col2', 'col1'], - columns: { - ...state.layers.first.columns, - col2: expect.objectContaining({ - dataType: 'string', - sourceField: 'source', - }), + it('keeps the operation when dropping a different compatible field', () => { + const dragging = { + field: { name: 'memory', type: 'number', aggregatable: true }, + indexPatternId: 'foo', + id: '1', + humanData: { label: 'Label' }, + }; + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: { + field: { name: 'memory', type: 'number', aggregatable: true }, + indexPatternId: 'foo', + id: '1', + }, + state: { + ...state, + layers: { + first: { + indexPatternId: 'foo', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Sum of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'sum', + sourceField: 'bytes', + }, + }, + }, }, }, - }, - }); - }); + dropType: 'field_replace', + }); - it('updates a column when a field is dropped', () => { - const dragging = { - field: { type: 'number', name: 'bytes', aggregatable: true }, - indexPatternId: 'foo', - id: 'bar', - }; - onDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging, - }, - droppedItem: dragging, - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - groupId: '1', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: expect.objectContaining({ - columns: expect.objectContaining({ - col1: expect.objectContaining({ - dataType: 'number', - sourceField: 'bytes', + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: expect.objectContaining({ + columns: expect.objectContaining({ + col1: expect.objectContaining({ + operationType: 'sum', + dataType: 'number', + sourceField: 'memory', + }), }), }), - }), - }, + }, + }); }); - }); - it('keeps the operation when dropping a different compatible field', () => { - const dragging = { - field: { name: 'memory', type: 'number', aggregatable: true }, - indexPatternId: 'foo', - id: '1', - }; - onDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging, - }, - droppedItem: dragging, - state: { + it('updates the column id when moving an operation to an empty dimension', () => { + const dragging = { + columnId: 'col1', + groupId: 'a', + layerId: 'first', + id: 'bar', + humanData: { label: 'Label' }, + }; + + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + columnId: 'col2', + dropType: 'move_compatible', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ ...state, layers: { first: { - indexPatternId: 'foo', - columnOrder: ['col1'], + ...state.layers.first, + columnOrder: ['col2'], columns: { - col1: { - label: 'Sum of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'sum', - sourceField: 'bytes', - }, + col2: state.layers.first.columns.col1, }, }, }, - }, - groupId: '1', + }); }); - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: expect.objectContaining({ - columns: expect.objectContaining({ - col1: expect.objectContaining({ - operationType: 'sum', - dataType: 'number', - sourceField: 'memory', - }), - }), - }), - }, - }); - }); - - it('updates the column id when moving an operation to an empty dimension', () => { - const dragging = { - columnId: 'col1', - groupId: 'a', - layerId: 'first', - id: 'bar', - }; - - onDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging, - }, - droppedItem: dragging, - columnId: 'col2', - groupId: '1', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columnOrder: ['col2'], - columns: { - col2: state.layers.first.columns.col1, + it('replaces an operation when moving to a populated dimension', () => { + const testState = { ...state }; + testState.layers.first = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + + col2: { + label: 'Top values of src', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col3' }, + orderDirection: 'desc', + size: 10, + }, + sourceField: 'src', }, - }, - }, - }); - }); - - it('replaces an operation when moving to a populated dimension', () => { - const dragging = { - columnId: 'col2', - groupId: 'a', - layerId: 'first', - id: 'col2', - }; - const testState = { ...state }; - testState.layers.first = { - indexPatternId: 'foo', - columnOrder: ['col1', 'col2', 'col3'], - columns: { - col1: testState.layers.first.columns.col1, - - col2: { - label: 'Top values of src', - dataType: 'string', - isBucketed: true, - - // Private - operationType: 'terms', - params: { - orderBy: { type: 'column', columnId: 'col3' }, - orderDirection: 'desc', - size: 10, + col3: { + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + sourceField: 'Records', }, - sourceField: 'src', }, - col3: { - label: 'Count', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'count', - sourceField: 'Records', - }, - }, - }; + }; - onDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging, - }, - droppedItem: dragging, - state: testState, - groupId: '1', - }); + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: defaultDragging, + }, + droppedItem: defaultDragging, + state: testState, + dropType: 'replace_compatible', + }); - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['col1', 'col3'], - columns: { - col1: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col3'], + columns: { + col1: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + }, }, }, - }, + }); }); - }); - it('if dnd is reorder, it correctly reorders columns', () => { - const dragging = { - columnId: 'col1', - groupId: 'a', - layerId: 'first', - id: 'col1', - }; - const testState = { - ...state, - layers: { - first: { - indexPatternId: 'foo', - columnOrder: ['col1', 'col2', 'col3'], - columns: { - col1: { - label: 'Date histogram of timestamp', - dataType: 'date', - isBucketed: true, - } as IndexPatternColumn, - col2: { - label: 'Top values of bar', - dataType: 'number', - isBucketed: true, - } as IndexPatternColumn, - col3: { - label: 'Top values of memory', - dataType: 'number', - isBucketed: true, - } as IndexPatternColumn, + it('copies a dimension if dropType is duplicate_in_group, respecting bucket metric order', () => { + const testState = { ...state }; + testState.layers.first = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + + col2: { + label: 'Top values of src', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col3' }, + orderDirection: 'desc', + size: 10, + }, + sourceField: 'src', + }, + col3: { + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + sourceField: 'Records', }, }, - }, - }; + }; - const defaultReorderDropParams = { - ...defaultProps, - isReorder: true, - dragDropContext: { - ...dragDropContext, - dragging, - }, - droppedItem: dragging, - state: testState, - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - groupId: 'a', - }; + const metricDragging = { + columnId: 'col3', + groupId: 'a', + layerId: 'first', + id: 'col3', + humanData: { label: 'Label' }, + }; - const stateWithColumnOrder = (columnOrder: string[]) => { - return { + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: metricDragging, + }, + droppedItem: metricDragging, + state: testState, + dropType: 'duplicate_in_group', + columnId: 'newCol', + }); + // metric is appended + expect(setState).toHaveBeenCalledWith({ ...testState, layers: { first: { ...testState.layers.first, - columnOrder, + columnOrder: ['col1', 'col2', 'col3', 'newCol'], columns: { - ...testState.layers.first.columns, + col1: testState.layers.first.columns.col1, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + newCol: testState.layers.first.columns.col3, }, }, }, - }; - }; - - // first element to last - onDrop({ - ...defaultReorderDropParams, - columnId: 'col3', - }); - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col2', 'col3', 'col1'])); + }); - // last element to first - onDrop({ - ...defaultReorderDropParams, - columnId: 'col1', - droppedItem: { - columnId: 'col3', - groupId: 'a', - layerId: 'first', - id: 'col3', - }, - }); - expect(setState).toBeCalledTimes(2); - expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col3', 'col1', 'col2'])); - - // middle column to first - onDrop({ - ...defaultReorderDropParams, - columnId: 'col1', - droppedItem: { + const bucketDragging = { columnId: 'col2', groupId: 'a', layerId: 'first', id: 'col2', - }, + humanData: { label: 'Label' }, + }; + + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: bucketDragging, + }, + droppedItem: bucketDragging, + state: testState, + dropType: 'duplicate_in_group', + columnId: 'newCol', + }); + + // bucket is placed after the last existing bucket + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'newCol', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + col2: testState.layers.first.columns.col2, + newCol: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + }, + }, + }, + }); }); - expect(setState).toBeCalledTimes(3); - expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col2', 'col1', 'col3'])); - - // middle column to last - onDrop({ - ...defaultReorderDropParams, - columnId: 'col3', - droppedItem: { - columnId: 'col2', + + it('if dropType is reorder, it correctly reorders columns', () => { + const dragging = { + columnId: 'col1', groupId: 'a', layerId: 'first', - id: 'col2', - }, + id: 'col1', + humanData: { label: 'Label' }, + }; + const testState = { + ...state, + layers: { + first: { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + } as IndexPatternColumn, + col2: { + label: 'Top values of bar', + dataType: 'number', + isBucketed: true, + } as IndexPatternColumn, + col3: { + label: 'Top values of memory', + dataType: 'number', + isBucketed: true, + } as IndexPatternColumn, + }, + }, + }, + }; + + const defaultReorderDropParams = { + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + dropType: 'reorder' as DropType, + }; + + const stateWithColumnOrder = (columnOrder: string[]) => { + return { + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder, + columns: { + ...testState.layers.first.columns, + }, + }, + }, + }; + }; + + // first element to last + onDrop({ + ...defaultReorderDropParams, + columnId: 'col3', + }); + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col2', 'col3', 'col1'])); + + // last element to first + onDrop({ + ...defaultReorderDropParams, + columnId: 'col1', + droppedItem: { + columnId: 'col3', + groupId: 'a', + layerId: 'first', + id: 'col3', + }, + }); + expect(setState).toBeCalledTimes(2); + expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col3', 'col1', 'col2'])); + + // middle column to first + onDrop({ + ...defaultReorderDropParams, + columnId: 'col1', + droppedItem: { + columnId: 'col2', + groupId: 'a', + layerId: 'first', + id: 'col2', + }, + }); + expect(setState).toBeCalledTimes(3); + expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col2', 'col1', 'col3'])); + + // middle column to last + onDrop({ + ...defaultReorderDropParams, + columnId: 'col3', + droppedItem: { + columnId: 'col2', + groupId: 'a', + layerId: 'first', + id: 'col2', + }, + }); + expect(setState).toBeCalledTimes(4); + expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col1', 'col3', 'col2'])); }); - expect(setState).toBeCalledTimes(4); - expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col1', 'col3', 'col2'])); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts index 3fa40911062cf4..cbd599743f8132 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts @@ -12,39 +12,46 @@ import { DraggedOperation, } from '../../types'; import { IndexPatternColumn } from '../indexpattern'; -import { insertOrReplaceColumn } from '../operations'; +import { insertOrReplaceColumn, deleteColumn } from '../operations'; import { mergeLayer } from '../state_helpers'; import { hasField, isDraggedField } from '../utils'; -import { IndexPatternPrivateState, IndexPatternField } from '../types'; +import { IndexPatternPrivateState, IndexPatternField, DraggedField } from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; -import { getOperationSupportMatrix, OperationSupportMatrix } from './operation_support'; +import { getOperationSupportMatrix } from './operation_support'; -type DropHandlerProps = Pick< - DatasourceDimensionDropHandlerProps, - 'columnId' | 'setState' | 'state' | 'layerId' | 'droppedItem' -> & { +type DropHandlerProps = DatasourceDimensionDropHandlerProps & { droppedItem: T; - operationSupportMatrix: OperationSupportMatrix; }; -export function canHandleDrop(props: DatasourceDimensionDropProps) { - const operationSupportMatrix = getOperationSupportMatrix(props); - +export function getDropTypes( + props: DatasourceDimensionDropProps & { groupId: string } +) { const { dragging } = props.dragDropContext; + if (!dragging) { + return; + } + const layerIndexPatternId = props.state.layers[props.layerId].indexPatternId; function hasOperationForField(field: IndexPatternField) { - return Boolean(operationSupportMatrix.operationByField[field.name]); + return !!getOperationSupportMatrix(props).operationByField[field.name]; } + const currentColumn = props.state.layers[props.layerId].columns[props.columnId]; if (isDraggedField(dragging)) { - const currentColumn = props.state.layers[props.layerId].columns[props.columnId]; - return Boolean( - layerIndexPatternId === dragging.indexPatternId && - Boolean(hasOperationForField(dragging.field)) && - (!currentColumn || - (hasField(currentColumn) && currentColumn.sourceField !== dragging.field.name)) - ); + if ( + !!(layerIndexPatternId === dragging.indexPatternId && hasOperationForField(dragging.field)) + ) { + if (!currentColumn) { + return 'field_add'; + } else if ( + (hasField(currentColumn) && currentColumn.sourceField !== dragging.field.name) || + !hasField(currentColumn) + ) { + return 'field_replace'; + } + } + return; } if ( @@ -52,12 +59,72 @@ export function canHandleDrop(props: DatasourceDimensionDropProps) { + const { droppedItem, dropType } = props; + + if (dropType === 'field_add' || dropType === 'field_replace') { + return operationOnDropMap[dropType]({ + ...props, + droppedItem: droppedItem as DraggedField, + }); + } + return operationOnDropMap[dropType]({ + ...props, + droppedItem: droppedItem as DraggedOperation, + }); +} + +const operationOnDropMap = { + field_add: onFieldDrop, + field_replace: onFieldDrop, + reorder: onReorderDrop, + duplicate_in_group: onSameGroupDuplicateDrop, + move_compatible: onMoveDropToCompatibleGroup, + replace_compatible: onMoveDropToCompatibleGroup, + move_incompatible: onMoveDropToNonCompatibleGroup, + replace_incompatible: onMoveDropToNonCompatibleGroup, +}; + function reorderElements(items: string[], dest: string, src: string) { const result = items.filter((c) => c !== src); const destIndex = items.findIndex((c) => c === src); @@ -69,7 +136,13 @@ function reorderElements(items: string[], dest: string, src: string) { return result; } -const onReorderDrop = ({ columnId, setState, state, layerId, droppedItem }: DropHandlerProps) => { +function onReorderDrop({ + columnId, + setState, + state, + layerId, + droppedItem, +}: DropHandlerProps) { setState( mergeLayer({ state, @@ -85,15 +158,98 @@ const onReorderDrop = ({ columnId, setState, state, layerId, droppedItem }: Drop ); return true; -}; +} + +function onMoveDropToNonCompatibleGroup(props: DropHandlerProps) { + const { columnId, setState, state, layerId, droppedItem } = props; + + const layer = state.layers[layerId]; + const op = { ...layer.columns[droppedItem.columnId] }; + const field = + hasField(op) && state.indexPatterns[layer.indexPatternId].getFieldByName(op.sourceField); + if (!field) { + return false; + } + + const operationSupportMatrix = getOperationSupportMatrix(props); + const operationsForNewField = operationSupportMatrix.operationByField[field.name]; + + if (!operationsForNewField || operationsForNewField.size === 0) { + return false; + } + + const currentIndexPattern = state.indexPatterns[layer.indexPatternId]; + + const newLayer = insertOrReplaceColumn({ + layer: deleteColumn({ + layer, + columnId: droppedItem.columnId, + indexPattern: currentIndexPattern, + }), + columnId, + indexPattern: currentIndexPattern, + op: operationsForNewField.values().next().value, + field, + }); + + trackUiEvent('drop_onto_dimension'); + setState( + mergeLayer({ + state, + layerId, + newLayer: { + ...newLayer, + }, + }) + ); + + return { deleted: droppedItem.columnId }; +} -const onMoveDropToCompatibleGroup = ({ +function onSameGroupDuplicateDrop({ columnId, setState, state, layerId, droppedItem, -}: DropHandlerProps) => { +}: DropHandlerProps) { + const layer = state.layers[layerId]; + + const op = { ...layer.columns[droppedItem.columnId] }; + const newColumns = { + ...layer.columns, + [columnId]: op, + }; + + const newColumnOrder = [...layer.columnOrder]; + // put a new bucketed dimension just in front of the metric dimensions, a metric dimension in the back of the array + // TODO this logic does not take into account groups - we probably need to pass the current + // group config to this position to place the column right + const insertionIndex = op.isBucketed + ? newColumnOrder.findIndex((id) => !newColumns[id].isBucketed) + : newColumnOrder.length; + newColumnOrder.splice(insertionIndex, 0, columnId); + // Time to replace + setState( + mergeLayer({ + state, + layerId, + newLayer: { + columnOrder: newColumnOrder, + columns: newColumns, + }, + }) + ); + return true; +} + +function onMoveDropToCompatibleGroup({ + columnId, + setState, + state, + layerId, + droppedItem, +}: DropHandlerProps) { const layer = state.layers[layerId]; const op = { ...layer.columns[droppedItem.columnId] }; const newColumns = { ...layer.columns }; @@ -122,18 +278,14 @@ const onMoveDropToCompatibleGroup = ({ }) ); return { deleted: droppedItem.columnId }; -}; +} + +function onFieldDrop(props: DropHandlerProps) { + const { columnId, setState, state, layerId, droppedItem } = props; + const operationSupportMatrix = getOperationSupportMatrix(props); -const onFieldDrop = ({ - columnId, - setState, - state, - layerId, - droppedItem, - operationSupportMatrix, -}: DropHandlerProps) => { function hasOperationForField(field: IndexPatternField) { - return Boolean(operationSupportMatrix.operationByField[field.name]); + return !!operationSupportMatrix.operationByField[field.name]; } if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) { @@ -176,55 +328,4 @@ const onFieldDrop = ({ trackUiEvent(hasData ? 'drop_non_empty' : 'drop_empty'); setState(mergeLayer({ state, layerId, newLayer })); return true; -}; - -export function onDrop(props: DatasourceDimensionDropHandlerProps) { - const operationSupportMatrix = getOperationSupportMatrix(props); - const { setState, state, droppedItem, columnId, layerId, groupId, isNew } = props; - - if (!isDraggedOperation(droppedItem)) { - return onFieldDrop({ - columnId, - setState, - state, - layerId, - droppedItem, - operationSupportMatrix, - }); - } - const isExistingFromSameGroup = - droppedItem.groupId === groupId && droppedItem.columnId !== columnId && !isNew; - - // reorder in the same group - if (isExistingFromSameGroup) { - return onReorderDrop({ - columnId, - setState, - state, - layerId, - droppedItem, - operationSupportMatrix, - }); - } - - // replace or move to compatible group - const isFromOtherGroup = droppedItem.groupId !== groupId && droppedItem.layerId === layerId; - - if (isFromOtherGroup) { - const layer = state.layers[layerId]; - const op = { ...layer.columns[droppedItem.columnId] }; - - if (props.filterOperations(op)) { - return onMoveDropToCompatibleGroup({ - columnId, - setState, - state, - layerId, - droppedItem, - operationSupportMatrix, - }); - } - } - - return false; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.scss b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.scss index 8c10ca9d30b733..8a6e10c8be6e4f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.scss @@ -1,4 +1,5 @@ .lnsFieldItem { + width: 100%; .lnsFieldItem__infoIcon { visibility: hidden; opacity: 0; @@ -13,6 +14,23 @@ transition: opacity $euiAnimSpeedFast ease-in-out 1s; } } + + &:focus, + &:focus-within, + &.kbnFieldButton-isActive { + animation: none !important; // sass-lint:disable-line no-important + } + + &:focus .kbnFieldButton__name span, + &:focus-within .kbnFieldButton__name span, + &.kbnFieldButton-isActive .kbnFieldButton__name span { + background-color: transparentize($euiColorVis1, .9) !important; + text-decoration: underline !important; + } +} + +.kbnFieldButton__name { + transition: background-color $euiAnimSpeedFast ease-in-out; } .lnsFieldItem--missing { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx index e598e85f2ff173..e0198d6d7903e7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx @@ -48,11 +48,10 @@ import { } from '../../../../../src/plugins/data/public'; import { FieldButton } from '../../../../../src/plugins/kibana_react/public'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; -import { DraggedField } from './indexpattern'; import { DragDrop, DragDropIdentifier } from '../drag_drop'; import { DatasourceDataPanelProps, DataType } from '../types'; import { BucketedAggregation, FieldStatsResponse } from '../../common'; -import { IndexPattern, IndexPatternField } from './types'; +import { IndexPattern, IndexPatternField, DraggedField } from './types'; import { LensFieldIcon } from './lens_field_icon'; import { trackUiEvent } from '../lens_ui_telemetry'; @@ -103,6 +102,8 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { dateRange, filters, hideDetails, + itemIndex, + groupIndex, dropOntoWorkspace, } = props; @@ -167,9 +168,18 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { } const value = useMemo( - () => ({ field, indexPatternId: indexPattern.id, id: field.name } as DraggedField), - [field, indexPattern.id] + () => ({ + field, + indexPatternId: indexPattern.id, + id: field.name, + humanData: { + label: field.displayName, + position: itemIndex + 1, + }, + }), + [field, indexPattern.id, itemIndex] ); + const order = useMemo(() => [0, groupIndex, itemIndex], [groupIndex, itemIndex]); const lensFieldIcon = ; const lensInfoIcon = ( @@ -204,9 +214,8 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { container={document.querySelector('.application') || undefined} button={ @@ -271,6 +280,9 @@ function FieldPanelHeader({ indexPatternId, id: field.name, field, + humanData: { + label: field.displayName, + }, }; return ( @@ -641,11 +653,7 @@ const DragToWorkspaceButton = ({ dropOntoWorkspace, isEnabled, }: { - field: { - indexPatternId: string; - id: string; - field: IndexPatternField; - }; + field: DraggedField; dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; isEnabled: boolean; }) => { 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 3f842792c20cfa..4e7e07b99904f5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -14,11 +14,8 @@ import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { Ast } from '@kbn/interpreter/common'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { getFieldByNameFactory } from './pure_helpers'; -import { - operationDefinitionMap, - getErrorMessages, - createMockedReferenceOperation, -} from './operations'; +import { operationDefinitionMap, getErrorMessages } from './operations'; +import { createMockedReferenceOperation } from './operations/mocks'; jest.mock('./loader'); jest.mock('../id_generator'); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 5571700b15b61e..6cc89d3dab119f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -31,7 +31,7 @@ import { toExpression } from './to_expression'; import { IndexPatternDimensionTrigger, IndexPatternDimensionEditor, - canHandleDrop, + getDropTypes, onDrop, } from './dimension_panel'; import { IndexPatternDataPanel } from './datapanel'; @@ -44,7 +44,7 @@ import { import { isDraggedField, normalizeOperationDataType } from './utils'; import { LayerPanel } from './layerpanel'; import { IndexPatternColumn, getErrorMessages, IncompleteColumn } from './operations'; -import { IndexPatternField, IndexPatternPrivateState, IndexPatternPersistedState } from './types'; +import { IndexPatternPrivateState, IndexPatternPersistedState } from './types'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; import { VisualizeFieldContext } from '../../../../../src/plugins/ui_actions/public'; @@ -52,15 +52,9 @@ import { mergeLayer } from './state_helpers'; import { Datasource, StateSetter } from '../types'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; import { deleteColumn, isReferenced } from './operations'; -import { DragDropIdentifier } from '../drag_drop/providers'; export { OperationType, IndexPatternColumn, deleteColumn } from './operations'; -export type DraggedField = DragDropIdentifier & { - field: IndexPatternField; - indexPatternId: string; -}; - export function columnToOperation(column: IndexPatternColumn, uniqueLabel?: string): Operation { const { dataType, label, isBucketed, scale } = column; return { @@ -314,8 +308,7 @@ export function getIndexPatternDatasource({ domElement ); }, - - canHandleDrop, + getDropTypes, onDrop, // Reset the temporary invalid state when closing the editor, but don't diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts index 306c87fa765e55..06560bb0fa2443 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts @@ -253,5 +253,6 @@ export function createMockedDragDropContext(): jest.Mocked { keyboardMode: false, setKeyboardMode: jest.fn(), setA11yMessage: jest.fn(), + registerDropTarget: jest.fn(), }; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts index 2677c16c566f58..aa46dd765bd8b8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts @@ -32,5 +32,3 @@ export { DerivativeIndexPatternColumn, MovingAverageIndexPatternColumn, } from './definitions'; - -export { createMockedReferenceOperation } from './mocks'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts index 10b1f7f1799da0..f45f963ee174f1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts @@ -8,6 +8,7 @@ import { IFieldType } from 'src/plugins/data/common'; import { IndexPatternColumn, IncompleteColumn } from './operations'; import { IndexPatternAggRestrictions } from '../../../../../src/plugins/data/public'; +import { DragDropIdentifier } from '../drag_drop/providers'; export { IndexPatternColumn, @@ -32,6 +33,10 @@ export { MovingAverageIndexPatternColumn, } from './operations'; +export type DraggedField = DragDropIdentifier & { + field: IndexPatternField; + indexPatternId: string; +}; export interface IndexPattern { id: string; fields: IndexPatternField[]; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts index 515d205637505d..d4c9da188be612 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts @@ -6,8 +6,7 @@ */ import { DataType } from '../types'; -import { IndexPattern, IndexPatternLayer } from './types'; -import { DraggedField } from './indexpattern'; +import { IndexPattern, IndexPatternLayer, DraggedField } from './types'; import type { BaseIndexPatternColumn, FieldBasedIndexPatternColumn, diff --git a/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx b/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx index 7d7ba1598cade9..c0788e6f67dfe1 100644 --- a/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx @@ -14,6 +14,7 @@ const typeToIconMap: { [type: string]: string | IconType } = { legend: EuiIconLegend as IconType, labels: 'visText', values: 'number', + list: 'list', }; export interface ToolbarPopoverProps { diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index ed1a34eac8c429..cccc35acb3fca1 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -26,10 +26,12 @@ import { RangeSelectContext, ValueClickContext } from '../../../../src/plugins/e import { LENS_EDIT_SORT_ACTION, LENS_EDIT_RESIZE_ACTION, + LENS_TOGGLE_ACTION, } from './datatable_visualization/components/constants'; import type { LensSortActionData, LensResizeActionData, + LensToggleActionData, } from './datatable_visualization/components/types'; export type ErrorCallback = (e: { message: string }) => void; @@ -136,6 +138,16 @@ export type TableChangeType = | 'reorder' | 'layers'; +export type DropType = + | 'field_add' + | 'field_replace' + | 'reorder' + | 'duplicate_in_group' + | 'move_compatible' + | 'replace_compatible' + | 'move_incompatible' + | 'replace_incompatible'; + export interface DatasourceSuggestion { state: T; table: TableSuggestion; @@ -177,7 +189,9 @@ export interface Datasource { renderDimensionTrigger: (domElement: Element, props: DatasourceDimensionTriggerProps) => void; renderDimensionEditor: (domElement: Element, props: DatasourceDimensionEditorProps) => void; renderLayerPanel: (domElement: Element, props: DatasourceLayerPanelProps) => void; - canHandleDrop: (props: DatasourceDimensionDropProps) => boolean; + getDropTypes: ( + props: DatasourceDimensionDropProps & { groupId: string } + ) => DropType | undefined; onDrop: (props: DatasourceDimensionDropHandlerProps) => false | true | { deleted: string }; updateStateOnCloseDimension?: (props: { layerId: string; @@ -297,13 +311,11 @@ export type DatasourceDimensionDropProps = SharedDimensionProps & { state: T; setState: StateSetter; dragDropContext: DragContextState; - isReorder?: boolean; }; export type DatasourceDimensionDropHandlerProps = DatasourceDimensionDropProps & { droppedItem: unknown; - groupId: string; - isNew?: boolean; + dropType: DropType; }; export type DataType = 'document' | 'string' | 'number' | 'date' | 'boolean' | 'ip'; @@ -368,7 +380,7 @@ export type VisualizationDimensionEditorProps = VisualizationConfig export interface AccessorConfig { columnId: string; - triggerIcon?: 'color' | 'disabled' | 'colorBy' | 'none'; + triggerIcon?: 'color' | 'disabled' | 'colorBy' | 'none' | 'invisible'; color?: string; palette?: string[]; } @@ -649,6 +661,7 @@ export interface LensBrushEvent { interface LensEditContextMapping { [LENS_EDIT_SORT_ACTION]: LensSortActionData; [LENS_EDIT_RESIZE_ACTION]: LensResizeActionData; + [LENS_TOGGLE_ACTION]: LensToggleActionData; } type LensEditSupportedActions = keyof LensEditContextMapping; diff --git a/x-pack/plugins/lens/server/migrations.test.ts b/x-pack/plugins/lens/server/migrations.test.ts index 077204b07ed733..01329d85baf008 100644 --- a/x-pack/plugins/lens/server/migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations.test.ts @@ -597,4 +597,77 @@ describe('Lens migrations', () => { expect(layersWithSuggestedPriority).toEqual(0); }); }); + + describe('7.12.0 restructure datatable state', () => { + const context = ({ log: { warning: () => {} } } as unknown) as SavedObjectMigrationContext; + const example = { + type: 'lens', + id: 'mock-saved-object-id', + attributes: { + state: { + datasourceStates: { + indexpattern: {}, + }, + visualization: { + layers: [ + { + layerId: 'first', + columns: ['a', 'b', 'c'], + }, + ], + sorting: { + columnId: 'a', + direction: 'asc', + }, + }, + query: { query: '', language: 'kuery' }, + filters: [], + }, + title: 'Table', + visualizationType: 'lnsDatatable', + }, + }; + + it('should not touch non datatable visualization', () => { + const xyChart = { + ...example, + attributes: { ...example.attributes, visualizationType: 'xy' }, + }; + const result = migrations['7.12.0'](xyChart, context) as ReturnType< + SavedObjectMigrationFn + >; + expect(result).toBe(xyChart); + }); + + it('should remove layer array and reshape state', () => { + const result = migrations['7.12.0'](example, context) as ReturnType< + SavedObjectMigrationFn + >; + expect(result.attributes.state.visualization).toEqual({ + layerId: 'first', + columns: [ + { + columnId: 'a', + }, + { + columnId: 'b', + }, + { + columnId: 'c', + }, + ], + sorting: { + columnId: 'a', + direction: 'asc', + }, + }); + // should leave other parts alone + expect(result.attributes.state.datasourceStates).toEqual( + example.attributes.state.datasourceStates + ); + expect(result.attributes.state.query).toEqual(example.attributes.state.query); + expect(result.attributes.state.filters).toEqual(example.attributes.state.filters); + expect(result.attributes.title).toEqual(example.attributes.title); + }); + }); }); diff --git a/x-pack/plugins/lens/server/migrations.ts b/x-pack/plugins/lens/server/migrations.ts index bb078ff204f2bd..4c6dfcd7949be8 100644 --- a/x-pack/plugins/lens/server/migrations.ts +++ b/x-pack/plugins/lens/server/migrations.ts @@ -83,6 +83,29 @@ interface XYStatePost77 { layers: Array>; } +interface DatatableStatePre711 { + layers: Array<{ + layerId: string; + columns: string[]; + }>; + sorting?: { + columnId: string | undefined; + direction: 'asc' | 'desc' | 'none'; + }; +} +interface DatatableStatePost711 { + layerId: string; + columns: Array<{ + columnId: string; + width?: number; + hidden?: boolean; + }>; + sorting?: { + columnId: string | undefined; + direction: 'asc' | 'desc' | 'none'; + }; +} + /** * Removes the `lens_auto_date` subexpression from a stored expression * string. For example: aggConfigs={lens_auto_date aggConfigs="JSON string"} @@ -334,6 +357,36 @@ const removeSuggestedPriority: SavedObjectMigrationFn, + LensDocShape +> = (doc) => { + // nothing to do for non-datatable visualizations + if (doc.attributes.visualizationType !== 'lnsDatatable') + return (doc as unknown) as SavedObjectUnsanitizedDoc>; + const oldState = doc.attributes.state.visualization; + const layer = oldState.layers[0] || { + layerId: '', + columns: [], + }; + // put together new saved object format + const newDoc: SavedObjectUnsanitizedDoc> = { + ...doc, + attributes: { + ...doc.attributes, + state: { + ...doc.attributes.state, + visualization: { + sorting: oldState.sorting, + layerId: layer.layerId, + columns: layer.columns.map((columnId) => ({ columnId })), + }, + }, + }, + }; + return newDoc; +}; + export const migrations: SavedObjectMigrationMap = { '7.7.0': removeInvalidAccessors, // The order of these migrations matter, since the timefield migration relies on the aggConfigs @@ -341,4 +394,5 @@ export const migrations: SavedObjectMigrationMap = { '7.8.0': (doc, context) => addTimeFieldToEsaggs(removeLensAutoDate(doc, context), context), '7.10.0': extractReferences, '7.11.0': removeSuggestedPriority, + '7.12.0': transformTableState, }; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index ede6b8abbd09c7..a73a68445a391f 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -25,7 +25,8 @@ "spaces", "management", "licenseManagement", - "maps" + "maps", + "lens" ], "server": true, "ui": true, diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 44558fb9dcfeb6..0199e13e93d8c0 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -77,6 +77,7 @@ const App: FC = ({ coreStart, deps, appMountParams }) => { data: deps.data, security: deps.security, licenseManagement: deps.licenseManagement, + lens: deps.lens, storage: localStorage, embeddable: deps.embeddable, maps: deps.maps, diff --git a/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js b/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js index 6b869d042ed7fa..f1093fd0b16a1a 100644 --- a/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js +++ b/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js @@ -249,7 +249,6 @@ export function getColumns( name: i18n.translate('xpack.ml.anomaliesTable.categoryExamplesColumnName', { defaultMessage: 'category examples', }), - sortable: false, truncateText: true, render: (item) => { const examples = get(examplesByJobId, [item.jobId, item.entityValue], []); @@ -268,7 +267,6 @@ export function getColumns( ); }, - textOnly: true, width: '13%', }); } diff --git a/x-pack/plugins/ml/public/application/components/anomalies_table/severity_cell/severity_cell.tsx b/x-pack/plugins/ml/public/application/components/anomalies_table/severity_cell/severity_cell.tsx index 7b7912f2a9fa5f..b761599a447b71 100644 --- a/x-pack/plugins/ml/public/application/components/anomalies_table/severity_cell/severity_cell.tsx +++ b/x-pack/plugins/ml/public/application/components/anomalies_table/severity_cell/severity_cell.tsx @@ -8,7 +8,10 @@ import React, { FC, memo } from 'react'; import { EuiHealth, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { MULTI_BUCKET_IMPACT } from '../../../../../common/constants/multi_bucket_impact'; -import { getSeverityColor } from '../../../../../common/util/anomaly_utils'; +import { + getSeverityColor, + getFormattedSeverityScore, +} from '../../../../../common/util/anomaly_utils'; interface SeverityCellProps { /** @@ -27,7 +30,7 @@ interface SeverityCellProps { * Renders anomaly severity score with single or multi-bucket impact marker. */ export const SeverityCell: FC = memo(({ score, multiBucketImpact }) => { - const severity = score >= 1 ? Math.floor(score) : '< 1'; + const severity = getFormattedSeverityScore(score); const color = getSeverityColor(score); const isMultiBucket = multiBucketImpact >= MULTI_BUCKET_IMPACT.MEDIUM; return isMultiBucket ? ( diff --git a/x-pack/plugins/ml/public/application/components/data_recognizer/data_recognizer.d.ts b/x-pack/plugins/ml/public/application/components/data_recognizer/data_recognizer.d.ts index c47e21222097d1..ff6363ea2cc6ed 100644 --- a/x-pack/plugins/ml/public/application/components/data_recognizer/data_recognizer.d.ts +++ b/x-pack/plugins/ml/public/application/components/data_recognizer/data_recognizer.d.ts @@ -7,10 +7,10 @@ import { FC } from 'react'; import { SavedSearchSavedObject } from '../../../../common/types/kibana'; -import { IndexPattern } from '../../../../../../../src/plugins/data/public'; +import type { IIndexPattern } from '../../../../../../../src/plugins/data/public'; declare const DataRecognizer: FC<{ - indexPattern: IndexPattern; + indexPattern: IIndexPattern; savedSearch: SavedSearchSavedObject | null; results: { count: number; diff --git a/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.test.js b/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.test.tsx similarity index 100% rename from x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.test.js rename to x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.test.tsx diff --git a/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.js b/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.tsx similarity index 59% rename from x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.js rename to x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.tsx index f6cfe486d65f8a..650a9d3deb5397 100644 --- a/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.js +++ b/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.tsx @@ -5,8 +5,7 @@ * 2.0. */ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { FC } from 'react'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -14,50 +13,67 @@ import { i18n } from '@kbn/i18n'; import { EMPTY_FIELD_VALUE_LABEL } from '../../timeseriesexplorer/components/entity_control/entity_control'; import { MLCATEGORY } from '../../../../common/constants/field_types'; -function getAddFilter({ entityName, entityValue, filter }) { - return ( - void; + +interface EntityCellProps { + entityName: string; + entityValue: string; + filter?: EntityCellFilter; + wrapText?: boolean; +} + +function getAddFilter({ entityName, entityValue, filter }: EntityCellProps) { + if (filter !== undefined) { + return ( + + } + > + filter(entityName, entityValue, '+')} + iconType="plusInCircle" + aria-label={i18n.translate('xpack.ml.anomaliesTable.entityCell.addFilterAriaLabel', { + defaultMessage: 'Add filter', + })} /> - } - > - filter(entityName, entityValue, '+')} - iconType="plusInCircle" - aria-label={i18n.translate('xpack.ml.anomaliesTable.entityCell.addFilterAriaLabel', { - defaultMessage: 'Add filter', - })} - /> - - ); + + ); + } } -function getRemoveFilter({ entityName, entityValue, filter }) { - return ( - + } + > + filter(entityName, entityValue, '-')} + iconType="minusInCircle" + aria-label={i18n.translate('xpack.ml.anomaliesTable.entityCell.removeFilterAriaLabel', { + defaultMessage: 'Remove filter', + })} /> - } - > - filter(entityName, entityValue, '-')} - iconType="minusInCircle" - aria-label={i18n.translate('xpack.ml.anomaliesTable.entityCell.removeFilterAriaLabel', { - defaultMessage: 'Remove filter', - })} - /> - - ); + + ); + } } /* @@ -65,12 +81,12 @@ function getRemoveFilter({ entityName, entityValue, filter }) { * of the entity, such as a partitioning or influencer field value, and optionally links for * adding or removing a filter on this entity. */ -export const EntityCell = function EntityCell({ +export const EntityCell: FC = ({ entityName, entityValue, filter, wrapText = false, -}) { +}) => { let valueText = entityValue === '' ? {EMPTY_FIELD_VALUE_LABEL} : entityValue; if (entityName === MLCATEGORY) { valueText = `${MLCATEGORY} ${valueText}`; @@ -117,10 +133,3 @@ export const EntityCell = function EntityCell({ ); } }; - -EntityCell.propTypes = { - entityName: PropTypes.string, - entityValue: PropTypes.any, - filter: PropTypes.func, - wrapText: PropTypes.bool, -}; diff --git a/x-pack/plugins/ml/public/application/components/entity_cell/index.ts b/x-pack/plugins/ml/public/application/components/entity_cell/index.ts new file mode 100644 index 00000000000000..d29e2adf66bfe8 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/entity_cell/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 { EntityCell, EntityCellFilter } from './entity_cell'; diff --git a/x-pack/plugins/ml/public/application/components/influencers_list/index.js b/x-pack/plugins/ml/public/application/components/influencers_list/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/components/influencers_list/index.js rename to x-pack/plugins/ml/public/application/components/influencers_list/index.ts diff --git a/x-pack/plugins/ml/public/application/components/influencers_list/influencers_list.js b/x-pack/plugins/ml/public/application/components/influencers_list/influencers_list.tsx similarity index 71% rename from x-pack/plugins/ml/public/application/components/influencers_list/influencers_list.js rename to x-pack/plugins/ml/public/application/components/influencers_list/influencers_list.tsx index ee562428114cef..a4c0aab282d15e 100644 --- a/x-pack/plugins/ml/public/application/components/influencers_list/influencers_list.js +++ b/x-pack/plugins/ml/public/application/components/influencers_list/influencers_list.tsx @@ -9,17 +9,39 @@ * React component for rendering a list of Machine Learning influencers. */ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { FC } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, EuiToolTip } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { abbreviateWholeNumber } from '../../formatters/abbreviate_whole_number'; -import { getSeverity } from '../../../../common/util/anomaly_utils'; -import { EntityCell } from '../entity_cell'; +import { getSeverity, getFormattedSeverityScore } from '../../../../common/util/anomaly_utils'; +import { EntityCell, EntityCellFilter } from '../entity_cell'; -function getTooltipContent(maxScoreLabel, totalScoreLabel) { +interface InfluencerValueData { + influencerFieldValue: string; + maxAnomalyScore: number; + sumAnomalyScore: number; +} + +interface InfluencerProps { + influencerFieldName: string; + influencerFilter: EntityCellFilter; + valueData: InfluencerValueData; +} + +interface InfluencersByNameProps { + influencerFieldName: string; + influencerFilter: EntityCellFilter; + fieldValues: InfluencerValueData[]; +} + +interface InfluencersListProps { + influencers: { [id: string]: InfluencerValueData[] }; + influencerFilter: EntityCellFilter; +} + +function getTooltipContent(maxScoreLabel: string, totalScoreLabel: string) { return (

    @@ -40,13 +62,12 @@ function getTooltipContent(maxScoreLabel, totalScoreLabel) { ); } -function Influencer({ influencerFieldName, influencerFilter, valueData }) { - const maxScorePrecise = valueData.maxAnomalyScore; - const maxScore = parseInt(maxScorePrecise); - const maxScoreLabel = maxScore !== 0 ? maxScore : '< 1'; +const Influencer: FC = ({ influencerFieldName, influencerFilter, valueData }) => { + const maxScore = Math.floor(valueData.maxAnomalyScore); + const maxScoreLabel = getFormattedSeverityScore(valueData.maxAnomalyScore); const severity = getSeverity(maxScore); - const totalScore = parseInt(valueData.sumAnomalyScore); - const totalScoreLabel = totalScore !== 0 ? totalScore : '< 1'; + const totalScore = Math.floor(valueData.sumAnomalyScore); + const totalScoreLabel = getFormattedSeverityScore(valueData.sumAnomalyScore); // Ensure the bar has some width for 0 scores. const barScore = maxScore !== 0 ? maxScore : 1; @@ -59,17 +80,13 @@ function Influencer({ influencerFieldName, influencerFilter, valueData }) { return (

    - {influencerFieldName !== 'mlcategory' ? ( - - ) : ( -
    mlcategory {valueData.influencerFieldValue}
    - )} +
    -
    +
    @@ -96,14 +113,13 @@ function Influencer({ influencerFieldName, influencerFilter, valueData }) {
    ); -} -Influencer.propTypes = { - influencerFieldName: PropTypes.string.isRequired, - influencerFilter: PropTypes.func, - valueData: PropTypes.object.isRequired, }; -function InfluencersByName({ influencerFieldName, influencerFilter, fieldValues }) { +const InfluencersByName: FC = ({ + influencerFieldName, + influencerFilter, + fieldValues, +}) => { const influencerValues = fieldValues.map((valueData) => ( ); -} -InfluencersByName.propTypes = { - influencerFieldName: PropTypes.string.isRequired, - influencerFilter: PropTypes.func, - fieldValues: PropTypes.array.isRequired, }; -export function InfluencersList({ influencers, influencerFilter }) { +export const InfluencersList: FC = ({ influencers, influencerFilter }) => { if (influencers === undefined || Object.keys(influencers).length === 0) { return ( @@ -158,8 +169,4 @@ export function InfluencersList({ influencers, influencerFilter }) { )); return
    {influencersByName}
    ; -} -InfluencersList.propTypes = { - influencers: PropTypes.object, - influencerFilter: PropTypes.func, }; diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index a8df8f8174bd3f..1dd30d5d99335d 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -17,7 +17,8 @@ import { SharePluginStart } from '../../../../../../../src/plugins/share/public' import { MlServicesContext } from '../../app'; import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; import type { EmbeddableStart } from '../../../../../../../src/plugins/embeddable/public'; -import { MapsStartApi } from '../../../../../maps/public'; +import type { MapsStartApi } from '../../../../../maps/public'; +import type { LensPublicStart } from '../../../../../lens/public'; interface StartPlugins { data: DataPublicPluginStart; @@ -26,6 +27,7 @@ interface StartPlugins { share: SharePluginStart; embeddable: EmbeddableStart; maps?: MapsStartApi; + lens?: LensPublicStart; } export type StartServices = CoreStart & StartPlugins & { diff --git a/x-pack/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx b/x-pack/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx index 5f451339746bb1..79d17a7846b8c9 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx @@ -137,7 +137,7 @@ export const DatavisualizerSelector: FC = () => { > } @@ -167,7 +167,7 @@ export const DatavisualizerSelector: FC = () => { > } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/combined_query.ts b/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/combined_query.ts new file mode 100644 index 00000000000000..7723277959b1f2 --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/combined_query.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface CombinedQuery { + searchString: string | { [key: string]: any }; + searchQueryLanguage: string; +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/index.ts b/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/index.ts index 50a67b946e5257..fe99a634327934 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/index.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/index.ts @@ -6,3 +6,4 @@ */ export { FieldHistogramRequestConfig, FieldRequestConfig } from './request'; +export type { CombinedQuery } from './combined_query'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx index 9dd455427b7476..255dfcc21ccab8 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx @@ -5,23 +5,51 @@ * 2.0. */ -import React, { FC, useState } from 'react'; +import React, { FC, useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiText, EuiTitle, EuiFlexGroup } from '@elastic/eui'; +import { + EuiSpacer, + EuiText, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiCard, + EuiIcon, +} from '@elastic/eui'; import { Link } from 'react-router-dom'; -import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; import { CreateJobLinkCard } from '../../../../components/create_job_link_card'; import { DataRecognizer } from '../../../../components/data_recognizer'; import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; +import { + DISCOVER_APP_URL_GENERATOR, + DiscoverUrlGeneratorState, +} from '../../../../../../../../../src/plugins/discover/public'; +import { useMlKibana } from '../../../../contexts/kibana'; +import { isFullLicense } from '../../../../license'; +import { checkPermission } from '../../../../capabilities/check_capabilities'; +import { mlNodesAvailable } from '../../../../ml_nodes_check'; +import { useUrlState } from '../../../../util/url_state'; +import type { IIndexPattern } from '../../../../../../../../../src/plugins/data/common'; interface Props { - indexPattern: IndexPattern; + indexPattern: IIndexPattern; + searchString?: string | { [key: string]: any }; + searchQueryLanguage?: string; } -export const ActionsPanel: FC = ({ indexPattern }) => { +export const ActionsPanel: FC = ({ indexPattern, searchString, searchQueryLanguage }) => { const [recognizerResultsCount, setRecognizerResultsCount] = useState(0); + const [discoverLink, setDiscoverLink] = useState(''); + const { + services: { + share: { + urlGenerators: { getUrlGenerator }, + }, + }, + } = useMlKibana(); + const [globalState] = useUrlState('_g'); const recognizerResults = { count: 0, @@ -29,63 +57,146 @@ export const ActionsPanel: FC = ({ indexPattern }) => { setRecognizerResultsCount(recognizerResults.count); }, }; + const showCreateJob = + isFullLicense() && + checkPermission('canCreateJob') && + mlNodesAvailable() && + indexPattern.timeFieldName !== undefined; const createJobLink = `/${ML_PAGES.ANOMALY_DETECTION_CREATE_JOB}/advanced?index=${indexPattern.id}`; + useEffect(() => { + let unmounted = false; + + const indexPatternId = indexPattern.id; + const getDiscoverUrl = async (): Promise => { + const state: DiscoverUrlGeneratorState = { + indexPatternId, + }; + if (searchString && searchQueryLanguage !== undefined) { + state.query = { query: searchString, language: searchQueryLanguage }; + } + if (globalState?.time) { + state.timeRange = globalState.time; + } + if (globalState?.refreshInterval) { + state.refreshInterval = globalState.refreshInterval; + } + + 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); + } + }; + getDiscoverUrl(); + return () => { + unmounted = true; + }; + }, [indexPattern, searchString, searchQueryLanguage, globalState]); + // Note we use display:none for the DataRecognizer section as it needs to be // passed the recognizerResults object, and then run the recognizer check which // controls whether the recognizer section is ultimately displayed. return (
    - -

    - -

    -
    - -
    - -

    - + +

    + +

    + + + + +

    + +

    +
    + + + + + + + )} + + {discoverLink && ( + <> + +

    + +

    +
    + + + } + description={i18n.translate( + 'xpack.ml.datavisualizer.actionsPanel.viewIndexInDiscoverDescription', + { + defaultMessage: 'Explore index in Discover', + } + )} + title={ + + } + href={discoverLink} /> -

    -
    - - - - - -
    - -

    - -

    -
    - - - - + + + )}
    ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/expanded_row.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/expanded_row.tsx index 96531de23fa4dc..8a0656abe95cc1 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/expanded_row.tsx @@ -10,7 +10,6 @@ import React from 'react'; import { ML_JOB_FIELD_TYPES } from '../../../../../../common/constants/field_types'; import { LoadingIndicator } from '../field_data_row/loading_indicator'; import { NotInDocsContent } from '../field_data_row/content_types'; -import { FieldVisConfig } from '../../../stats_table/types'; import { BooleanContent, DateContent, @@ -20,8 +19,10 @@ import { OtherContent, TextContent, } from '../../../stats_table/components/field_data_expanded_row'; -import { CombinedQuery, GeoPointContent } from './geo_point_content'; -import { IndexPattern } from '../../../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; +import { GeoPointContent } from './geo_point_content'; +import type { CombinedQuery } from '../../common'; +import type { IndexPattern } from '../../../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; +import type { FieldVisConfig } from '../../../stats_table/types'; export const IndexBasedDataVisualizerExpandedRow = ({ item, diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/geo_point_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/geo_point_content.tsx index cea65edbfb55ab..33b347b4da805d 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/geo_point_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/geo_point_content.tsx @@ -9,20 +9,17 @@ import React, { FC, useEffect, useState } from 'react'; import { EuiFlexItem } from '@elastic/eui'; import { ExamplesList } from '../../../index_based/components/field_data_row/examples_list'; -import { FieldVisConfig } from '../../../stats_table/types'; -import { IndexPattern } from '../../../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; import { MlEmbeddedMapComponent } from '../../../../components/ml_embedded_map'; import { ML_JOB_FIELD_TYPES } from '../../../../../../common/constants/field_types'; import { ES_GEO_FIELD_TYPE } from '../../../../../../../maps/common/constants'; -import { LayerDescriptor } from '../../../../../../../maps/common/descriptor_types'; import { useMlKibana } from '../../../../contexts/kibana'; import { DocumentStatsTable } from '../../../stats_table/components/field_data_expanded_row/document_stats'; import { ExpandedRowContent } from '../../../stats_table/components/field_data_expanded_row/expanded_row_content'; +import type { CombinedQuery } from '../../common'; +import type { IndexPattern } from '../../../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; +import type { LayerDescriptor } from '../../../../../../../maps/common/descriptor_types'; +import type { FieldVisConfig } from '../../../stats_table/types'; -export interface CombinedQuery { - searchString: string | { [key: string]: any }; - searchQueryLanguage: string; -} export const GeoPointContent: FC<{ config: FieldVisConfig; indexPattern: IndexPattern | undefined; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/action_menu/actions.ts b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/action_menu/actions.ts new file mode 100644 index 00000000000000..57675927ce816f --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/action_menu/actions.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 { i18n } from '@kbn/i18n'; +import { Action } from '@elastic/eui/src/components/basic_table/action_types'; +import { getCompatibleLensDataType, getLensAttributes } from './lens_utils'; +import type { CombinedQuery } from '../../../common'; +import type { IIndexPattern } from '../../../../../../../../../../src/plugins/data/common/index_patterns'; +import type { LensPublicStart } from '../../../../../../../../lens/public'; +import type { FieldVisConfig } from '../../../../stats_table/types'; + +export function getActions( + indexPattern: IIndexPattern, + lensPlugin: LensPublicStart, + combinedQuery: CombinedQuery +): Array> { + const canUseLensEditor = lensPlugin.canUseEditor(); + return [ + { + name: i18n.translate('xpack.ml.dataVisualizer.indexBasedDataGrid.exploreInLensTitle', { + defaultMessage: 'Explore in Lens', + }), + description: i18n.translate( + 'xpack.ml.dataVisualizer.indexBasedDataGrid.exploreInLensDescription', + { + defaultMessage: 'Explore in Lens', + } + ), + type: 'icon', + icon: 'lensApp', + available: (item: FieldVisConfig) => + getCompatibleLensDataType(item.type) !== undefined && canUseLensEditor, + onClick: (item: FieldVisConfig) => { + const lensAttributes = getLensAttributes(indexPattern, combinedQuery, item); + if (lensAttributes) { + lensPlugin.navigateToPrefilledEditor({ + id: `ml-dataVisualizer-${item.fieldName}`, + attributes: lensAttributes, + }); + } + }, + 'data-test-subj': 'mlActionButtonViewInLens', + }, + ]; +} diff --git a/x-pack/plugins/ml/public/application/components/entity_cell/index.js b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/action_menu/index.ts similarity index 85% rename from x-pack/plugins/ml/public/application/components/entity_cell/index.js rename to x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/action_menu/index.ts index f1fbb8ede4ee24..df36cc89ce911a 100644 --- a/x-pack/plugins/ml/public/application/components/entity_cell/index.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/action_menu/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { EntityCell } from './entity_cell'; +export { getActions } from './actions'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/action_menu/lens_utils.ts b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/action_menu/lens_utils.ts new file mode 100644 index 00000000000000..8d078b59ad7785 --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/action_menu/lens_utils.ts @@ -0,0 +1,288 @@ +/* + * 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 { ML_JOB_FIELD_TYPES } from '../../../../../../../common/constants/field_types'; +import type { TypedLensByValueInput } from '../../../../../../../../lens/public'; +import type { FieldVisConfig } from '../../../../stats_table/types'; +import type { IndexPatternColumn, XYLayerConfig } from '../../../../../../../../lens/public'; +import type { CombinedQuery } from '../../../common'; +import type { IIndexPattern } from '../../../../../../../../../../src/plugins/data/common/index_patterns'; +interface ColumnsAndLayer { + columns: Record; + layer: XYLayerConfig; +} + +const TOP_VALUES_LABEL = i18n.translate('xpack.ml.dataVisualizer.lensChart.topValuesLabel', { + defaultMessage: 'Top values', +}); +const COUNT = i18n.translate('xpack.ml.dataVisualizer.lensChart.countLabel', { + defaultMessage: 'Count', +}); + +export function getNumberSettings(item: FieldVisConfig, defaultIndexPattern: IIndexPattern) { + // if index has no timestamp field + if (defaultIndexPattern.timeFieldName === undefined) { + const columns: Record = { + col1: { + label: item.fieldName!, + dataType: 'number', + isBucketed: true, + operationType: 'range', + params: { + type: 'histogram', + maxBars: 'auto', + ranges: [], + }, + sourceField: item.fieldName!, + }, + col2: { + label: COUNT, + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }; + + const layer: XYLayerConfig = { + accessors: ['col2'], + layerId: 'layer1', + seriesType: 'bar', + xAccessor: 'col1', + }; + return { columns, layer }; + } + + const columns: Record = { + col2: { + dataType: 'number', + isBucketed: false, + label: i18n.translate('xpack.ml.dataVisualizer.lensChart.averageOfLabel', { + defaultMessage: 'Average of {fieldName}', + values: { fieldName: item.fieldName }, + }), + operationType: 'avg', + sourceField: item.fieldName!, + }, + col1: { + dataType: 'date', + isBucketed: true, + label: defaultIndexPattern.timeFieldName!, + operationType: 'date_histogram', + params: { interval: 'auto' }, + scale: 'interval', + sourceField: defaultIndexPattern.timeFieldName!, + }, + }; + + const layer: XYLayerConfig = { + accessors: ['col2'], + layerId: 'layer1', + seriesType: 'line', + xAccessor: 'col1', + }; + + return { columns, layer }; +} +export function getDateSettings(item: FieldVisConfig) { + const columns: Record = { + col2: { + dataType: 'number', + isBucketed: false, + label: COUNT, + operationType: 'count', + scale: 'ratio', + sourceField: 'Records', + }, + col1: { + dataType: 'date', + isBucketed: true, + label: item.fieldName!, + operationType: 'date_histogram', + params: { interval: 'auto' }, + scale: 'interval', + sourceField: item.fieldName!, + }, + }; + const layer: XYLayerConfig = { + accessors: ['col2'], + layerId: 'layer1', + seriesType: 'line', + xAccessor: 'col1', + }; + + return { columns, layer }; +} + +export function getKeywordSettings(item: FieldVisConfig) { + const columns: Record = { + col1: { + label: TOP_VALUES_LABEL, + dataType: 'string', + isBucketed: true, + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col2' }, + size: 10, + orderDirection: 'desc', + }, + sourceField: item.fieldName!, + }, + col2: { + label: COUNT, + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }; + const layer: XYLayerConfig = { + accessors: ['col2'], + layerId: 'layer1', + seriesType: 'bar', + xAccessor: 'col1', + }; + + return { columns, layer }; +} + +export function getBooleanSettings(item: FieldVisConfig) { + const columns: Record = { + col1: { + label: TOP_VALUES_LABEL, + dataType: 'string', + isBucketed: true, + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 2, + orderDirection: 'desc', + }, + sourceField: item.fieldName!, + }, + col2: { + label: COUNT, + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }; + const layer: XYLayerConfig = { + accessors: ['col2'], + layerId: 'layer1', + seriesType: 'bar', + xAccessor: 'col1', + }; + + return { columns, layer }; +} + +export function getCompatibleLensDataType(type: FieldVisConfig['type']): string | undefined { + let lensType: string | undefined; + switch (type) { + case ML_JOB_FIELD_TYPES.KEYWORD: + lensType = 'string'; + break; + case ML_JOB_FIELD_TYPES.DATE: + lensType = 'date'; + break; + case ML_JOB_FIELD_TYPES.NUMBER: + lensType = 'number'; + break; + case ML_JOB_FIELD_TYPES.IP: + lensType = 'ip'; + break; + case ML_JOB_FIELD_TYPES.BOOLEAN: + lensType = 'string'; + break; + default: + lensType = undefined; + } + return lensType; +} + +function getColumnsAndLayer( + fieldType: FieldVisConfig['type'], + item: FieldVisConfig, + defaultIndexPattern: IIndexPattern +): ColumnsAndLayer | undefined { + if (item.fieldName === undefined) return; + + if (fieldType === ML_JOB_FIELD_TYPES.DATE) { + return getDateSettings(item); + } + if (fieldType === ML_JOB_FIELD_TYPES.NUMBER) { + return getNumberSettings(item, defaultIndexPattern); + } + if (fieldType === ML_JOB_FIELD_TYPES.IP || fieldType === ML_JOB_FIELD_TYPES.KEYWORD) { + return getKeywordSettings(item); + } + if (fieldType === ML_JOB_FIELD_TYPES.BOOLEAN) { + return getBooleanSettings(item); + } +} +// Get formatted Lens visualization format depending on field type +// currently only supports the following types: +// 'document' | 'string' | 'number' | 'date' | 'boolean' | 'ip' +export function getLensAttributes( + defaultIndexPattern: IIndexPattern | undefined, + combinedQuery: CombinedQuery, + item: FieldVisConfig +): TypedLensByValueInput['attributes'] | undefined { + if (defaultIndexPattern === undefined || item.type === undefined || item.fieldName === undefined) + return; + + const presets = getColumnsAndLayer(item.type, item, defaultIndexPattern); + + if (!presets) return; + + return { + visualizationType: 'lnsXY', + title: i18n.translate('xpack.ml.dataVisualizer.lensChart.chartTitle', { + defaultMessage: 'Lens for {fieldName}', + values: { fieldName: item.fieldName }, + }), + references: [ + { + id: defaultIndexPattern.id!, + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: defaultIndexPattern.id!, + name: 'indexpattern-datasource-layer-layer1', + type: 'index-pattern', + }, + ], + state: { + datasourceStates: { + indexpattern: { + layers: { + layer1: { + columnOrder: ['col1', 'col2'], + columns: presets.columns, + }, + }, + }, + }, + filters: [], + query: { language: combinedQuery.searchQueryLanguage, query: combinedQuery.searchString }, + visualization: { + axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true }, + fittingFunction: 'None', + gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true }, + layers: [presets.layer], + legend: { isVisible: true, position: 'right' }, + preferredSeriesType: 'line', + tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, + valueLabels: 'hide', + }, + }, + }; +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx index 6ea85c354d88ba..6bc1970bc615b2 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx @@ -19,6 +19,8 @@ import { EuiSpacer, EuiTitle, } from '@elastic/eui'; +import { EuiTableActionsColumnType } from '@elastic/eui/src/components/basic_table/table_types'; +import { FormattedMessage } from '@kbn/i18n/react'; import { IFieldType, KBN_FIELD_TYPES, @@ -32,9 +34,6 @@ import { NavigationMenu } from '../../components/navigation_menu'; import { DatePickerWrapper } from '../../components/navigation_menu/date_picker_wrapper'; import { ML_JOB_FIELD_TYPES } from '../../../../common/constants/field_types'; import { SEARCH_QUERY_LANGUAGE, SearchQueryLanguage } from '../../../../common/constants/search'; -import { isFullLicense } from '../../license'; -import { checkPermission } from '../../capabilities/check_capabilities'; -import { mlNodesAvailable } from '../../ml_nodes_check/check_ml_nodes'; import { FullTimeRangeSelector } from '../../components/full_time_range_selector'; import { mlTimefilterRefresh$ } from '../../services/timefilter_refresh_service'; import { useMlContext } from '../../contexts/ml'; @@ -63,6 +62,7 @@ import type { MetricFieldsStats, TotalFieldsStats, } from '../stats_table/components/field_count_stats'; +import { getActions } from './components/field_data_row/action_menu/actions'; interface DataVisualizerPageState { overallStats: OverallStats; @@ -116,6 +116,10 @@ export const getDefaultDataVisualizerListState = (): Required { const mlContext = useMlContext(); const restorableDefaults = getDefaultDataVisualizerListState(); + const { + services: { lens: lensPlugin, docLinks }, + } = useMlKibana(); + const [dataVisualizerListState, setDataVisualizerListState] = usePageUrlState( ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER, restorableDefaults @@ -167,12 +171,6 @@ export const Page: FC = () => { const defaults = getDefaultPageState(); - const showActionsPanel = - isFullLicense() && - checkPermission('canCreateJob') && - mlNodesAvailable() && - currentIndexPattern.timeFieldName !== undefined; - const { searchQueryLanguage, searchString, searchQuery } = useMemo(() => { const searchData = extractSearchData(currentSavedSearch); if (searchData === undefined || dataVisualizerListState.searchString !== '') { @@ -686,9 +684,27 @@ export const Page: FC = () => { [currentIndexPattern, searchQuery] ); - const { - services: { docLinks }, - } = useMlKibana(); + // Inject custom action column for the index based visualizer + const extendedColumns = useMemo(() => { + if (lensPlugin === undefined) { + // eslint-disable-next-line no-console + console.error('Lens plugin not available'); + return; + } + const actionColumn: EuiTableActionsColumnType = { + name: ( + + ), + actions: getActions(currentIndexPattern, lensPlugin, { searchQueryLanguage, searchString }), + width: '100px', + }; + + return [actionColumn]; + }, [currentIndexPattern, lensPlugin, searchQueryLanguage, searchString]); + const helpLink = docLinks.links.ml.guide; return ( @@ -766,14 +782,17 @@ export const Page: FC = () => { pageState={dataVisualizerListState} updatePageState={setDataVisualizerListState} getItemIdToExpandedRowMap={getItemIdToExpandedRowMap} + extendedColumns={extendedColumns} /> - {showActionsPanel === true && ( - - - - )} + + +
    diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/data_visualizer_stats_table.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/data_visualizer_stats_table.tsx index 82e807fa61e674..2a6a681c632107 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/data_visualizer_stats_table.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/data_visualizer_stats_table.tsx @@ -9,6 +9,7 @@ import React, { useMemo, useState } from 'react'; import { CENTER_ALIGNMENT, + EuiBasicTableColumn, EuiButtonIcon, EuiFlexItem, EuiIcon, @@ -52,6 +53,7 @@ interface DataVisualizerTableProps { update: Partial ) => void; getItemIdToExpandedRowMap: (itemIds: string[], items: T[]) => ItemIdToExpandedRowMap; + extendedColumns?: Array>; } export const DataVisualizerTable = ({ @@ -59,11 +61,12 @@ export const DataVisualizerTable = ({ pageState, updatePageState, getItemIdToExpandedRowMap, + extendedColumns, }: DataVisualizerTableProps) => { const [expandedRowItemIds, setExpandedRowItemIds] = useState([]); const [expandAll, toggleExpandAll] = useState(false); - const { onTableChange, pagination, sorting } = useTableSettings( + const { onTableChange, pagination, sorting } = useTableSettings( items, pageState, updatePageState @@ -136,7 +139,7 @@ export const DataVisualizerTable = ({ 'data-test-subj': 'mlDataVisualizerTableColumnDetailsToggle', }; - return [ + const baseColumns = [ expanderColumn, { field: 'type', @@ -236,7 +239,8 @@ export const DataVisualizerTable = ({ 'data-test-subj': 'mlDataVisualizerTableColumnDistribution', }, ]; - }, [expandAll, showDistributions, updatePageState]); + return extendedColumns ? [...baseColumns, ...extendedColumns] : baseColumns; + }, [expandAll, showDistributions, updatePageState, extendedColumns]); const itemIdToExpandedRowMap = useMemo(() => { let itemIds = expandedRowItemIds; @@ -248,7 +252,7 @@ export const DataVisualizerTable = ({ return ( - + className={'mlDataVisualizer'} items={items} itemId={FIELD_NAME} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js index d0cfe55e8d01e3..4607ac65c87a6e 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js @@ -20,7 +20,11 @@ import moment from 'moment'; import { formatHumanReadableDateTime } from '../../../../common/util/date_utils'; import { formatValue } from '../../formatters/format_value'; -import { getSeverityColor, getSeverityWithLow } from '../../../../common/util/anomaly_utils'; +import { + getFormattedSeverityScore, + getSeverityColor, + getSeverityWithLow, +} from '../../../../common/util/anomaly_utils'; import { getChartType, getTickValues, @@ -458,7 +462,7 @@ export class ExplorerChartDistribution extends React.Component { if (marker.anomalyScore !== undefined) { const score = parseInt(marker.anomalyScore); - const displayScore = score > 0 ? score : '< 1'; + const displayScore = getFormattedSeverityScore(score); tooltipData.push({ label: i18n.translate('xpack.ml.explorer.distributionChart.anomalyScoreLabel', { defaultMessage: 'anomaly score', diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js index 109592c2079409..d2d81e0349c68f 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js @@ -21,6 +21,7 @@ import { i18n } from '@kbn/i18n'; import { formatHumanReadableDateTime } from '../../../../common/util/date_utils'; import { formatValue } from '../../formatters/format_value'; import { + getFormattedSeverityScore, getSeverityColor, getSeverityWithLow, getMultiBucketImpactLabel, @@ -380,12 +381,11 @@ export class ExplorerChartSingleMetric extends React.Component { if (marker.anomalyScore !== undefined) { const score = parseInt(marker.anomalyScore); - const displayScore = score > 0 ? score : '< 1'; tooltipData.push({ label: i18n.translate('xpack.ml.explorer.singleMetricChart.anomalyScoreLabel', { defaultMessage: 'anomaly score', }), - value: displayScore, + value: getFormattedSeverityScore(score), color: getSeverityColor(score), seriesIdentifier: { key: seriesKey, diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js index 2b88a9cbcaaf5a..98d8b5eaf912a7 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js @@ -373,11 +373,14 @@ export function filterJobs(jobs, clauses) { // start datafeed modal. export function checkForAutoStartDatafeed() { const job = mlJobService.tempJobCloningObjects.job; + const datafeed = mlJobService.tempJobCloningObjects.datafeed; if (job !== undefined) { mlJobService.tempJobCloningObjects.job = undefined; - const hasDatafeed = - typeof job.datafeed_config === 'object' && Object.keys(job.datafeed_config).length > 0; - const datafeedId = hasDatafeed ? job.datafeed_config.datafeed_id : ''; + mlJobService.tempJobCloningObjects.datafeed = undefined; + mlJobService.tempJobCloningObjects.createdBy = undefined; + + const hasDatafeed = typeof datafeed === 'object' && Object.keys(datafeed).length > 0; + const datafeedId = hasDatafeed ? datafeed.datafeed_id : ''; return { id: job.job_id, hasDatafeed, diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts index d24b1cbf22de34..9ae8585933ca80 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts @@ -229,17 +229,14 @@ export function isSparseDataJob(job: Job, datafeed: Datafeed): boolean { return false; } -function stashCombinedJob( +function stashJobForCloning( jobCreator: JobCreatorType, skipTimeRangeStep: boolean = false, includeTimeRange: boolean = false ) { - const combinedJob = { - ...jobCreator.jobConfig, - datafeed_config: jobCreator.datafeedConfig, - }; - - mlJobService.tempJobCloningObjects.job = combinedJob; + mlJobService.tempJobCloningObjects.job = jobCreator.jobConfig; + mlJobService.tempJobCloningObjects.datafeed = jobCreator.datafeedConfig; + mlJobService.tempJobCloningObjects.createdBy = jobCreator.createdBy ?? undefined; // skip over the time picker step of the wizard mlJobService.tempJobCloningObjects.skipTimeRangeStep = skipTimeRangeStep; @@ -259,21 +256,21 @@ export function convertToMultiMetricJob( ) { jobCreator.createdBy = CREATED_BY_LABEL.MULTI_METRIC; jobCreator.modelPlot = false; - stashCombinedJob(jobCreator, true, true); + stashJobForCloning(jobCreator, true, true); navigateToPath(`jobs/new_job/${JOB_TYPE.MULTI_METRIC}`, true); } export function convertToAdvancedJob(jobCreator: JobCreatorType, navigateToPath: NavigateToPath) { jobCreator.createdBy = null; - stashCombinedJob(jobCreator, true, true); + stashJobForCloning(jobCreator, true, true); navigateToPath(`jobs/new_job/${JOB_TYPE.ADVANCED}`, true); } export function resetJob(jobCreator: JobCreatorType, navigateToPath: NavigateToPath) { jobCreator.jobId = ''; - stashCombinedJob(jobCreator, true, true); + stashJobForCloning(jobCreator, true, true); navigateToPath('/jobs/new_job'); } @@ -282,7 +279,7 @@ export function advancedStartDatafeed( navigateToPath: NavigateToPath ) { if (jobCreator !== null) { - stashCombinedJob(jobCreator, false, false); + stashJobForCloning(jobCreator, false, false); } navigateToPath('/jobs'); } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx index 7b9d79a2cfb2fa..c8dbb908044446 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx @@ -72,7 +72,10 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { let autoSetTimeRange = false; - if (mlJobService.tempJobCloningObjects.job !== undefined) { + if ( + mlJobService.tempJobCloningObjects.job !== undefined && + mlJobService.tempJobCloningObjects.datafeed !== undefined + ) { // cloning a job const clonedJob = mlJobService.tempJobCloningObjects.job; const clonedDatafeed = mlJobService.cloneDatafeed(mlJobService.tempJobCloningObjects.datafeed); @@ -89,6 +92,8 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { mlJobService.tempJobCloningObjects.skipTimeRangeStep = false; mlJobService.tempJobCloningObjects.job = undefined; + mlJobService.tempJobCloningObjects.datafeed = undefined; + mlJobService.tempJobCloningObjects.createdBy = undefined; if ( mlJobService.tempJobCloningObjects.start !== undefined && diff --git a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/table.tsx b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/table.tsx index b62df648d19317..7c6b109f059f2b 100644 --- a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/table.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/table.tsx @@ -30,7 +30,10 @@ import { StatsBar, JobStatsBarStats } from '../../../components/stats_bar'; // @ts-ignore import { JobSelectorBadge } from '../../../components/job_selector/job_selector_badge/index'; import { toLocaleString } from '../../../util/string_utils'; -import { getSeverityColor } from '../../../../../common/util/anomaly_utils'; +import { + getFormattedSeverityScore, + getSeverityColor, +} from '../../../../../common/util/anomaly_utils'; // Used to pass on attribute names to table columns export enum AnomalyDetectionListColumns { @@ -125,7 +128,7 @@ export const AnomalyDetectionTable: FC = ({ items, jobsList, statsBarData return ( // @ts-ignore - {score >= 1 ? Math.floor(score) : '< 1'} + {getFormattedSeverityScore(score)} ); } diff --git a/x-pack/plugins/ml/public/application/services/job_service.d.ts b/x-pack/plugins/ml/public/application/services/job_service.d.ts index b954f1d344b452..544d346341591c 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/job_service.d.ts @@ -7,7 +7,7 @@ import { SearchResponse } from 'elasticsearch'; import { TimeRange } from 'src/plugins/data/common/query/timefilter/types'; -import { CombinedJob, Datafeed } from '../../../common/types/anomaly_detection_jobs'; +import { CombinedJob, Datafeed, Job } from '../../../common/types/anomaly_detection_jobs'; import { Calendar } from '../../../common/types/calendars'; export interface ExistingJobsAndGroups { @@ -21,15 +21,15 @@ declare interface JobService { tempJobCloningObjects: { createdBy?: string; datafeed?: Datafeed; - job: any; + job?: Job; skipTimeRangeStep: boolean; start?: number; end?: number; calendars: Calendar[] | undefined; }; skipTimeRangeStep: boolean; - saveNewJob(job: any): Promise; - cloneDatafeed(datafeed: any): Datafeed; + saveNewJob(job: Job): Promise; + cloneDatafeed(Datafeed: Datafeed): Datafeed; openJob(jobId: string): Promise; saveNewDatafeed(datafeedConfig: any, jobId: string): Promise; startDatafeed( 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 74c9a6117e5660..fa172fa0c2190d 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 @@ -19,6 +19,7 @@ import moment from 'moment'; import { i18n } from '@kbn/i18n'; import { + getFormattedSeverityScore, getSeverityWithLow, getMultiBucketImpactLabel, } from '../../../../../common/util/anomaly_utils'; @@ -1442,12 +1443,11 @@ class TimeseriesChartIntl extends Component { if (marker.anomalyScore !== undefined) { const score = parseInt(marker.anomalyScore); - const displayScore = score > 0 ? score : '< 1'; tooltipData.push({ label: i18n.translate('xpack.ml.timeSeriesExplorer.timeSeriesChart.anomalyScoreLabel', { defaultMessage: 'anomaly score', }), - value: displayScore, + value: getFormattedSeverityScore(score), color: anomalyColorScale(score), seriesIdentifier: { key: seriesKey, diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index bfbc04943273ea..9fd245a7e16ba5 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -46,6 +46,7 @@ import { registerFeature } from './register_feature'; // Not importing from `ml_url_generator/index` here to avoid importing unnecessary code import { registerUrlGenerator } from './ml_url_generator/ml_url_generator'; import type { MapsStartApi } from '../../maps/public'; +import { LensPublicStart } from '../../lens/public'; export interface MlStartDependencies { data: DataPublicPluginStart; @@ -55,6 +56,7 @@ export interface MlStartDependencies { spaces?: SpacesPluginStart; embeddable: EmbeddableStart; maps?: MapsStartApi; + lens?: LensPublicStart; } export interface MlSetupDependencies { security?: SecurityPluginSetup; @@ -106,6 +108,7 @@ export class MlPlugin implements Plugin { embeddable: { ...pluginsSetup.embeddable, ...pluginsStart.embeddable }, maps: pluginsStart.maps, uiActions: pluginsStart.uiActions, + lens: pluginsStart.lens, kibanaVersion, }, params diff --git a/x-pack/plugins/ml/tsconfig.json b/x-pack/plugins/ml/tsconfig.json index 113bcbe71047f7..2caf88de1b76a6 100644 --- a/x-pack/plugins/ml/tsconfig.json +++ b/x-pack/plugins/ml/tsconfig.json @@ -28,6 +28,7 @@ { "path": "../license_management/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, { "path": "../maps/tsconfig.json" }, + { "path": "../lens/tsconfig.json" }, { "path": "../security/tsconfig.json" }, { "path": "../spaces/tsconfig.json" }, ] diff --git a/x-pack/plugins/monitoring/public/alerts/lib/alerts_toast.tsx b/x-pack/plugins/monitoring/public/alerts/lib/alerts_toast.tsx index 8d889a7a4dc2a0..026f1721471920 100644 --- a/x-pack/plugins/monitoring/public/alerts/lib/alerts_toast.tsx +++ b/x-pack/plugins/monitoring/public/alerts/lib/alerts_toast.tsx @@ -19,7 +19,7 @@ export interface EnableAlertResponse { } const showTlsAndEncryptionError = () => { - const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = Legacy.shims.docLinks; + const settingsUrl = Legacy.shims.docLinks.links.alerting.generalSettings; Legacy.shims.toastNotifications.addWarning({ title: toMountPoint( @@ -36,11 +36,7 @@ const showTlsAndEncryptionError = () => { })}

    - + {i18n.translate('xpack.monitoring.healthCheck.encryptionErrorAction', { defaultMessage: 'Learn how.', })} @@ -51,7 +47,7 @@ const showTlsAndEncryptionError = () => { }; const showUnableToDisableWatcherClusterAlertsError = () => { - const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = Legacy.shims.docLinks; + const settingsUrl = Legacy.shims.docLinks.links.alerting.generalSettings; Legacy.shims.toastNotifications.addWarning({ title: toMountPoint( @@ -68,11 +64,7 @@ const showUnableToDisableWatcherClusterAlertsError = () => { })}

    - + {i18n.translate('xpack.monitoring.healthCheck.unableToDisableWatches.action', { defaultMessage: 'Learn more.', })} diff --git a/x-pack/plugins/monitoring/public/components/logs/__snapshots__/reason.test.js.snap b/x-pack/plugins/monitoring/public/components/logs/__snapshots__/reason.test.js.snap index c925ecd1c98ff8..40541aeaad4c10 100644 --- a/x-pack/plugins/monitoring/public/components/logs/__snapshots__/reason.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/logs/__snapshots__/reason.test.js.snap @@ -13,7 +13,7 @@ exports[`Logs should render a default message 1`] = ` values={ Object { "link": Click here for more information @@ -67,7 +67,7 @@ exports[`Logs should render with a no cluster found reason 1`] = ` values={ Object { "link": setup @@ -92,7 +92,7 @@ exports[`Logs should render with a no index found reason 1`] = ` values={ Object { "link": setup @@ -117,7 +117,7 @@ exports[`Logs should render with a no index pattern found reason 1`] = ` values={ Object { "link": Filebeat @@ -142,7 +142,7 @@ exports[`Logs should render with a no node found reason 1`] = ` values={ Object { "link": setup @@ -167,7 +167,7 @@ exports[`Logs should render with a no structured logs reason 1`] = ` values={ Object { "link": points to JSON logs @@ -195,7 +195,7 @@ exports[`Logs should render with a no type found reason 1`] = ` values={ Object { "link": these directions diff --git a/x-pack/plugins/monitoring/public/components/logs/reason.js b/x-pack/plugins/monitoring/public/components/logs/reason.js index 538c8934cdaeff..512b44c8165b10 100644 --- a/x-pack/plugins/monitoring/public/components/logs/reason.js +++ b/x-pack/plugins/monitoring/public/components/logs/reason.js @@ -13,7 +13,9 @@ import { Legacy } from '../../legacy_shims'; import { Monospace } from '../metricbeat_migration/instruction_steps/components/monospace/monospace'; export const Reason = ({ reason }) => { - const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = Legacy.shims.docLinks; + const filebeatUrl = Legacy.shims.docLinks.links.filebeat.installation; + const elasticsearchUrl = Legacy.shims.docLinks.links.filebeat.elasticsearchModule; + const troubleshootUrl = Legacy.shims.docLinks.links.monitoring.troubleshootKibana; let title = i18n.translate('xpack.monitoring.logs.reason.defaultTitle', { defaultMessage: 'No log data found', }); @@ -23,10 +25,7 @@ export const Reason = ({ reason }) => { defaultMessage="We did not find any log data and we are unable to diagnose why. {link}" values={{ link: ( - + { defaultMessage="Set up {link}, then configure your Elasticsearch output to your monitoring cluster." values={{ link: ( - + {i18n.translate('xpack.monitoring.logs.reason.noIndexPatternLink', { defaultMessage: 'Filebeat', })} @@ -82,10 +78,7 @@ export const Reason = ({ reason }) => { defaultMessage="Follow {link} to set up Elasticsearch." values={{ link: ( - + {i18n.translate('xpack.monitoring.logs.reason.noTypeLink', { defaultMessage: 'these directions', })} @@ -105,10 +98,7 @@ export const Reason = ({ reason }) => { values={{ varPaths: var.paths, link: ( - + {i18n.translate('xpack.monitoring.logs.reason.notUsingStructuredLogsLink', { defaultMessage: 'points to JSON logs', })} @@ -127,10 +117,7 @@ export const Reason = ({ reason }) => { defaultMessage="Check that your {link} is correct." values={{ link: ( - + {i18n.translate('xpack.monitoring.logs.reason.noClusterLink', { defaultMessage: 'setup', })} @@ -149,10 +136,7 @@ export const Reason = ({ reason }) => { defaultMessage="Check that your {link} is correct." values={{ link: ( - + {i18n.translate('xpack.monitoring.logs.reason.noNodeLink', { defaultMessage: 'setup', })} @@ -171,10 +155,7 @@ export const Reason = ({ reason }) => { defaultMessage="We found logs, but none for this index. If this problem continues, check that your {link} is correct." values={{ link: ( - + {i18n.translate('xpack.monitoring.logs.reason.noIndexLink', { defaultMessage: 'setup', })} @@ -193,10 +174,7 @@ export const Reason = ({ reason }) => { defaultMessage="There is an issue reading from your filebeat indices. {link}." values={{ link: ( - + {i18n.translate('xpack.monitoring.logs.reason.correctIndexNameLink', { defaultMessage: 'Click here for more information', })} diff --git a/x-pack/plugins/monitoring/public/components/logs/reason.test.js b/x-pack/plugins/monitoring/public/components/logs/reason.test.js index 53aad5511e0ae3..0d75af1d1048fb 100644 --- a/x-pack/plugins/monitoring/public/components/logs/reason.test.js +++ b/x-pack/plugins/monitoring/public/components/logs/reason.test.js @@ -15,6 +15,15 @@ jest.mock('../../legacy_shims', () => ({ docLinks: { ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', DOC_LINK_VERSION: 'current', + links: { + filebeat: { + elasticsearchModule: 'jest-metadata-mock-url', + installation: 'jest-metadata-mock-url', + }, + monitoring: { + troubleshootKibana: 'jest-metadata-mock-url', + }, + }, }, }, }, diff --git a/x-pack/plugins/monitoring/public/components/metricbeat_migration/flyout/__snapshots__/flyout.test.js.snap b/x-pack/plugins/monitoring/public/components/metricbeat_migration/flyout/__snapshots__/flyout.test.js.snap index 2f29cd9122a610..1173f36d620d62 100644 --- a/x-pack/plugins/monitoring/public/components/metricbeat_migration/flyout/__snapshots__/flyout.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/metricbeat_migration/flyout/__snapshots__/flyout.test.js.snap @@ -156,7 +156,7 @@ exports[`Flyout apm part two should show instructions to migrate to metricbeat 1 "children":

    ({ shims: { kfetch: jest.fn(), docLinks: { - ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', - DOC_LINK_VERSION: 'current', + links: { + monitoring: { + monitorKibana: 'jest-metadata-mock-url', + monitorElasticsearch: 'jest-metadata-mock-url', + }, + metricbeat: { + install: 'jest-metadata-mock-url', + configure: 'jest-metadata-mock-url', + }, + }, }, }, }, diff --git a/x-pack/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/enable_metricbeat_instructions.js b/x-pack/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/enable_metricbeat_instructions.js index 1006468d0c7360..a0b5468cb9c771 100644 --- a/x-pack/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/enable_metricbeat_instructions.js +++ b/x-pack/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/enable_metricbeat_instructions.js @@ -14,10 +14,10 @@ import { Legacy } from '../../../../legacy_shims'; import { getMigrationStatusStep, getSecurityStep } from '../common_instructions'; export function getApmInstructionsForEnablingMetricbeat(product, _meta, { esMonitoringUrl }) { - const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = Legacy.shims.docLinks; - const securitySetup = getSecurityStep( - `${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/configuring-howto-metricbeat.html` - ); + const metricbeatConfigUrl = Legacy.shims.docLinks.links.metricbeat.configure; + const metricbeatInstallUrl = Legacy.shims.docLinks.links.metricbeat.install; + const metricbeatStartUrl = Legacy.shims.docLinks.links.metricbeat.start; + const securitySetup = getSecurityStep(metricbeatConfigUrl); const installMetricbeatStep = { title: i18n.translate( @@ -29,10 +29,7 @@ export function getApmInstructionsForEnablingMetricbeat(product, _meta, { esMoni children: (

    - +

    - +

    - +

    - +

    - +

    - +

    - +

    - +

    - +

    - + }); const showIfLegacyOnlyIndices = () => { - const { ELASTIC_WEBSITE_URL } = Legacy.shims.docLinks; + const blogUrl = Legacy.shims.docLinks.links.monitoring.metricbeatBlog; const toast = Legacy.shims.toastNotifications.addWarning({ title: toMountPoint( { - + {learnMoreLabel()}

    @@ -69,7 +65,7 @@ const showIfLegacyOnlyIndices = () => { }; const showIfLegacyAndMetricbeatIndices = () => { - const { ELASTIC_WEBSITE_URL } = Legacy.shims.docLinks; + const blogUrl = Legacy.shims.docLinks.links.monitoring.metricbeatBlog; const toast = Legacy.shims.toastNotifications.addWarning({ title: toMountPoint( { - + {learnMoreLabel()}
    diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.js b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.js index 7c01eea57e7230..325215d08af5f0 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.js @@ -528,7 +528,7 @@ export class RemoteClusterForm extends Component { title={ } > diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/processed_search_response.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/processed_search_response.ts index 2d8e812408efe2..c2a9bfa6506e59 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/processed_search_response.ts +++ b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/processed_search_response.ts @@ -6,6 +6,7 @@ */ import { produce } from 'immer'; +import { Index } from '../../../../types'; const shard1 = { id: ['L22w_FX2SbqlQYOP5QrYDg', '.kibana_1', '0'], @@ -336,11 +337,14 @@ const search1Child = { (searchRoot.treeRoot as any) = search1; (shard1.searches[0] as any) = searchRoot; -export const processedResponseWithFirstShard = produce(null, () => [ - { - shards: [shard1], - time: 0.058419, - name: '.kibana_1', - visible: false, - }, -]); +export const processedResponseWithFirstShard = produce( + [ + { + shards: [shard1], + time: 0.058419, + name: '.kibana_1', + visible: false, + }, + ], + () => undefined +); diff --git a/x-pack/plugins/security/server/authorization/__snapshots__/validate_es_response.test.ts.snap b/x-pack/plugins/security/server/authorization/__snapshots__/validate_es_response.test.ts.snap index 226002545a378f..76d284a21984e9 100644 --- a/x-pack/plugins/security/server/authorization/__snapshots__/validate_es_response.test.ts.snap +++ b/x-pack/plugins/security/server/authorization/__snapshots__/validate_es_response.test.ts.snap @@ -1,21 +1,21 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`validateEsPrivilegeResponse fails validation when an action is malformed in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [child \\"action3\\" fails because [\\"action3\\" must be a boolean]]]]"`; +exports[`validateEsPrivilegeResponse fails validation when an action is malformed in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: [action3]: expected value of type [boolean] but got [string]"`; -exports[`validateEsPrivilegeResponse fails validation when an action is missing in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [child \\"action2\\" fails because [\\"action2\\" is required]]]]"`; +exports[`validateEsPrivilegeResponse fails validation when an action is missing in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: [action2]: expected value of type [boolean] but got [undefined]"`; -exports[`validateEsPrivilegeResponse fails validation when an expected resource property is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"bar-resource\\" fails because [\\"bar-resource\\" is required]]]"`; +exports[`validateEsPrivilegeResponse fails validation when an expected resource property is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: Payload did not match expected resources"`; -exports[`validateEsPrivilegeResponse fails validation when an extra action is present in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [\\"action4\\" is not allowed]]]"`; +exports[`validateEsPrivilegeResponse fails validation when an extra action is present in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: [action4]: definition for this key is missing"`; -exports[`validateEsPrivilegeResponse fails validation when an extra application is present in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [\\"otherApplication\\" is not allowed]"`; +exports[`validateEsPrivilegeResponse fails validation when an extra application is present in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.otherApplication]: definition for this key is missing"`; -exports[`validateEsPrivilegeResponse fails validation when an unexpected resource property is present in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"bar-resource\\" fails because [\\"bar-resource\\" is required]]]"`; +exports[`validateEsPrivilegeResponse fails validation when an unexpected resource property is present in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: Payload did not match expected resources"`; -exports[`validateEsPrivilegeResponse fails validation when the "application" property is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [\\"application\\" is required]"`; +exports[`validateEsPrivilegeResponse fails validation when the "application" property is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: Payload did not match expected resources"`; -exports[`validateEsPrivilegeResponse fails validation when the requested application is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [\\"foo-application\\" is required]]"`; +exports[`validateEsPrivilegeResponse fails validation when the requested application is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: Payload did not match expected resources"`; -exports[`validateEsPrivilegeResponse fails validation when the resource propertry is malformed in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [\\"foo-resource\\" must be an object]]]"`; +exports[`validateEsPrivilegeResponse fails validation when the resource propertry is malformed in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: could not parse object value from json input"`; -exports[`validateEsPrivilegeResponse fails validation when there are no resource properties in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [\\"foo-resource\\" is required]]]"`; +exports[`validateEsPrivilegeResponse fails validation when there are no resource properties in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: Payload did not match expected resources"`; diff --git a/x-pack/plugins/security/server/authorization/check_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_privileges.test.ts index cfa6153c1b164a..93f5efed58fb8d 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.test.ts @@ -316,7 +316,7 @@ describe('#atSpace', () => { }, }); expect(result).toMatchInlineSnapshot( - `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because ["saved_object:bar-type/get" is not allowed]]]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: [saved_object:bar-type/get]: definition for this key is missing]` ); }); @@ -338,7 +338,7 @@ describe('#atSpace', () => { }, }); expect(result).toMatchInlineSnapshot( - `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because [child "saved_object:foo-type/get" fails because ["saved_object:foo-type/get" is required]]]]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: [saved_object:foo-type/get]: expected value of type [boolean] but got [undefined]]` ); }); }); @@ -1092,7 +1092,7 @@ describe('#atSpaces', () => { }, }); expect(result).toMatchInlineSnapshot( - `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because [child "mock-action:version" fails because ["mock-action:version" is required]]]]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: [mock-action:version]: expected value of type [boolean] but got [undefined]]` ); }); @@ -1379,7 +1379,7 @@ describe('#atSpaces', () => { }, }); expect(result).toMatchInlineSnapshot( - `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: Payload did not match expected resources]` ); }); @@ -1407,7 +1407,7 @@ describe('#atSpaces', () => { }, }); expect(result).toMatchInlineSnapshot( - `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: Payload did not match expected resources]` ); }); @@ -1440,7 +1440,7 @@ describe('#atSpaces', () => { }, }); expect(result).toMatchInlineSnapshot( - `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because ["space:space_3" is not allowed]]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: Payload did not match expected resources]` ); }); @@ -1463,7 +1463,7 @@ describe('#atSpaces', () => { }, }); expect(result).toMatchInlineSnapshot( - `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: Payload did not match expected resources]` ); }); }); @@ -2266,7 +2266,7 @@ describe('#globally', () => { }, }); expect(result).toMatchInlineSnapshot( - `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because [child "mock-action:version" fails because ["mock-action:version" is required]]]]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: [mock-action:version]: expected value of type [boolean] but got [undefined]]` ); }); @@ -2384,7 +2384,7 @@ describe('#globally', () => { }, }); expect(result).toMatchInlineSnapshot( - `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because ["saved_object:bar-type/get" is not allowed]]]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: [saved_object:bar-type/get]: definition for this key is missing]` ); }); @@ -2405,7 +2405,7 @@ describe('#globally', () => { }, }); expect(result).toMatchInlineSnapshot( - `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because [child "saved_object:foo-type/get" fails because ["saved_object:foo-type/get" is required]]]]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: [saved_object:foo-type/get]: expected value of type [boolean] but got [undefined]]` ); }); }); diff --git a/x-pack/plugins/security/server/authorization/validate_es_response.ts b/x-pack/plugins/security/server/authorization/validate_es_response.ts index dbc5bdee8f250b..19afaaf035c15e 100644 --- a/x-pack/plugins/security/server/authorization/validate_es_response.ts +++ b/x-pack/plugins/security/server/authorization/validate_es_response.ts @@ -5,7 +5,7 @@ * 2.0. */ -import Joi from 'joi'; +import { schema } from '@kbn/config-schema'; import { HasPrivilegesResponse } from './types'; export function validateEsPrivilegeResponse( @@ -14,48 +14,57 @@ export function validateEsPrivilegeResponse( actions: string[], resources: string[] ) { - const schema = buildValidationSchema(application, actions, resources); - const { error, value } = schema.validate(response); - - if (error) { - throw new Error( - `Invalid response received from Elasticsearch has_privilege endpoint. ${error}` - ); + const validationSchema = buildValidationSchema(application, actions, resources); + try { + validationSchema.validate(response); + } catch (e) { + throw new Error(`Invalid response received from Elasticsearch has_privilege endpoint. ${e}`); } - return value; + return response; } function buildActionsValidationSchema(actions: string[]) { - return Joi.object({ + return schema.object({ ...actions.reduce>((acc, action) => { return { ...acc, - [action]: Joi.bool().required(), + [action]: schema.boolean(), }; }, {}), - }).required(); + }); } function buildValidationSchema(application: string, actions: string[], resources: string[]) { const actionValidationSchema = buildActionsValidationSchema(actions); - const resourceValidationSchema = Joi.object({ - ...resources.reduce((acc, resource) => { - return { - ...acc, - [resource]: actionValidationSchema, - }; - }, {}), - }).required(); + const resourceValidationSchema = schema.object( + {}, + { + unknowns: 'allow', + validate: (value) => { + const actualResources = Object.keys(value).sort(); + if ( + resources.length !== actualResources.length || + !resources.sort().every((x, i) => x === actualResources[i]) + ) { + throw new Error('Payload did not match expected resources'); + } + + Object.values(value).forEach((actionResult) => { + actionValidationSchema.validate(actionResult); + }); + }, + } + ); - return Joi.object({ - username: Joi.string().required(), - has_all_requested: Joi.bool(), - cluster: Joi.object(), - application: Joi.object({ + return schema.object({ + username: schema.string(), + has_all_requested: schema.boolean(), + cluster: schema.object({}, { unknowns: 'allow' }), + application: schema.object({ [application]: resourceValidationSchema, - }).required(), - index: Joi.object(), - }).required(); + }), + index: schema.object({}, { unknowns: 'allow' }), + }); } diff --git a/x-pack/plugins/security_solution/common/ecs/process/index.ts b/x-pack/plugins/security_solution/common/ecs/process/index.ts index cc4a961a5b5284..3a8ccc309aecb9 100644 --- a/x-pack/plugins/security_solution/common/ecs/process/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/process/index.ts @@ -7,7 +7,9 @@ export interface ProcessEcs { entity_id?: string[]; + exit_code?: number[]; hash?: ProcessHashData; + parent?: ProcessParentData; pid?: number[]; name?: string[]; ppid?: number[]; @@ -24,6 +26,10 @@ export interface ProcessHashData { sha256?: string[]; } +export interface ProcessParentData { + name?: string[]; +} + export interface Thread { id?: number[]; start?: string[]; diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index d4bae9d88d2624..ba64814cd1daf1 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -1277,6 +1277,7 @@ export class EndpointDocGenerator { status: agentPolicyStatuses.Active, description: 'Some description', namespace: 'default', + is_managed: false, monitoring_enabled: ['logs', 'metrics'], revision: 2, updated_at: '2020-07-22T16:36:49.196Z', diff --git a/x-pack/plugins/security_solution/public/common/lib/lib.ts b/x-pack/plugins/security_solution/public/common/lib/lib.ts index e953fb1a341a39..7919ef78fff0b9 100644 --- a/x-pack/plugins/security_solution/public/common/lib/lib.ts +++ b/x-pack/plugins/security_solution/public/common/lib/lib.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { IScope } from 'angular'; import { NormalizedCacheObject } from 'apollo-cache-inmemory'; import ApolloClient from 'apollo-client'; @@ -38,10 +37,3 @@ export interface AppKibanaUIConfig { // eslint-disable-next-line @typescript-eslint/no-explicit-any set(key: string, value: any): Promise; } - -export interface AppKibanaAdapterServiceRefs { - config: AppKibanaUIConfig; - rootScope: IScope; -} - -export type AppBufferedKibanaServiceCall = (serviceRefs: ServiceRefs) => void; diff --git a/x-pack/plugins/security_solution/public/common/mock/mock_endgame_ecs_data.ts b/x-pack/plugins/security_solution/public/common/mock/mock_endgame_ecs_data.ts index 98bedbb08028ba..1082b5f9474e53 100644 --- a/x-pack/plugins/security_solution/public/common/mock/mock_endgame_ecs_data.ts +++ b/x-pack/plugins/security_solution/public/common/mock/mock_endgame_ecs_data.ts @@ -58,6 +58,121 @@ export const mockEndgameDnsRequest: Ecs = { }, }; +export const mockEndpointNetworkLookupRequestedEvent: Ecs = { + host: { + os: { + full: ['Windows Server 2019 Datacenter 1809 (10.0.17763.1697)'], + name: ['Windows'], + version: ['1809 (10.0.17763.1697)'], + family: ['windows'], + kernel: ['1809 (10.0.17763.1697)'], + platform: ['windows'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + name: ['win2019-endpoint'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['d8ad572e-d224-4044-a57d-f5a84c0dfe5d'], + }, + event: { + category: ['network'], + kind: ['event'], + created: ['2021-01-25T16:44:40.788Z'], + module: ['endpoint'], + action: ['lookup_requested'], + type: ['protocol,info'], + id: ['LzzWB9jjGmCwGMvk++++6FZj'], + dataset: ['endpoint.events.network'], + }, + process: { + name: ['google_osconfig_agent.exe'], + pid: [3272], + entity_id: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTMyNzItMTMyNTUwNzg4NjguNjUzODkxNTAw', + ], + executable: ['C:\\Program Files\\Google\\OSConfig\\google_osconfig_agent.exe'], + }, + dns: { + question: { + name: ['logging.googleapis.com'], + type: ['A'], + }, + }, + agent: { + type: ['endpoint'], + }, + user: { + name: ['SYSTEM'], + domain: ['NT AUTHORITY'], + }, + network: { + protocol: ['dns'], + }, + message: [ + 'DNS query is completed for the name logging.googleapis.com, type 1, query options 1073766400 with status 87 Results', + ], + timestamp: '2021-01-25T16:44:40.788Z', + _id: 'sUNzOncBPmkOXwyN9VbT', +}; + +export const mockEndpointNetworkLookupResultEvent: Ecs = { + host: { + os: { + full: ['Windows Server 2019 Datacenter 1809 (10.0.17763.1697)'], + name: ['Windows'], + version: ['1809 (10.0.17763.1697)'], + family: ['windows'], + kernel: ['1809 (10.0.17763.1697)'], + platform: ['windows'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + name: ['win2019-endpoint'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['d8ad572e-d224-4044-a57d-f5a84c0dfe5d'], + }, + event: { + category: ['network'], + kind: ['event'], + outcome: ['success'], + created: ['2021-01-25T16:44:40.789Z'], + module: ['endpoint'], + action: ['lookup_result'], + type: ['protocol,info'], + id: ['LzzWB9jjGmCwGMvk++++6FZq'], + dataset: ['endpoint.events.network'], + }, + process: { + name: ['google_osconfig_agent.exe'], + pid: [3272], + entity_id: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTMyNzItMTMyNTUwNzg4NjguNjUzODkxNTAw', + ], + executable: ['C:\\Program Files\\Google\\OSConfig\\google_osconfig_agent.exe'], + }, + agent: { + type: ['endpoint'], + }, + dns: { + question: { + name: ['logging.googleapis.com'], + type: ['AAAA'], + }, + }, + user: { + name: ['SYSTEM'], + domain: ['NT AUTHORITY'], + }, + network: { + protocol: ['dns'], + }, + message: [ + 'DNS query is completed for the name logging.googleapis.com, type 28, query options 2251800887582720 with status 0 Results', + ], + timestamp: '2021-01-25T16:44:40.789Z', + _id: 'skNzOncBPmkOXwyN9VbT', +}; + export const mockEndgameFileCreateEvent: Ecs = { _id: '98jPcG0BOpWiDweSouzg', user: { @@ -91,6 +206,59 @@ export const mockEndgameFileCreateEvent: Ecs = { }, }; +export const mockEndpointFileCreationEvent: Ecs = { + file: { + path: ['C:\\Windows\\TEMP\\E38FD162-B6E6-4799-B52D-F590BACBAE94\\WimProvider.dll'], + extension: ['dll'], + name: ['WimProvider.dll'], + }, + host: { + os: { + full: ['Windows Server 2019 Datacenter 1809 (10.0.17763.1697)'], + name: ['Windows'], + version: ['1809 (10.0.17763.1697)'], + family: ['windows'], + kernel: ['1809 (10.0.17763.1697)'], + platform: ['windows'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + name: ['win2019-endpoint'], + architecture: ['x86_64'], + ip: ['10.9.8.7'], + id: ['d8ad572e-d224-4044-a57d-f5a84c0dfe5d'], + }, + event: { + category: ['file'], + kind: ['event'], + created: ['2021-01-25T16:21:56.832Z'], + module: ['endpoint'], + action: ['creation'], + type: ['creation'], + id: ['LzzWB9jjGmCwGMvk++++6FEM'], + dataset: ['endpoint.events.file'], + }, + process: { + name: ['MsMpEng.exe'], + pid: [2424], + entity_id: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTI0MjQtMTMyNTUwNzg2OTAuNDQ1MzY0NzAw', + ], + executable: [ + 'C:\\ProgramData\\Microsoft\\Windows Defender\\Platform\\4.18.2011.6-0\\MsMpEng.exe', + ], + }, + agent: { + type: ['endpoint'], + }, + user: { + name: ['SYSTEM'], + domain: ['NT AUTHORITY'], + }, + message: ['Endpoint file event'], + timestamp: '2021-01-25T16:21:56.832Z', + _id: 'eSdbOncBLJMagDUQ3YFs', +}; + export const mockEndgameFileDeleteEvent: Ecs = { _id: 'OMjPcG0BOpWiDweSeuW9', user: { @@ -123,6 +291,58 @@ export const mockEndgameFileDeleteEvent: Ecs = { }, }; +export const mockEndpointFileDeletionEvent: Ecs = { + file: { + path: ['C:\\Windows\\SoftwareDistribution\\Download\\Install\\AM_Delta_Patch_1.329.2793.0.exe'], + extension: ['exe'], + name: ['AM_Delta_Patch_1.329.2793.0.exe'], + }, + host: { + os: { + full: ['Windows Server 2019 Datacenter 1809 (10.0.17763.1697)'], + name: ['Windows'], + version: ['1809 (10.0.17763.1697)'], + family: ['windows'], + kernel: ['1809 (10.0.17763.1697)'], + platform: ['windows'], + }, + mac: ['11:22:33:44:55:66'], + name: ['windows-endpoint-1'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['ce6fa3c3-fda1-4984-9bce-f6d602a5bd1a'], + }, + event: { + category: ['file'], + kind: ['event'], + created: ['2021-01-25T22:50:36.783Z'], + module: ['endpoint'], + action: ['deletion'], + type: ['deletion'], + id: ['Lzty2lsJxA05IUWg++++CBsc'], + dataset: ['endpoint.events.file'], + }, + process: { + name: ['svchost.exe'], + pid: [1728], + entity_id: [ + 'YjUwNDNiMTMtYTdjNi0xZGFlLTEyZWQtODQ1ZDlhNTRhZmQyLTE3MjgtMTMyNTQ5ODc2MjYuNjg3OTg0MDAw', + ], + executable: ['C:\\Windows\\System32\\svchost.exe'], + }, + user: { + id: ['S-1-5-18'], + name: ['SYSTEM'], + domain: ['NT AUTHORITY'], + }, + agent: { + type: ['endpoint'], + }, + message: ['Endpoint file event'], + timestamp: '2021-01-25T22:50:36.783Z', + _id: 'mnXHO3cBPmkOXwyNlyv_', +}; + export const mockEndgameIpv4ConnectionAcceptEvent: Ecs = { _id: 'LsjPcG0BOpWiDweSCNfu', user: { @@ -213,6 +433,74 @@ export const mockEndgameIpv6ConnectionAcceptEvent: Ecs = { }, }; +export const mockEndpointNetworkConnectionAcceptedEvent: Ecs = { + host: { + os: { + full: ['Windows Server 2019 Datacenter 1809 (10.0.17763.1697)'], + name: ['Windows'], + version: ['1809 (10.0.17763.1697)'], + family: ['windows'], + kernel: ['1809 (10.0.17763.1697)'], + platform: ['windows'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + name: ['windows-endpoint-1'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['ce6fa3c3-fda1-4984-9bce-f6d602a5bd1a'], + }, + event: { + category: ['network'], + kind: ['event'], + outcome: ['success'], + created: ['2021-01-25T16:44:45.048Z'], + module: ['endpoint'], + action: ['connection_accepted'], + type: ['start'], + id: ['Lzty2lsJxA05IUWg++++C1CY'], + dataset: ['endpoint.events.network'], + }, + process: { + name: ['svchost.exe'], + pid: [328], + entity_id: [ + 'YjUwNDNiMTMtYTdjNi0xZGFlLTEyZWQtODQ1ZDlhNTRhZmQyLTMyOC0xMzI1NDk4NzUwNS45OTYxMjUzMDA=', + ], + executable: ['C:\\Windows\\System32\\svchost.exe'], + }, + source: { + geo: { + region_name: ['North Carolina'], + region_iso_code: ['US-NC'], + city_name: ['Concord'], + country_iso_code: ['US'], + continent_name: ['North America'], + country_name: ['United States'], + }, + ip: ['10.1.2.3'], + port: [64557], + }, + destination: { + port: [3389], + ip: ['10.50.60.70'], + }, + user: { + id: ['S-1-5-20'], + name: ['NETWORK SERVICE'], + domain: ['NT AUTHORITY'], + }, + agent: { + type: ['endpoint'], + }, + network: { + direction: ['incoming'], + transport: ['tcp'], + }, + message: ['Endpoint network event'], + timestamp: '2021-01-25T16:44:45.048Z', + _id: 'tUN0OncBPmkOXwyNOGPV', +}; + export const mockEndgameIpv4DisconnectReceivedEvent: Ecs = { _id: 'hMjPcG0BOpWiDweSoOin', user: { @@ -309,6 +597,75 @@ export const mockEndgameIpv6DisconnectReceivedEvent: Ecs = { }, }; +export const mockEndpointDisconnectReceivedEvent: Ecs = { + host: { + os: { + full: ['Windows Server 2019 Datacenter 1809 (10.0.17763.1697)'], + name: ['Windows'], + version: ['1809 (10.0.17763.1697)'], + family: ['windows'], + kernel: ['1809 (10.0.17763.1697)'], + platform: ['windows'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + name: ['windows-endpoint-1'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['ce6fa3c3-fda1-4984-9bce-f6d602a5bd1a'], + }, + event: { + category: ['network'], + kind: ['event'], + created: ['2021-01-25T16:44:47.004Z'], + module: ['endpoint'], + action: ['disconnect_received'], + type: ['end'], + id: ['Lzty2lsJxA05IUWg++++C1Ch'], + dataset: ['endpoint.events.network'], + }, + process: { + name: ['svchost.exe'], + pid: [328], + entity_id: [ + 'YjUwNDNiMTMtYTdjNi0xZGFlLTEyZWQtODQ1ZDlhNTRhZmQyLTMyOC0xMzI1NDk4NzUwNS45OTYxMjUzMDA=', + ], + executable: ['C:\\Windows\\System32\\svchost.exe'], + }, + source: { + geo: { + region_name: ['North Carolina'], + region_iso_code: ['US-NC'], + city_name: ['Concord'], + country_iso_code: ['US'], + continent_name: ['North America'], + country_name: ['United States'], + }, + ip: ['10.20.30.40'], + port: [64557], + bytes: [1192], + }, + destination: { + bytes: [1615], + port: [3389], + ip: ['10.11.12.13'], + }, + user: { + id: ['S-1-5-20'], + name: ['NETWORK SERVICE'], + domain: ['NT AUTHORITY'], + }, + agent: { + type: ['endpoint'], + }, + network: { + direction: ['incoming'], + transport: ['tcp'], + }, + message: ['Endpoint network event'], + timestamp: '2021-01-25T16:44:47.004Z', + _id: 'uUN0OncBPmkOXwyNOGPV', +}; + export const mockEndgameUserLogon: Ecs = { _id: 'QsjPcG0BOpWiDweSeuRE', user: { @@ -357,6 +714,92 @@ export const mockEndgameUserLogon: Ecs = { }, }; +export const mockEndpointSecurityLogOnSuccessEvent: Ecs = { + host: { + os: { + full: ['Windows Server 2019 Datacenter 1809 (10.0.17763.1697)'], + name: ['Windows'], + version: ['1809 (10.0.17763.1697)'], + family: ['windows'], + kernel: ['1809 (10.0.17763.1697)'], + platform: ['windows'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + name: ['win2019-endpoint'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['d8ad572e-d224-4044-a57d-f5a84c0dfe5d'], + }, + event: { + category: ['authentication', 'session'], + kind: ['event'], + outcome: ['success'], + created: ['2021-01-25T16:24:51.761Z'], + module: ['endpoint'], + action: ['log_on'], + type: ['start'], + id: ['LzzWB9jjGmCwGMvk++++6FKC'], + dataset: ['endpoint.events.security'], + }, + process: { + name: ['C:\\Program Files\\OpenSSH-Win64\\sshd.exe'], + entity_id: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTQzNDQtMTMyNTYwNjU0ODYuMzIwNDI3MDAw', + ], + executable: ['C:\\Program Files\\OpenSSH-Win64\\sshd.exe'], + pid: [90210], + }, + agent: { + type: ['endpoint'], + }, + user: { + name: ['SYSTEM'], + domain: ['NT AUTHORITY'], + }, + message: ['Endpoint security event'], + timestamp: '2021-01-25T16:24:51.761Z', + _id: 'eSlgOncBLJMagDUQ-yBL', +}; + +export const mockEndpointSecurityLogOnFailureEvent: Ecs = { + host: { + os: { + full: ['Windows Server 2019 Datacenter 1809 (10.0.17763.1637)'], + name: ['Windows'], + version: ['1809 (10.0.17763.1637)'], + kernel: ['1809 (10.0.17763.1637)'], + platform: ['windows'], + family: ['windows'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + ip: ['10.1.2.3'], + name: ['win2019-endpoint'], + id: ['d8ad572e-d224-4044-a57d-f5a84c0dfe5d'], + architecture: ['x86_64'], + }, + event: { + category: ['authentication', 'session'], + module: ['endpoint'], + kind: ['event'], + outcome: ['failure'], + action: ['log_on'], + created: ['2020-12-28T04:05:01.409Z'], + type: ['start'], + id: ['Ly1AjdVRChqy2iq3++++3jlX'], + dataset: ['endpoint.events.security'], + }, + process: { + name: ['C:\\Program Files\\OpenSSH-Win64\\sshd.exe'], + pid: [90210], + }, + agent: { + type: ['endpoint'], + }, + message: ['Endpoint security event'], + timestamp: '2020-12-28T04:05:01.409Z', + _id: 's8GIp3YBN9Y7_e914Upz', +}; + export const mockEndgameAdminLogon: Ecs = { _id: 'psjPcG0BOpWiDweSoelR', user: { @@ -488,6 +931,49 @@ export const mockEndgameUserLogoff: Ecs = { }, }; +export const mockEndpointSecurityLogOffEvent: Ecs = { + host: { + os: { + full: ['Windows Server 2019 Datacenter 1809 (10.0.17763.1697)'], + name: ['Windows'], + version: ['1809 (10.0.17763.1697)'], + family: ['windows'], + kernel: ['1809 (10.0.17763.1697)'], + platform: ['windows'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + name: ['win2019-endpoint'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['d8ad572e-d224-4044-a57d-f5a84c0dfe5d'], + }, + event: { + category: ['authentication,session'], + kind: ['event'], + outcome: ['success'], + created: ['2021-01-26T23:27:27.610Z'], + module: ['endpoint'], + action: ['log_off'], + type: ['end'], + id: ['LzzWB9jjGmCwGMvk++++6l0y'], + dataset: ['endpoint.events.security'], + }, + process: { + entity_id: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTU4MC0xMzI1NTA3ODY2Ny45MTg5Njc1MDA=', + ], + executable: ['C:\\Windows\\System32\\lsass.exe'], + pid: [90210], + }, + user: { + name: ['SYSTEM'], + domain: ['NT AUTHORITY'], + }, + message: ['Endpoint security event'], + timestamp: '2021-01-26T23:27:27.610Z', + _id: 'ZesLQXcBPmkOXwyNdT1a', +}; + export const mockEndgameCreationEvent: Ecs = { _id: 'BcjPcG0BOpWiDweSou3g', user: { @@ -537,6 +1023,58 @@ export const mockEndgameCreationEvent: Ecs = { }, }; +export const mockEndpointProcessStartEvent: Ecs = { + process: { + hash: { + md5: ['1b0e9b5fcb62de0787235ecca560b610'], + sha256: ['697334c236cce7d4c9e223146ee683a1219adced9729d4ae771fd6a1502a6b63'], + sha1: ['e19da2c35ba1c38adf12d1a472c1fcf1f1a811a7'], + }, + name: ['conhost.exe'], + pid: [3636], + entity_id: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTM2MzYtMTMyNTYwODU1OTguMTA3NTA3MDAw', + ], + executable: ['C:\\Windows\\System32\\conhost.exe'], + args: ['C:\\Windows\\system32\\conhost.exe,0xffffffff,-ForceV1'], + }, + host: { + os: { + full: ['Windows Server 2019 Datacenter 1809 (10.0.17763.1697)'], + name: ['Windows'], + version: ['1809 (10.0.17763.1697)'], + family: ['windows'], + kernel: ['1809 (10.0.17763.1697)'], + platform: ['windows'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + name: ['win2019-endpoint-1'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['d8ad572e-d224-4044-a57d-f5a84c0dfe5d'], + }, + event: { + category: ['process'], + kind: ['event'], + created: ['2021-01-25T21:59:58.107Z'], + module: ['endpoint'], + action: ['start'], + type: ['start'], + id: ['LzzWB9jjGmCwGMvk++++6Kw+'], + dataset: ['endpoint.events.process'], + }, + agent: { + type: ['endpoint'], + }, + user: { + name: ['SYSTEM'], + domain: ['NT AUTHORITY'], + }, + message: ['Endpoint process event'], + timestamp: '2021-01-25T21:59:58.107Z', + _id: 't5KSO3cB8l64wN2iQ8V9', +}; + export const mockEndgameTerminationEvent: Ecs = { _id: '2MjPcG0BOpWiDweSoutC', user: { @@ -578,3 +1116,59 @@ export const mockEndgameTerminationEvent: Ecs = { exit_code: [0], }, }; + +export const mockEndpointProcessEndEvent: Ecs = { + process: { + hash: { + md5: ['8a0a29438052faed8a2532da50455756'], + sha256: ['7fd065bac18c5278777ae44908101cdfed72d26fa741367f0ad4d02020787ab6'], + sha1: ['a1385ce20ad79f55df235effd9780c31442aa234'], + }, + name: ['svchost.exe'], + parent: { + name: ['services.exe'], + }, + pid: [10392], + entity_id: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTEwMzkyLTEzMjU2MjY2OTkwLjcwMzgzMDgwMA==', + ], + executable: ['C:\\Windows\\System32\\svchost.exe'], + exit_code: [-1], + args: ['C:\\Windows\\System32\\svchost.exe,-k,netsvcs,-p,-s,NetSetupSvc'], + }, + host: { + os: { + full: ['Windows Server 2019 Datacenter 1809 (10.0.17763.1697)'], + name: ['Windows'], + version: ['1809 (10.0.17763.1697)'], + family: ['windows'], + kernel: ['1809 (10.0.17763.1697)'], + platform: ['windows'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + name: ['win2019-endpoint'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['d8ad572e-d224-4044-a57d-f5a84c0dfe5d'], + }, + event: { + category: ['process'], + kind: ['event'], + created: ['2021-01-28T00:24:05.929Z'], + module: ['endpoint'], + action: ['end'], + type: ['end'], + id: ['LzzWB9jjGmCwGMvk++++77mE'], + dataset: ['endpoint.events.process'], + }, + agent: { + type: ['endpoint'], + }, + user: { + name: ['SYSTEM'], + domain: ['NT AUTHORITY'], + }, + message: ['Endpoint process event'], + timestamp: '2021-01-28T00:24:05.929Z', + _id: 'quloRncBX5UUcOOYo2ZS', +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index 154e26dd0f3802..1ae4144a268356 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -336,6 +336,11 @@ describe('Policy Details', () => { const ransomware = policyView.find('EuiPanel[data-test-subj="ransomwareProtectionsForm"]'); expect(ransomware).toHaveLength(0); }); + + it('shows the locked card in place of 1 paid feature', () => { + const lockedCard = policyView.find('EuiCard[data-test-subj="lockedPolicyCard"]'); + expect(lockedCard).toHaveLength(1); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details_form.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details_form.tsx index aa1d62c2e14302..528f3afc1e64ae 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details_form.tsx @@ -13,6 +13,7 @@ import { LinuxEvents, MacEvents, WindowsEvents } from './policy_forms/events'; import { AdvancedPolicyForms } from './policy_advanced'; import { AntivirusRegistrationForm } from './components/antivirus_registration_form'; import { Ransomware } from './policy_forms/protections/ransomware'; +import { LockedPolicyCard } from './policy_forms/locked_card'; import { useLicense } from '../../../../common/hooks/use_license'; export const PolicyDetailsForm = memo(() => { @@ -36,7 +37,7 @@ export const PolicyDetailsForm = memo(() => { - {isPlatinumPlus && } + {isPlatinumPlus ? : } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/locked_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/locked_card.tsx new file mode 100644 index 00000000000000..5c19a103076084 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/locked_card.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiCard, EuiIcon, EuiTextColor, EuiLink, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; + +const LockedPolicyDiv = styled.div` + .euiCard__betaBadgeWrapper { + .euiCard__betaBadge { + width: auto; + } + } + .lockedCardDescription { + padding: 0 ${(props) => props.theme.eui.fractions.thirds.percentage}; + } +`; + +export const LockedPolicyCard = memo(() => { + return ( + + } + title={ +

    + + + +

    + } + description={ + + +

    + + + +

    +
    + +

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

    +
    +
    + } + /> +
    + ); +}); +LockedPolicyCard.displayName = 'LockedPolicyCard'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/process_draggable.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/process_draggable.test.tsx.snap index 494a2b2b7732bf..84aea591337ee1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/process_draggable.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/process_draggable.test.tsx.snap @@ -1,20 +1,31 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ProcessDraggable rendering it renders against shallow snapshot 1`] = ` -
    - - -
    + + + + + + + + `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details.tsx index 819c77343fc14d..515db45e9fcd48 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details.tsx @@ -42,6 +42,7 @@ export const EndgameSecurityEventDetails = React.memo(({ data, contextId, const endgameTargetUserName: string | null | undefined = get('endgame.target_user_name[0]', data); const eventAction: string | null | undefined = get('event.action[0]', data); const eventCode: string | null | undefined = get('event.code[0]', data); + const eventOutcome: string | null | undefined = get('event.outcome[0]', data); const hostName: string | null | undefined = get('host.name[0]', data); const id = data._id; const processExecutable: string | null | undefined = get('process.executable[0]', data); @@ -64,6 +65,7 @@ export const EndgameSecurityEventDetails = React.memo(({ data, contextId, endgameTargetUserName={endgameTargetUserName} eventAction={eventAction} eventCode={eventCode} + eventOutcome={eventOutcome} hostName={hostName} id={id} processExecutable={processExecutable} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.test.tsx index a502180edfcf1f..5d088987898215 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.test.tsx @@ -39,6 +39,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="admin_logon" eventCode="[eventCode]" + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" @@ -69,6 +70,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="explicit_user_logon" eventCode="[eventCode]" + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" @@ -99,6 +101,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="explicit_user_logon" eventCode="[eventCode]" + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" @@ -129,6 +132,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="explicit_user_logon" eventCode="[eventCode]" + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" @@ -159,6 +163,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="explicit_user_logon" eventCode="[eventCode]" + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" @@ -189,6 +194,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="explicit_user_logon" eventCode="[eventCode]" + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" @@ -219,6 +225,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="explicit_user_logon" eventCode="[eventCode]" + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" @@ -249,6 +256,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="explicit_user_logon" eventCode="[eventCode]" + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" @@ -279,6 +287,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName={undefined} eventAction="explicit_user_logon" eventCode="[eventCode]" + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" @@ -309,6 +318,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction={undefined} eventCode="[eventCode]" + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" @@ -339,6 +349,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="explicit_user_logon" eventCode={undefined} + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" @@ -369,6 +380,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="explicit_user_logon" eventCode="[eventCode]" + eventOutcome={undefined} hostName={undefined} id="1" processExecutable="[processExecutable]" @@ -399,6 +411,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="explicit_user_logon" eventCode="[eventCode]" + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable={undefined} @@ -429,6 +442,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="explicit_user_logon" eventCode="[eventCode]" + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" @@ -459,6 +473,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="explicit_user_logon" eventCode="[eventCode]" + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" @@ -489,6 +504,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="admin_logon" eventCode="[eventCode]" + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" @@ -519,6 +535,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="admin_logon" eventCode="[eventCode]" + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" @@ -549,6 +566,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="admin_logon" eventCode="[eventCode]" + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" @@ -579,6 +597,7 @@ describe('EndgameSecurityEventDetailsLine', () => { endgameTargetUserName="[endgameTargetUserName]" eventAction="admin_logon" eventCode={undefined} + eventOutcome={undefined} hostName="[hostName]" id="1" processExecutable="[processExecutable]" diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.tsx index 9d3e74435852aa..aba6f7346271d7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.tsx @@ -35,6 +35,7 @@ interface Props { endgameTargetUserName: string | null | undefined; eventAction: string | null | undefined; eventCode: string | null | undefined; + eventOutcome: string | null | undefined; hostName: string | null | undefined; id: string; processExecutable: string | null | undefined; @@ -57,6 +58,7 @@ export const EndgameSecurityEventDetailsLine = React.memo( endgameTargetUserName, eventAction, eventCode, + eventOutcome, hostName, id, processExecutable, @@ -67,7 +69,7 @@ export const EndgameSecurityEventDetailsLine = React.memo( winlogEventId, }) => { const domain = getTargetUserAndTargetDomain(eventAction) ? endgameTargetDomainName : userDomain; - const eventDetails = getEventDetails(eventAction); + const eventDetails = getEventDetails({ eventAction, eventOutcome }); const hostNameSeparator = getHostNameSeparator(eventAction); const user = getTargetUserAndTargetDomain(eventAction) ? endgameTargetUserName : userName; const userDomainField = getUserDomainField(eventAction); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/helpers.test.tsx index a8955ccf22fec4..5efc1e0b156733 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/helpers.test.tsx @@ -182,28 +182,116 @@ describe('helpers', () => { }); describe('#getEventDetails', () => { - test('it returns successfully logged in when eventAction is undefined', () => { - expect(getEventDetails(undefined)).toEqual('successfully logged in'); + test('it returns an empty string when eventAction is "explicit_user_logon"', () => { + expect( + getEventDetails({ eventAction: 'explicit_user_logon', eventOutcome: undefined }) + ).toEqual(''); }); - test('it returns successfully logged in when eventAction is null', () => { - expect(getEventDetails(null)).toEqual('successfully logged in'); + test('it returns logged off when eventAction is "log_off" and eventOutcome is null', () => { + expect(getEventDetails({ eventAction: 'log_off', eventOutcome: null })).toEqual('logged off'); }); - test('it returns successfully logged in when eventAction is an empty string', () => { - expect(getEventDetails('')).toEqual('successfully logged in'); + test('it returns logged off when eventAction is "log_off" and eventOutcome is undefined', () => { + expect(getEventDetails({ eventAction: 'log_off', eventOutcome: undefined })).toEqual( + 'logged off' + ); }); - test('it returns successfully logged in when eventAction is a random value', () => { - expect(getEventDetails('a random value')).toEqual('successfully logged in'); + test('it returns failed to log off when eventAction is "log_off" and eventOutcome is failure', () => { + expect(getEventDetails({ eventAction: 'log_off', eventOutcome: 'failure' })).toEqual( + 'failed to log off' + ); }); - test('it returns an empty string when eventAction is "explicit_user_logon"', () => { - expect(getEventDetails('explicit_user_logon')).toEqual(''); + test('it returns failed to log off when eventAction is "log_off" and eventOutcome is fAiLuRe', () => { + expect(getEventDetails({ eventAction: 'log_off', eventOutcome: 'fAiLuRe' })).toEqual( + 'failed to log off' + ); + }); + + test('it returns logged off when eventAction is "log_off" and eventOutcome is anything_else', () => { + expect(getEventDetails({ eventAction: 'log_off', eventOutcome: 'anything_else' })).toEqual( + 'logged off' + ); + }); + + test('it returns logged off when eventAction is "user_logoff" and eventOutcome is null', () => { + expect(getEventDetails({ eventAction: 'user_logoff', eventOutcome: null })).toEqual( + 'logged off' + ); + }); + + test('it returns logged off when eventAction is "user_logoff" and eventOutcome is undefined', () => { + expect(getEventDetails({ eventAction: 'user_logoff', eventOutcome: undefined })).toEqual( + 'logged off' + ); + }); + + test('it returns failed to log off when eventAction is "user_logoff" and eventOutcome is failure', () => { + expect(getEventDetails({ eventAction: 'user_logoff', eventOutcome: 'failure' })).toEqual( + 'failed to log off' + ); + }); + + test('it returns failed to log off when eventAction is "user_logoff" and eventOutcome is fAiLuRe', () => { + expect(getEventDetails({ eventAction: 'user_logoff', eventOutcome: 'fAiLuRe' })).toEqual( + 'failed to log off' + ); + }); + + test('it returns logged off when eventAction is "user_logoff" and eventOutcome is anything_else', () => { + expect( + getEventDetails({ eventAction: 'user_logoff', eventOutcome: 'anything_else' }) + ).toEqual('logged off'); + }); + + test('it returns successfully logged in when eventAction is null and eventOutcome is undefined', () => { + expect(getEventDetails({ eventAction: null, eventOutcome: undefined })).toEqual( + 'successfully logged in' + ); + }); + + test('it returns successfully logged in when eventAction is null and eventOutcome is null', () => { + expect(getEventDetails({ eventAction: null, eventOutcome: null })).toEqual( + 'successfully logged in' + ); + }); + + test('it returns successfully logged in when eventAction is undefined and eventOutcome is null', () => { + expect(getEventDetails({ eventAction: undefined, eventOutcome: null })).toEqual( + 'successfully logged in' + ); + }); + + test('it returns successfully logged in when eventAction is undefined and eventOutcome is undefined', () => { + expect(getEventDetails({ eventAction: undefined, eventOutcome: undefined })).toEqual( + 'successfully logged in' + ); + }); + + test('it returns successfully logged in when eventAction is anything_else and eventOutcome is undefined', () => { + expect(getEventDetails({ eventAction: 'anything_else', eventOutcome: undefined })).toEqual( + 'successfully logged in' + ); + }); + + test('it returns successfully logged in when eventAction is anything_else and eventOutcome is null', () => { + expect(getEventDetails({ eventAction: 'anything_else', eventOutcome: null })).toEqual( + 'successfully logged in' + ); + }); + + test('it returns failed to log in when eventAction is anything_else and eventOutcome is failure', () => { + expect(getEventDetails({ eventAction: 'anything_else', eventOutcome: 'failure' })).toEqual( + 'failed to log in' + ); }); - test('it returns logged off when eventAction is "user_logoff"', () => { - expect(getEventDetails('user_logoff')).toEqual('logged off'); + test('it returns failed to log in when eventAction is anything_else and eventOutcome is fAiLuRe', () => { + expect(getEventDetails({ eventAction: 'anything_else', eventOutcome: 'fAiLuRe' })).toEqual( + 'failed to log in' + ); }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/helpers.ts index 86785c3986270b..87c0ed2782f9d3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/helpers.ts @@ -50,13 +50,22 @@ export const getUserDomainField = (eventAction: string | null | undefined): stri export const getUserNameField = (eventAction: string | null | undefined): string => getTargetUserAndTargetDomain(eventAction) ? 'endgame.target_user_name' : 'user.name'; -export const getEventDetails = (eventAction: string | null | undefined): string => { +export const getEventDetails = ({ + eventAction, + eventOutcome, +}: { + eventAction: string | null | undefined; + eventOutcome: string | null | undefined; +}): string => { switch (eventAction) { case 'explicit_user_logon': return ''; // no details + case 'log_off': // fall through case 'user_logoff': - return i18n.LOGGED_OFF; + return eventOutcome?.toLowerCase() === 'failure' ? i18n.FAILED_TO_LOG_OFF : i18n.LOGGED_OFF; default: - return i18n.SUCCESSFULLY_LOGGED_IN; + return eventOutcome?.toLowerCase() === 'failure' + ? i18n.FAILED_TO_LOG_IN + : i18n.SUCCESSFULLY_LOGGED_IN; } }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/translations.ts index e7dfefb2b570c2..859fc8ead332a9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/translations.ts @@ -21,6 +21,20 @@ export const AS_REQUESTED_BY_SUBJECT = i18n.translate( } ); +export const FAILED_TO_LOG_IN = i18n.translate( + 'xpack.securitySolution.timeline.body.renderers.endpoint.failedToLogInDescription', + { + defaultMessage: 'failed to log in', + } +); + +export const FAILED_TO_LOG_OFF = i18n.translate( + 'xpack.securitySolution.timeline.body.renderers.endpoint.failedToLogOffDescription', + { + defaultMessage: 'failed to log off', + } +); + export const LOGGED_OFF = i18n.translate( 'xpack.securitySolution.timeline.body.renderers.endgame.loggedOffDescription', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.test.tsx index 2d502f11959952..a6f15a9f79f4e1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.test.tsx @@ -25,22 +25,29 @@ jest.mock('@elastic/eui', () => { describe('ExitCodeDraggable', () => { const mount = useMountAppended(); - test('it renders the expected text and exit code, when both text and an endgameExitCode are provided', () => { + test('it renders the expected text and exit codes, when text, processExitCode, and an endgameExitCode are provided', () => { const wrapper = mount( - + ); - expect(wrapper.text()).toEqual('with exit code0'); + expect(wrapper.text()).toEqual('with exit code-10'); }); - test('it returns an empty string when text is provided, but endgameExitCode is undefined', () => { + test('it returns an empty string when text is provided, but processExitCode and endgameExitCode are undefined', () => { const wrapper = mount( @@ -48,13 +55,14 @@ describe('ExitCodeDraggable', () => { expect(wrapper.text()).toEqual(''); }); - test('it returns an empty string when text is provided, but endgameExitCode is null', () => { + test('it returns an empty string when text is provided, but processExitCode and endgameExitCode are null', () => { const wrapper = mount( @@ -65,36 +73,105 @@ describe('ExitCodeDraggable', () => { test('it returns an empty string when text is provided, but endgameExitCode is an empty string', () => { const wrapper = mount( - + ); expect(wrapper.text()).toEqual(''); }); - test('it renders just the exit code when text is undefined', () => { + test('it renders just the endgameExitCode code when text is undefined', () => { const wrapper = mount( - + ); expect(wrapper.text()).toEqual('1'); }); - test('it renders just the exit code when text is null', () => { + test('it renders just the processExitCode code when text is undefined', () => { const wrapper = mount( - + + + ); + expect(wrapper.text()).toEqual('-1'); + }); + + test('it renders just the endgameExitCode code when text is null', () => { + const wrapper = mount( + + ); expect(wrapper.text()).toEqual('1'); }); - test('it renders just the exit code when text is an empty string', () => { + test('it renders just the processExitCode code when text is null', () => { const wrapper = mount( - + + + ); + expect(wrapper.text()).toEqual('-1'); + }); + + test('it renders just the endgameExitCode code when text is an empty string', () => { + const wrapper = mount( + + ); expect(wrapper.text()).toEqual('1'); }); + + test('it renders just the processExitCode code when text is an empty string', () => { + const wrapper = mount( + + + + ); + expect(wrapper.text()).toEqual('-1'); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.tsx index 7d680aeb2ea761..7ac9fe290893f8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.tsx @@ -15,12 +15,13 @@ interface Props { contextId: string; endgameExitCode: string | null | undefined; eventId: string; + processExitCode: number | null | undefined; text: string | null | undefined; } export const ExitCodeDraggable = React.memo( - ({ contextId, endgameExitCode, eventId, text }) => { - if (isNillEmptyOrNotFinite(endgameExitCode)) { + ({ contextId, endgameExitCode, eventId, processExitCode, text }) => { + if (isNillEmptyOrNotFinite(processExitCode) && isNillEmptyOrNotFinite(endgameExitCode)) { return null; } @@ -32,14 +33,27 @@ export const ExitCodeDraggable = React.memo( )} - - - + {!isNillEmptyOrNotFinite(processExitCode) && ( + + + + )} + + {!isNillEmptyOrNotFinite(endgameExitCode) && ( + + + + )} ); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/helpers.tsx index 1dfff526dcce65..ea84dc19908f03 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/helpers.tsx @@ -51,14 +51,15 @@ export const isFileEvent = ({ eventCategory: string | null | undefined; eventDataset: string | null | undefined; }) => - (eventCategory != null && eventCategory.toLowerCase() === 'file') || - (eventDataset != null && eventDataset.toLowerCase() === 'file'); + eventCategory?.toLowerCase() === 'file' || + eventDataset?.toLowerCase() === 'file' || + eventDataset?.toLowerCase() === 'endpoint.events.file'; export const isProcessStoppedOrTerminationEvent = ( eventAction: string | null | undefined ): boolean => ['process_stopped', 'termination_event'].includes(`${eventAction}`.toLowerCase()); export const showVia = (eventAction: string | null | undefined): boolean => - ['file_create_event', 'created', 'file_delete_event', 'deleted'].includes( + ['file_create_event', 'created', 'creation', 'file_delete_event', 'deleted', 'deletion'].includes( `${eventAction}`.toLowerCase() ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.test.tsx index 19fd5eee0e2306..2402be88dea188 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.test.tsx @@ -25,28 +25,34 @@ jest.mock('@elastic/eui', () => { describe('ParentProcessDraggable', () => { const mount = useMountAppended(); - test('displays the text, endgameParentProcessName, and processPpid when they are all provided', () => { + test('displays the text, endgameParentProcessName, processParentName, processParentPid, and processPpid when they are all provided', () => { const wrapper = mount( ); - expect(wrapper.text()).toEqual('via parent process[endgameParentProcessName](456)'); + expect(wrapper.text()).toEqual( + 'via parent process[processParentName][endgameParentProcessName](789)(456)' + ); }); - test('displays nothing when the text is provided, but endgameParentProcessName and processPpid are both undefined', () => { + test('displays nothing when the text is provided, but endgameParentProcessName and processParentName are both undefined', () => { const wrapper = mount( @@ -55,63 +61,71 @@ describe('ParentProcessDraggable', () => { expect(wrapper.text()).toEqual(''); }); - test('displays the text and processPpid when endgameParentProcessName is undefined', () => { + test('displays the text and endgameParentProcessName when processPpid is undefined', () => { const wrapper = mount( ); - expect(wrapper.text()).toEqual('via parent process(456)'); + expect(wrapper.text()).toEqual('via parent process[endgameParentProcessName]'); }); - test('displays the processPpid when both endgameParentProcessName and text are undefined', () => { + test('displays the text and processParentName when processParentPid is undefined', () => { const wrapper = mount( ); - expect(wrapper.text()).toEqual('(456)'); + expect(wrapper.text()).toEqual('via parent process[processParentName]'); }); - test('displays the text and endgameParentProcessName when processPpid is undefined', () => { + test('displays the endgameParentProcessName when both processPpid and text are undefined', () => { const wrapper = mount( ); - expect(wrapper.text()).toEqual('via parent process[endgameParentProcessName]'); + expect(wrapper.text()).toEqual('[endgameParentProcessName]'); }); - test('displays the endgameParentProcessName when both processPpid and text are undefined', () => { + test('displays the processParentName when both processParentPid and text are undefined', () => { const wrapper = mount( ); - expect(wrapper.text()).toEqual('[endgameParentProcessName]'); + expect(wrapper.text()).toEqual('[processParentName]'); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.tsx index 816b2c8ddae785..f0a63404feeb73 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.tsx @@ -15,13 +15,26 @@ interface Props { contextId: string; endgameParentProcessName: string | null | undefined; eventId: string; + processParentPid: number | null | undefined; + processParentName: string | null | undefined; processPpid: number | undefined | null; text: string | null | undefined; } export const ParentProcessDraggable = React.memo( - ({ contextId, endgameParentProcessName, eventId, processPpid, text }) => { - if (isNillEmptyOrNotFinite(endgameParentProcessName) && isNillEmptyOrNotFinite(processPpid)) { + ({ + contextId, + endgameParentProcessName, + eventId, + processParentName, + processParentPid, + processPpid, + text, + }) => { + if ( + isNillEmptyOrNotFinite(processParentName) && + isNillEmptyOrNotFinite(endgameParentProcessName) + ) { return null; } @@ -37,6 +50,17 @@ export const ParentProcessDraggable = React.memo( )} + {!isNillEmptyOrNotFinite(processParentName) && ( + + + + )} + {!isNillEmptyOrNotFinite(endgameParentProcessName) && ( ( )} + {!isNillEmptyOrNotFinite(processParentPid) && ( + + + + )} + {!isNillEmptyOrNotFinite(processPpid) && ( ( } return ( -
    + {!isNillEmptyOrNotFinite(processName) ? ( - + + + ) : !isNillEmptyOrNotFinite(processExecutable) ? ( - + + + ) : !isNillEmptyOrNotFinite(endgameProcessName) ? ( - + + + ) : null} {!isNillEmptyOrNotFinite(processPid) ? ( - + + + ) : !isNillEmptyOrNotFinite(endgamePid) ? ( - + + + ) : null} -
    + ); } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.test.tsx index 536de70a712a80..a3932fde44c1df 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.test.tsx @@ -116,10 +116,13 @@ describe('SystemGenericFileDetails', () => { packageName="[packageName-123]" packageSummary="[packageSummary-123]" packageVersion="[packageVersion-123]" - processExecutable="[packageExecutable=123]" + processExecutable="[processExecutable=123]" + processExitCode={-1} processHashMd5="[processHashMd5-123]" processHashSha1="[processHashSha1-123]" processHashSha256="[processHashSha256-123]" + processParentName="[processParentName-123]" + processParentPid={789} processPid={123} processPpid={456} processName="[processName-123]" @@ -135,7 +138,7 @@ describe('SystemGenericFileDetails', () => {
    ); expect(wrapper.text()).toEqual( - '[username-123]\\[userDomain-123]@[hostname-123]in[working-directory-123][generic-text-123][fileName-123]in[filePath-123][processName-123](123)[arg-1][arg-2][arg-3][some-title-123]with exit code[endgameExitCode-123]via parent process[endgameParentProcessName-123](456)with result[outcome-123][sshSignature-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' + '[username-123]\\[userDomain-123]@[hostname-123]in[working-directory-123][generic-text-123][fileName-123]in[filePath-123][processName-123](123)[arg-1][arg-2][arg-3][some-title-123]with exit code-1[endgameExitCode-123]via parent process[processParentName-123][endgameParentProcessName-123](789)(456)with result[outcome-123][sshSignature-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' ); }); @@ -162,9 +165,12 @@ describe('SystemGenericFileDetails', () => { packageSummary={null} packageVersion={null} processExecutable={null} + processExitCode={null} processHashMd5={null} processHashSha1={null} processHashSha256={null} + processParentName={null} + processParentPid={null} processPid={null} processPpid={null} processName={null} @@ -207,9 +213,12 @@ describe('SystemGenericFileDetails', () => { packageSummary={null} packageVersion={null} processExecutable={null} + processExitCode={null} processHashMd5={null} processHashSha1={null} processHashSha256={null} + processParentName={null} + processParentPid={null} processPid={null} processPpid={null} processName={null} @@ -252,9 +261,12 @@ describe('SystemGenericFileDetails', () => { packageSummary={null} packageVersion={null} processExecutable={null} + processExitCode={null} processHashMd5={null} processHashSha1={null} processHashSha256={null} + processParentName={null} + processParentPid={null} processPid={null} processPpid={null} processName={null} @@ -297,9 +309,12 @@ describe('SystemGenericFileDetails', () => { packageSummary={null} packageVersion={null} processExecutable={null} + processExitCode={null} processHashMd5={null} processHashSha1={null} processHashSha256={null} + processParentName={null} + processParentPid={null} processPid={null} processPpid={null} processName={null} @@ -344,9 +359,12 @@ describe('SystemGenericFileDetails', () => { packageSummary={null} packageVersion={null} processExecutable={null} + processExitCode={null} processHashMd5={null} processHashSha1={null} processHashSha256={null} + processParentName={null} + processParentPid={null} processPid={null} processPpid={null} processName={null} @@ -391,9 +409,12 @@ describe('SystemGenericFileDetails', () => { packageSummary="[packageSummary-123]" packageVersion={null} processExecutable={null} + processExitCode={null} processHashMd5={null} processHashSha1={null} processHashSha256={null} + processParentName={null} + processParentPid={null} processPid={null} processPpid={null} processName={null} @@ -438,9 +459,12 @@ describe('SystemGenericFileDetails', () => { packageSummary="[packageSummary-123]" packageVersion="[packageVersion-123]" processExecutable={null} + processExitCode={null} processHashMd5={null} processHashSha1={null} processHashSha256={null} + processParentName={null} + processParentPid={null} processPid={null} processPpid={null} processName={null} @@ -462,7 +486,7 @@ describe('SystemGenericFileDetails', () => { ); }); - test('it can return the host, message, outcome, packageName, pacakgeSummary, packageVersion, packageExecutable', () => { + test('it can return the host, message, outcome, packageName, pacakgeSummary, packageVersion, packageExecutable, processExitCode', () => { const wrapper = mount(
    @@ -484,10 +508,13 @@ describe('SystemGenericFileDetails', () => { packageName="[packageName-123]" packageSummary="[packageSummary-123]" packageVersion="[packageVersion-123]" - processExecutable="[packageVersion-123]" + processExecutable="[processExecutable-123]" + processExitCode={-1} processHashMd5={null} processHashSha1={null} processHashSha256={null} + processParentName={null} + processParentPid={null} processPid={null} processPpid={null} processName={null} @@ -505,11 +532,11 @@ describe('SystemGenericFileDetails', () => { ); expect(wrapper.text()).toEqual( - '[hostname-123][packageVersion-123]with result[outcome-123][packageName-123][packageVersion-123][packageSummary-123][message-123]' + '[hostname-123][processExecutable-123]with exit code-1with result[outcome-123][packageName-123][packageVersion-123][packageSummary-123][message-123]' ); }); - test('it can return the host, message, outcome, packageName, pacakgeSummary, packageVersion, packageExecutable, processHashMd5', () => { + test('it can return the host, message, outcome, packageName, pacakgeSummary, packageVersion, processExecutable, processExitCode, processHashMd5', () => { const wrapper = mount(
    @@ -531,10 +558,13 @@ describe('SystemGenericFileDetails', () => { packageName="[packageName-123]" packageSummary="[packageSummary-123]" packageVersion="[packageVersion-123]" - processExecutable="[packageVersion-123]" + processExecutable="[processExecutable-123]" + processExitCode={-1} processHashMd5="[processHashMd5-123]" processHashSha1={null} processHashSha256={null} + processParentName={null} + processParentPid={null} processPid={null} processPpid={null} processName={null} @@ -552,11 +582,11 @@ describe('SystemGenericFileDetails', () => { ); expect(wrapper.text()).toEqual( - '[hostname-123][packageVersion-123]with result[outcome-123][packageName-123][packageVersion-123][packageSummary-123][processHashMd5-123][message-123]' + '[hostname-123][processExecutable-123]with exit code-1with result[outcome-123][packageName-123][packageVersion-123][packageSummary-123][processHashMd5-123][message-123]' ); }); - test('it can return the host, message, outcome, packageName, pacakgeSummary, packageVersion, packageExecutable, processHashMd5, processHashSha1', () => { + test('it can return the host, message, outcome, packageName, pacakgeSummary, packageVersion, processExecutable, processExitCode, processHashMd5, processHashSha1', () => { const wrapper = mount(
    @@ -578,10 +608,63 @@ describe('SystemGenericFileDetails', () => { packageName="[packageName-123]" packageSummary="[packageSummary-123]" packageVersion="[packageVersion-123]" - processExecutable="[packageVersion-123]" + processExecutable="[processExecutable-123]" + processExitCode={-1} processHashMd5="[processHashMd5-123]" processHashSha1="[processHashSha1-123]" processHashSha256={null} + processParentName={null} + processParentPid={null} + processPid={null} + processPpid={null} + processName={null} + showMessage={true} + sshMethod={null} + sshSignature={null} + text={null} + userDomain={null} + userName={null} + workingDirectory={null} + processTitle={null} + args={null} + /> +
    +
    + ); + expect(wrapper.text()).toEqual( + '[hostname-123][processExecutable-123]with exit code-1with result[outcome-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha1-123][processHashMd5-123][message-123]' + ); + }); + + test('it can return the host, message, outcome, packageName, pacakgeSummary, packageVersion, processExecutable, processExitCode, processHashMd5, processHashSha1, processHashSha256', () => { + const wrapper = mount( + +
    + { ); expect(wrapper.text()).toEqual( - '[hostname-123][packageVersion-123]with result[outcome-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha1-123][processHashMd5-123][message-123]' + '[hostname-123][processExecutable-123]with exit code-1with result[outcome-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' ); }); - test('it can return the host, message, outcome, packageName, pacakgeSummary, packageVersion, packageExecutable, processHashMd5, processHashSha1, processHashSha256', () => { + test('it can return the host, message, outcome, packageName, pacakgeSummary, packageVersion, processExecutable, processExitCode, processHashMd5, processHashSha1, processHashSha256, processParentName', () => { const wrapper = mount(
    @@ -625,10 +708,13 @@ describe('SystemGenericFileDetails', () => { packageName="[packageName-123]" packageSummary="[packageSummary-123]" packageVersion="[packageVersion-123]" - processExecutable="[packageVersion-123]" + processExecutable="[processExecutable-123]" + processExitCode={-1} processHashMd5="[processHashMd5-123]" processHashSha1="[processHashSha1-123]" processHashSha256="[processHashSha256-123]" + processParentName="[processParentName-123]" + processParentPid={null} processPid={null} processPpid={null} processName={null} @@ -646,11 +732,11 @@ describe('SystemGenericFileDetails', () => { ); expect(wrapper.text()).toEqual( - '[hostname-123][packageVersion-123]with result[outcome-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' + '[hostname-123][processExecutable-123]with exit code-1via parent process[processParentName-123]with result[outcome-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' ); }); - test('it can return the host, message, outcome, packageName, pacakgeSummary, packageVersion, packageExecutable, processHashMd5, processHashSha1, processHashSha256, processPid', () => { + test('it can return the host, message, outcome, packageName, pacakgeSummary, packageVersion, processExecutable, processExitCode, processHashMd5, processHashSha1, processHashSha256, processParentName, processParentPid', () => { const wrapper = mount(
    @@ -673,9 +759,62 @@ describe('SystemGenericFileDetails', () => { packageSummary="[packageSummary-123]" packageVersion="[packageVersion-123]" processExecutable="[processExecutable-123]" + processExitCode={-1} processHashMd5="[processHashMd5-123]" processHashSha1="[processHashSha1-123]" processHashSha256="[processHashSha256-123]" + processParentName="[processParentName-123]" + processParentPid={789} + processPid={null} + processPpid={null} + processName={null} + showMessage={true} + sshMethod={null} + sshSignature={null} + text={null} + userDomain={null} + userName={null} + workingDirectory={null} + processTitle={null} + args={null} + /> +
    +
    + ); + expect(wrapper.text()).toEqual( + '[hostname-123][processExecutable-123]with exit code-1via parent process[processParentName-123](789)with result[outcome-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' + ); + }); + + test('it can return the host, message, outcome, packageName, pacakgeSummary, packageVersion, processExecutable, processExitCode, processHashMd5, processHashSha1, processHashSha256, processParentName, processParentPid, processPid', () => { + const wrapper = mount( + +
    + { ); expect(wrapper.text()).toEqual( - '[hostname-123][processExecutable-123](123)with result[outcome-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' + '[hostname-123][processExecutable-123](123)with exit code-1via parent process[processParentName-123](789)with result[outcome-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' ); }); - test('it can return the host, message, outcome, packageName, pacakgeSummary, packageVersion, packageExecutable, processHashMd5, processHashSha1, processHashSha256, processPid, processPpid, processName', () => { + test('it can return the host, message, outcome, packageName, pacakgeSummary, packageVersion, processExecutable, processExitCode, processHashMd5, processHashSha1, processHashSha256, processParentName, processParentPid, processPid, processPpid, processName', () => { const wrapper = mount(
    @@ -719,10 +858,13 @@ describe('SystemGenericFileDetails', () => { packageName="[packageName-123]" packageSummary="[packageSummary-123]" packageVersion="[packageVersion-123]" - processExecutable="[packageVersion-123]" + processExecutable="[processExecutable-123]" + processExitCode={-1} processHashMd5="[processHashMd5-123]" processHashSha1="[processHashSha1-123]" processHashSha256="[processHashSha256-123]" + processParentName="[processParentName-123]" + processParentPid={789} processPid={123} processPpid={456} processName="[processName-123]" @@ -740,11 +882,11 @@ describe('SystemGenericFileDetails', () => { ); expect(wrapper.text()).toEqual( - '[hostname-123][processName-123](123)via parent process(456)with result[outcome-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' + '[hostname-123][processName-123](123)with exit code-1via parent process[processParentName-123](789)(456)with result[outcome-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' ); }); - test('it can return the endgameExitCode, endgameParentProcessName, eventAction, host, message, outcome, packageName, pacakgeSummary, packageVersion, packageExecutable, processHashMd5, processHashSha1, processHashSha256, processPid, processPpid, processName, sshMethod', () => { + test('it can return the endgameExitCode, endgameParentProcessName, eventAction, host, message, outcome, packageName, pacakgeSummary, packageVersion, processExecutable, processExitCode, processHashMd5, processHashSha1, processHashSha256, processParentName, processParentPid, processPid, processPpid, processName, sshMethod', () => { const wrapper = mount(
    @@ -766,10 +908,13 @@ describe('SystemGenericFileDetails', () => { packageName="[packageName-123]" packageSummary="[packageSummary-123]" packageVersion="[packageVersion-123]" - processExecutable="[packageVersion-123]" + processExecutable="[processExecutable-123]" + processExitCode={-1} processHashMd5="[processHashMd5-123]" processHashSha1="[processHashSha1-123]" processHashSha256="[processHashSha256-123]" + processParentName="[processParentName-123]" + processParentPid={789} processPid={123} processPpid={456} processName="[processName-123]" @@ -787,11 +932,11 @@ describe('SystemGenericFileDetails', () => { ); expect(wrapper.text()).toEqual( - '[hostname-123][processName-123](123)with exit code[endgameExitCode-123]via parent process[endgameParentProcessName-123](456)with result[outcome-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' + '[hostname-123][processName-123](123)with exit code-1[endgameExitCode-123]via parent process[processParentName-123][endgameParentProcessName-123](789)(456)with result[outcome-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' ); }); - test('it can return the endgameExitCode, endgameParentProcessName, eventAction, host, message, outcome, packageName, pacakgeSummary, packageVersion, packageExecutable, processHashMd5, processHashSha1, processHashSha256, processPid, processPpid, processName, sshMethod, sshSignature', () => { + test('it can return the endgameExitCode, endgameParentProcessName, eventAction, host, message, outcome, packageName, pacakgeSummary, packageVersion, processExecutable, processExitCode, processHashMd5, processHashSha1, processHashSha256, processParentName, processParentPid, processPid, processPpid, processName, sshMethod, sshSignature', () => { const wrapper = mount(
    @@ -813,10 +958,13 @@ describe('SystemGenericFileDetails', () => { packageName="[packageName-123]" packageSummary="[packageSummary-123]" packageVersion="[packageVersion-123]" - processExecutable="[packageVersion-123]" + processExecutable="[processExecutable-123]" + processExitCode={-1} processHashMd5="[processHashMd5-123]" processHashSha1="[processHashSha1-123]" processHashSha256="[processHashSha256-123]" + processParentName="[processParentName-123]" + processParentPid={789} processPid={123} processPpid={456} processName="[processName-123]" @@ -834,11 +982,11 @@ describe('SystemGenericFileDetails', () => { ); expect(wrapper.text()).toEqual( - '[hostname-123][processName-123](123)with exit code[endgameExitCode-123]via parent process[endgameParentProcessName-123](456)with result[outcome-123][sshSignature-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' + '[hostname-123][processName-123](123)with exit code-1[endgameExitCode-123]via parent process[processParentName-123][endgameParentProcessName-123](789)(456)with result[outcome-123][sshSignature-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' ); }); - test('it can return the endgameExitCode, endgameParentProcessName, eventAction, host, message, outcome, packageName, pacakgeSummary, packageVersion, packageExecutable, processHashMd5, processHashSha1, processHashSha256, processPid, processPpid, processName, sshMethod, sshSignature, text', () => { + test('it can return the endgameExitCode, endgameParentProcessName, eventAction, host, message, outcome, packageName, pacakgeSummary, packageVersion, processExecutable, processExitCode, processHashMd5, processHashSha1, processHashSha256, processParentName, processParentPid, processPid, processPpid, processName, sshMethod, sshSignature, text', () => { const wrapper = mount(
    @@ -860,10 +1008,13 @@ describe('SystemGenericFileDetails', () => { packageName="[packageName-123]" packageSummary="[packageSummary-123]" packageVersion="[packageVersion-123]" - processExecutable="[packageVersion-123]" + processExecutable="[processExecutable-123]" + processExitCode={-1} processHashMd5="[processHashMd5-123]" processHashSha1="[processHashSha1-123]" processHashSha256="[processHashSha256-123]" + processParentName="[processParentName-123]" + processParentPid={789} processPid={123} processPpid={456} processName="[processName-123]" @@ -881,11 +1032,11 @@ describe('SystemGenericFileDetails', () => { ); expect(wrapper.text()).toEqual( - '[hostname-123][text-123][processName-123](123)with exit code[endgameExitCode-123]via parent process[endgameParentProcessName-123](456)with result[outcome-123][sshSignature-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' + '[hostname-123][text-123][processName-123](123)with exit code-1[endgameExitCode-123]via parent process[processParentName-123][endgameParentProcessName-123](789)(456)with result[outcome-123][sshSignature-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' ); }); - test('it can return the endgameExitCode, endgameParentProcessName, eventAction, host, message, outcome, packageName, pacakgeSummary, packageVersion, packageExecutable, processHashMd5, processHashSha1, processHashSha256, processPid, processPpid, processName, sshMethod, sshSignature, text, userDomain', () => { + test('it can return the endgameExitCode, endgameParentProcessName, eventAction, host, message, outcome, packageName, pacakgeSummary, packageVersion, processExecutable, processExitCode, processHashMd5, processHashSha1, processHashSha256, processParentName, processParentPid, processPid, processPpid, processName, sshMethod, sshSignature, text, userDomain', () => { const wrapper = mount(
    @@ -907,10 +1058,13 @@ describe('SystemGenericFileDetails', () => { packageName="[packageName-123]" packageSummary="[packageSummary-123]" packageVersion="[packageVersion-123]" - processExecutable="[packageVersion-123]" + processExecutable="[processExecutable-123]" + processExitCode={-1} processHashMd5="[processHashMd5-123]" processHashSha1="[processHashSha1-123]" processHashSha256="[processHashSha256-123]" + processParentName="[processParentName-123]" + processParentPid={789} processPid={123} processPpid={456} processName="[processName-123]" @@ -928,11 +1082,11 @@ describe('SystemGenericFileDetails', () => { ); expect(wrapper.text()).toEqual( - '\\[userDomain-123][hostname-123][text-123][processName-123](123)with exit code[endgameExitCode-123]via parent process[endgameParentProcessName-123](456)with result[outcome-123][sshSignature-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' + '\\[userDomain-123][hostname-123][text-123][processName-123](123)with exit code-1[endgameExitCode-123]via parent process[processParentName-123][endgameParentProcessName-123](789)(456)with result[outcome-123][sshSignature-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' ); }); - test('it can return the endgameExitCode, endgameParentProcessName, eventAction, host, message, outcome, packageName, pacakgeSummary, packageVersion, packageExecutable, processHashMd5, processHashSha1, processHashSha256, processPid, processPpid, processName, sshMethod, sshSignature, text, userDomain, username', () => { + test('it can return the endgameExitCode, endgameParentProcessName, eventAction, host, message, outcome, packageName, pacakgeSummary, packageVersion, processExecutable, processExitCode, processHashMd5, processHashSha1, processHashSha256, processParentName, processParentPid, processPid, processPpid, processName, sshMethod, sshSignature, text, userDomain, username', () => { const wrapper = mount(
    @@ -954,10 +1108,13 @@ describe('SystemGenericFileDetails', () => { packageName="[packageName-123]" packageSummary="[packageSummary-123]" packageVersion="[packageVersion-123]" - processExecutable="[packageVersion-123]" + processExecutable="[processExecutable-123]" + processExitCode={-1} processHashMd5="[processHashMd5-123]" processHashSha1="[processHashSha1-123]" processHashSha256="[processHashSha256-123]" + processParentName="[processParentName-123]" + processParentPid={789} processPid={123} processPpid={456} processName="[processName-123]" @@ -975,11 +1132,11 @@ describe('SystemGenericFileDetails', () => { ); expect(wrapper.text()).toEqual( - '[username-123]\\[userDomain-123]@[hostname-123][text-123][processName-123](123)with exit code[endgameExitCode-123]via parent process[endgameParentProcessName-123](456)with result[outcome-123][sshSignature-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' + '[username-123]\\[userDomain-123]@[hostname-123][text-123][processName-123](123)with exit code-1[endgameExitCode-123]via parent process[processParentName-123][endgameParentProcessName-123](789)(456)with result[outcome-123][sshSignature-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' ); }); - test('it can return the endgameExitCode, endgameParentProcessName, eventAction, host, message, outcome, packageName, pacakgeSummary, packageVersion, packageExecutable, processHashMd5, processHashSha1, processHashSha256, processPid, processPpid, processName, sshMethod, sshSignature, text, userDomain, username, working-directory', () => { + test('it can return the endgameExitCode, endgameParentProcessName, eventAction, host, message, outcome, packageName, pacakgeSummary, packageVersion, processExecutable, processExitCode, processHashMd5, processHashSha1, processHashSha256, processParentName, processParentPid, processPid, processPpid, processName, sshMethod, sshSignature, text, userDomain, username, working-directory', () => { const wrapper = mount(
    @@ -1001,10 +1158,13 @@ describe('SystemGenericFileDetails', () => { packageName="[packageName-123]" packageSummary="[packageSummary-123]" packageVersion="[packageVersion-123]" - processExecutable="[packageVersion-123]" + processExecutable="[processExecutable-123]" + processExitCode={-1} processHashMd5="[processHashMd5-123]" processHashSha1="[processHashSha1-123]" processHashSha256="[processHashSha256-123]" + processParentName="[processParentName-123]" + processParentPid={789} processPid={123} processPpid={456} processName="[processName-123]" @@ -1022,11 +1182,11 @@ describe('SystemGenericFileDetails', () => { ); expect(wrapper.text()).toEqual( - '[username-123]\\[userDomain-123]@[hostname-123]in[working-directory-123][text-123][processName-123](123)with exit code[endgameExitCode-123]via parent process[endgameParentProcessName-123](456)with result[outcome-123][sshSignature-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' + '[username-123]\\[userDomain-123]@[hostname-123]in[working-directory-123][text-123][processName-123](123)with exit code-1[endgameExitCode-123]via parent process[processParentName-123][endgameParentProcessName-123](789)(456)with result[outcome-123][sshSignature-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' ); }); - test('it can return the endgameExitCode, endgameParentProcessName, eventAction, host, message, outcome, packageName, pacakgeSummary, packageVersion, packageExecutable, processHashMd5, processHashSha1, processHashSha256, processPid, processPpid, processName, sshMethod, sshSignature, text, userDomain, username, working-directory, process-title', () => { + test('it can return the endgameExitCode, endgameParentProcessName, eventAction, host, message, outcome, packageName, pacakgeSummary, packageVersion, processExecutable, processExitCode, processHashMd5, processHashSha1, processHashSha256, processParentName, processParentPid, processPid, processPpid, processName, sshMethod, sshSignature, text, userDomain, username, working-directory, process-title', () => { const wrapper = mount(
    @@ -1048,10 +1208,13 @@ describe('SystemGenericFileDetails', () => { packageName="[packageName-123]" packageSummary="[packageSummary-123]" packageVersion="[packageVersion-123]" - processExecutable="[packageVersion-123]" + processExecutable="[processExecutable-123]" + processExitCode={-1} processHashMd5="[processHashMd5-123]" processHashSha1="[processHashSha1-123]" processHashSha256="[processHashSha256-123]" + processParentName="[processParentName-123]" + processParentPid={789} processPid={123} processPpid={456} processName="[processName-123]" @@ -1069,11 +1232,11 @@ describe('SystemGenericFileDetails', () => { ); expect(wrapper.text()).toEqual( - '[username-123]\\[userDomain-123]@[hostname-123]in[working-directory-123][text-123][processName-123](123)[process-title-123]with exit code[endgameExitCode-123]via parent process[endgameParentProcessName-123](456)with result[outcome-123][sshSignature-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' + '[username-123]\\[userDomain-123]@[hostname-123]in[working-directory-123][text-123][processName-123](123)[process-title-123]with exit code-1[endgameExitCode-123]via parent process[processParentName-123][endgameParentProcessName-123](789)(456)with result[outcome-123][sshSignature-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' ); }); - test('it can return the endgameExitCode, endgameParentProcessName, eventAction, host, message, outcome, packageName, pacakgeSummary, packageVersion, packageExecutable, processHashMd5, processHashSha1, processHashSha256, processPid, processPpid, processName, sshMethod, sshSignature, text, userDomain, username, working-directory, process-title, args', () => { + test('it can return the endgameExitCode, endgameParentProcessName, eventAction, host, message, outcome, packageName, pacakgeSummary, packageVersion, processExecutable, processExitCode, processHashMd5, processHashSha1, processHashSha256, processParentName, processParentPid, processPid, processPpid, processName, sshMethod, sshSignature, text, userDomain, username, working-directory, process-title, args', () => { const wrapper = mount(
    @@ -1095,10 +1258,13 @@ describe('SystemGenericFileDetails', () => { packageName="[packageName-123]" packageSummary="[packageSummary-123]" packageVersion="[packageVersion-123]" - processExecutable="[packageVersion-123]" + processExecutable="[processExecutable-123]" + processExitCode={-1} processHashMd5="[processHashMd5-123]" processHashSha1="[processHashSha1-123]" processHashSha256="[processHashSha256-123]" + processParentName="[processParentName-123]" + processParentPid={789} processPid={123} processPpid={456} processName="[processName-123]" @@ -1116,7 +1282,7 @@ describe('SystemGenericFileDetails', () => { ); expect(wrapper.text()).toEqual( - '[username-123]\\[userDomain-123]@[hostname-123]in[working-directory-123][text-123][processName-123](123)[arg-1][arg-2][arg-3][process-title-123]with exit code[endgameExitCode-123]via parent process[endgameParentProcessName-123](456)with result[outcome-123][sshSignature-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' + '[username-123]\\[userDomain-123]@[hostname-123]in[working-directory-123][text-123][processName-123](123)[arg-1][arg-2][arg-3][process-title-123]with exit code-1[endgameExitCode-123]via parent process[processParentName-123][endgameParentProcessName-123](789)(456)with result[outcome-123][sshSignature-123][sshMethod-123][packageName-123][packageVersion-123][packageSummary-123][processHashSha256-123][processHashSha1-123][processHashMd5-123][message-123]' ); }); @@ -1143,9 +1309,12 @@ describe('SystemGenericFileDetails', () => { packageSummary={undefined} packageVersion={undefined} processExecutable={undefined} + processExitCode={undefined} processHashMd5={undefined} processHashSha1={undefined} processHashSha256={undefined} + processParentName={undefined} + processParentPid={undefined} processPid={undefined} processPpid={undefined} processName={undefined} @@ -1188,9 +1357,12 @@ describe('SystemGenericFileDetails', () => { packageSummary={undefined} packageVersion={undefined} processExecutable={undefined} + processExitCode={undefined} processHashMd5={undefined} processHashSha1={undefined} processHashSha256={undefined} + processParentName={undefined} + processParentPid={undefined} processPid={undefined} processPpid={undefined} processName={undefined} @@ -1235,9 +1407,12 @@ describe('SystemGenericFileDetails', () => { packageSummary={undefined} packageVersion={undefined} processExecutable={undefined} + processExitCode={undefined} processHashMd5={undefined} processHashSha1={undefined} processHashSha256={undefined} + processParentName={undefined} + processParentPid={undefined} processPid={undefined} processPpid={undefined} processName={undefined} @@ -1284,9 +1459,12 @@ describe('SystemGenericFileDetails', () => { packageSummary={undefined} packageVersion={undefined} processExecutable={undefined} + processExitCode={undefined} processHashMd5={undefined} processHashSha1={undefined} processHashSha256={undefined} + processParentName={undefined} + processParentPid={undefined} processPid={undefined} processPpid={undefined} processName={undefined} @@ -1332,9 +1510,12 @@ describe('SystemGenericFileDetails', () => { packageSummary={undefined} packageVersion={undefined} processExecutable={undefined} + processExitCode={undefined} processHashMd5={undefined} processHashSha1={undefined} processHashSha256={undefined} + processParentName={undefined} + processParentPid={undefined} processPid={undefined} processPpid={456} processName={undefined} @@ -1382,9 +1563,12 @@ describe('SystemGenericFileDetails', () => { packageSummary={undefined} packageVersion={undefined} processExecutable={undefined} + processExitCode={undefined} processHashMd5={undefined} processHashSha1={undefined} processHashSha256={undefined} + processParentName={undefined} + processParentPid={undefined} processPid={undefined} processPpid={456} processName={undefined} @@ -1430,9 +1614,12 @@ describe('SystemGenericFileDetails', () => { packageSummary={undefined} packageVersion={undefined} processExecutable={undefined} + processExitCode={undefined} processHashMd5={undefined} processHashSha1={undefined} processHashSha256={undefined} + processParentName={undefined} + processParentPid={undefined} processPid={undefined} processPpid={456} processName={undefined} @@ -1476,9 +1663,12 @@ describe('SystemGenericFileDetails', () => { packageSummary={undefined} packageVersion={undefined} processExecutable={undefined} + processExitCode={undefined} processHashMd5={undefined} processHashSha1={undefined} processHashSha256={undefined} + processParentName={undefined} + processParentPid={undefined} processPid={undefined} processPpid={undefined} processName={undefined} @@ -1522,9 +1712,12 @@ describe('SystemGenericFileDetails', () => { packageSummary={undefined} packageVersion={undefined} processExecutable={undefined} + processExitCode={undefined} processHashMd5={undefined} processHashSha1={undefined} processHashSha256={undefined} + processParentName={undefined} + processParentPid={undefined} processPid={undefined} processPpid={undefined} processName={undefined} @@ -1568,9 +1761,12 @@ describe('SystemGenericFileDetails', () => { packageSummary={undefined} packageVersion={undefined} processExecutable={undefined} + processExitCode={undefined} processHashMd5={undefined} processHashSha1={undefined} processHashSha256={undefined} + processParentName={undefined} + processParentPid={undefined} processPid={undefined} processPpid={undefined} processName={undefined} @@ -1614,9 +1810,12 @@ describe('SystemGenericFileDetails', () => { packageSummary={undefined} packageVersion={undefined} processExecutable={undefined} + processExitCode={undefined} processHashMd5={undefined} processHashSha1={undefined} processHashSha256={undefined} + processParentName={undefined} + processParentPid={undefined} processPid={123} processPpid={undefined} processName="[processName]" diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.tsx index e0e0743fb30431..4026613ff7d0a9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.tsx @@ -48,6 +48,9 @@ interface Props { packageSummary: string | null | undefined; packageVersion: string | null | undefined; processName: string | null | undefined; + processParentName: string | null | undefined; + processParentPid: number | null | undefined; + processExitCode: number | null | undefined; processPid: number | null | undefined; processPpid: number | null | undefined; processExecutable: string | null | undefined; @@ -82,6 +85,9 @@ export const SystemGenericFileLine = React.memo( message, outcome, packageName, + processParentName, + processParentPid, + processExitCode, packageSummary, packageVersion, processExecutable, @@ -142,6 +148,7 @@ export const SystemGenericFileLine = React.memo( contextId={contextId} endgameExitCode={endgameExitCode} eventId={id} + processExitCode={processExitCode} text={i18n.WITH_EXIT_CODE} /> {!isProcessStoppedOrTerminationEvent(eventAction) && ( @@ -149,6 +156,8 @@ export const SystemGenericFileLine = React.memo( contextId={contextId} endgameParentProcessName={endgameParentProcessName} eventId={id} + processParentName={processParentName} + processParentPid={processParentPid} processPpid={processPpid} text={i18n.VIA_PARENT_PROCESS} /> @@ -239,6 +248,9 @@ export const SystemGenericFileDetails = React.memo( const packageName: string | null | undefined = get('system.audit.package.name[0]', data); const packageSummary: string | null | undefined = get('system.audit.package.summary[0]', data); const packageVersion: string | null | undefined = get('system.audit.package.version[0]', data); + const processExitCode: number | null | undefined = get('process.exit_code[0]', data); + const processParentName: string | null | undefined = get('process.parent.name[0]', data); + const processParentPid: number | null | undefined = get('process.parent.pid[0]', data); const processHashMd5: string | null | undefined = get('process.hash.md5[0]', data); const processHashSha1: string | null | undefined = get('process.hash.sha1[0]', data); const processHashSha256: string | null | undefined = get('process.hash.sha256[0]', data); @@ -271,6 +283,9 @@ export const SystemGenericFileDetails = React.memo( userDomain={userDomain} userName={userName} message={message} + processExitCode={processExitCode} + processParentName={processParentName} + processParentPid={processParentPid} processTitle={processTitle} workingDirectory={workingDirectory} args={args} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.test.tsx index 23c7770d1f25b6..4de9bcbdd9c187 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.test.tsx @@ -36,6 +36,17 @@ import { mockEndgameTerminationEvent, mockEndgameUserLogoff, mockEndgameUserLogon, + mockEndpointDisconnectReceivedEvent, + mockEndpointFileCreationEvent, + mockEndpointFileDeletionEvent, + mockEndpointNetworkConnectionAcceptedEvent, + mockEndpointNetworkLookupRequestedEvent, + mockEndpointNetworkLookupResultEvent, + mockEndpointProcessStartEvent, + mockEndpointProcessEndEvent, + mockEndpointSecurityLogOnSuccessEvent, + mockEndpointSecurityLogOnFailureEvent, + mockEndpointSecurityLogOffEvent, } from '../../../../../../common/mock/mock_endgame_ecs_data'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; import { RowRenderer } from '../row_renderer'; @@ -187,6 +198,31 @@ describe('GenericRowRenderer', () => { }); describe('#createEndgameProcessRowRenderer', () => { + test('it renders an endpoint process start event', () => { + const actionName = 'start'; + const text = i18n.PROCESS_STARTED; + + const endpointProcessStartRowRenderer = createEndgameProcessRowRenderer({ + actionName, + text, + }); + + const wrapper = mount( + + {endpointProcessStartRowRenderer.isInstance(mockEndpointProcessStartEvent) && + endpointProcessStartRowRenderer.renderRow({ + browserFields: mockBrowserFields, + data: mockEndpointProcessStartEvent, + timelineId: 'test', + })} + + ); + + expect(wrapper.text()).toEqual( + 'SYSTEM\\NT AUTHORITY@win2019-endpoint-1started processconhost.exe(3636)C:\\Windows\\system32\\conhost.exe,0xffffffff,-ForceV1697334c236cce7d4c9e223146ee683a1219adced9729d4ae771fd6a1502a6b63e19da2c35ba1c38adf12d1a472c1fcf1f1a811a71b0e9b5fcb62de0787235ecca560b610' + ); + }); + test('it renders an endgame process creation_event', () => { const actionName = 'creation_event'; const text = i18n.PROCESS_STARTED; @@ -215,6 +251,31 @@ describe('GenericRowRenderer', () => { ); }); + test('it renders an endpoint process end event', () => { + const actionName = 'end'; + const text = i18n.TERMINATED_PROCESS; + + const endpointProcessEndRowRenderer = createEndgameProcessRowRenderer({ + actionName, + text, + }); + + const wrapper = mount( + + {endpointProcessEndRowRenderer.isInstance(mockEndpointProcessEndEvent) && + endpointProcessEndRowRenderer.renderRow({ + browserFields: mockBrowserFields, + data: mockEndpointProcessEndEvent, + timelineId: 'test', + })} + + ); + + expect(wrapper.text()).toEqual( + 'SYSTEM\\NT AUTHORITY@win2019-endpointterminated processsvchost.exe(10392)C:\\Windows\\System32\\svchost.exe,-k,netsvcs,-p,-s,NetSetupSvcwith exit code-1via parent processservices.exe7fd065bac18c5278777ae44908101cdfed72d26fa741367f0ad4d02020787ab6a1385ce20ad79f55df235effd9780c31442aa2348a0a29438052faed8a2532da50455756' + ); + }); + test('it renders an endgame process termination_event', () => { const actionName = 'termination_event'; const text = i18n.TERMINATED_PROCESS; @@ -331,6 +392,31 @@ describe('GenericRowRenderer', () => { }); describe('#createFimRowRenderer', () => { + test('it renders an endpoint file creation event', () => { + const actionName = 'creation'; + const text = i18n.CREATED_FILE; + + const endpointFileCreationRowRenderer = createFimRowRenderer({ + actionName, + text, + }); + + const wrapper = mount( + + {endpointFileCreationRowRenderer.isInstance(mockEndpointFileCreationEvent) && + endpointFileCreationRowRenderer.renderRow({ + browserFields: mockBrowserFields, + data: mockEndpointFileCreationEvent, + timelineId: 'test', + })} + + ); + + expect(wrapper.text()).toEqual( + 'SYSTEM\\NT AUTHORITY@win2019-endpointcreated a fileWimProvider.dllinC:\\Windows\\TEMP\\E38FD162-B6E6-4799-B52D-F590BACBAE94\\WimProvider.dllviaMsMpEng.exe(2424)' + ); + }); + test('it renders an endgame file_create_event', () => { const actionName = 'file_create_event'; const text = i18n.CREATED_FILE; @@ -359,6 +445,31 @@ describe('GenericRowRenderer', () => { ); }); + test('it renders an endpoint file deletion event', () => { + const actionName = 'deletion'; + const text = i18n.DELETED_FILE; + + const endpointFileDeletionRowRenderer = createFimRowRenderer({ + actionName, + text, + }); + + const wrapper = mount( + + {endpointFileDeletionRowRenderer.isInstance(mockEndpointFileDeletionEvent) && + endpointFileDeletionRowRenderer.renderRow({ + browserFields: mockBrowserFields, + data: mockEndpointFileDeletionEvent, + timelineId: 'test', + })} + + ); + + expect(wrapper.text()).toEqual( + 'SYSTEM\\NT AUTHORITY@windows-endpoint-1deleted a fileAM_Delta_Patch_1.329.2793.0.exeinC:\\Windows\\SoftwareDistribution\\Download\\Install\\AM_Delta_Patch_1.329.2793.0.exeviasvchost.exe(1728)' + ); + }); + test('it renders an endgame file_delete_event', () => { const actionName = 'file_delete_event'; const text = i18n.DELETED_FILE; @@ -529,6 +640,33 @@ describe('GenericRowRenderer', () => { }); describe('#createSocketRowRenderer', () => { + test('it renders an Endpoint network connection_accepted event', () => { + const actionName = 'connection_accepted'; + const text = i18n.ACCEPTED_A_CONNECTION_VIA; + + const endpointConnectionAcceptedRowRenderer = createSocketRowRenderer({ + actionName, + text, + }); + + const wrapper = mount( + + {endpointConnectionAcceptedRowRenderer.isInstance( + mockEndpointNetworkConnectionAcceptedEvent + ) && + endpointConnectionAcceptedRowRenderer.renderRow({ + browserFields: mockBrowserFields, + data: mockEndpointNetworkConnectionAcceptedEvent, + timelineId: 'test', + })} + + ); + + expect(removeExternalLinkText(wrapper.text())).toEqual( + 'NETWORK SERVICE\\NT AUTHORITY@windows-endpoint-1accepted a connection viasvchost.exe(328)with resultsuccessEndpoint network eventincomingtcpSource10.1.2.3:64557North AmericaUnited States🇺🇸USNorth CarolinaConcordDestination10.50.60.70:3389' + ); + }); + test('it renders an Endgame ipv4_connection_accept_event', () => { const actionName = 'ipv4_connection_accept_event'; const text = i18n.ACCEPTED_A_CONNECTION_VIA; @@ -585,6 +723,31 @@ describe('GenericRowRenderer', () => { ); }); + test('it renders an endpoint network disconnect_received event', () => { + const actionName = 'disconnect_received'; + const text = i18n.DISCONNECTED_VIA; + + const endpointDisconnectReceivedRowRenderer = createSocketRowRenderer({ + actionName, + text, + }); + + const wrapper = mount( + + {endpointDisconnectReceivedRowRenderer.isInstance(mockEndpointDisconnectReceivedEvent) && + endpointDisconnectReceivedRowRenderer.renderRow({ + browserFields: mockBrowserFields, + data: mockEndpointDisconnectReceivedEvent, + timelineId: 'test', + })} + + ); + + expect(removeExternalLinkText(wrapper.text())).toEqual( + 'NETWORK SERVICE\\NT AUTHORITY@windows-endpoint-1disconnected viasvchost.exe(328)Endpoint network eventincomingtcpSource10.20.30.40:64557North AmericaUnited States🇺🇸USNorth CarolinaConcord(42.47%)1.2KB(57.53%)1.6KBDestination10.11.12.13:3389' + ); + }); + test('it renders an Endgame ipv4_disconnect_received_event', () => { const actionName = 'ipv4_disconnect_received_event'; const text = i18n.DISCONNECTED_VIA; @@ -725,6 +888,48 @@ describe('GenericRowRenderer', () => { }); describe('#createSecurityEventRowRenderer', () => { + test('it renders an endpoint security log_on event with event.outcome: success', () => { + const actionName = 'log_on'; + + const securityLogOnRowRenderer = createSecurityEventRowRenderer({ actionName }); + + const wrapper = mount( + + {securityLogOnRowRenderer.isInstance(mockEndpointSecurityLogOnSuccessEvent) && + securityLogOnRowRenderer.renderRow({ + browserFields: mockBrowserFields, + data: mockEndpointSecurityLogOnSuccessEvent, + timelineId: 'test', + })} + + ); + + expect(wrapper.text()).toEqual( + 'SYSTEM\\NT AUTHORITY@win2019-endpointsuccessfully logged inviaC:\\Program Files\\OpenSSH-Win64\\sshd.exe(90210)' + ); + }); + + test('it renders an endpoint security log_on event with event.outcome: failure', () => { + const actionName = 'log_on'; + + const securityLogOnRowRenderer = createSecurityEventRowRenderer({ actionName }); + + const wrapper = mount( + + {securityLogOnRowRenderer.isInstance(mockEndpointSecurityLogOnFailureEvent) && + securityLogOnRowRenderer.renderRow({ + browserFields: mockBrowserFields, + data: mockEndpointSecurityLogOnFailureEvent, + timelineId: 'test', + })} + + ); + + expect(wrapper.text()).toEqual( + 'win2019-endpointfailed to log inviaC:\\Program Files\\OpenSSH-Win64\\sshd.exe(90210)' + ); + }); + test('it renders an Endgame user_logon event', () => { const actionName = 'user_logon'; const userLogonEvent = { @@ -797,6 +1002,27 @@ describe('GenericRowRenderer', () => { ); }); + test('it renders an endpoint security log_off event', () => { + const actionName = 'log_off'; + + const securityLogOffRowRenderer = createSecurityEventRowRenderer({ actionName }); + + const wrapper = mount( + + {securityLogOffRowRenderer.isInstance(mockEndpointSecurityLogOffEvent) && + securityLogOffRowRenderer.renderRow({ + browserFields: mockBrowserFields, + data: mockEndpointSecurityLogOffEvent, + timelineId: 'test', + })} + + ); + + expect(wrapper.text()).toEqual( + 'SYSTEM\\NT AUTHORITY@win2019-endpointlogged offviaC:\\Windows\\System32\\lsass.exe(90210)' + ); + }); + test('it renders an Endgame user_logoff event', () => { const actionName = 'user_logoff'; const userLogoffEvent = { @@ -845,6 +1071,44 @@ describe('GenericRowRenderer', () => { }); describe('#createDnsRowRenderer', () => { + test('it renders an endpoint network lookup_requested event', () => { + const dnsRowRenderer = createDnsRowRenderer(); + + const wrapper = mount( + + {dnsRowRenderer.isInstance(mockEndpointNetworkLookupRequestedEvent) && + dnsRowRenderer.renderRow({ + browserFields: mockBrowserFields, + data: mockEndpointNetworkLookupRequestedEvent, + timelineId: 'test', + })} + + ); + + expect(wrapper.text()).toEqual( + 'SYSTEM\\NT AUTHORITY@win2019-endpointasked forlogging.googleapis.comwith question typeAviagoogle_osconfig_agent.exe(3272)dns' + ); + }); + + test('it renders an endpoint network lookup_result event', () => { + const dnsRowRenderer = createDnsRowRenderer(); + + const wrapper = mount( + + {dnsRowRenderer.isInstance(mockEndpointNetworkLookupResultEvent) && + dnsRowRenderer.renderRow({ + browserFields: mockBrowserFields, + data: mockEndpointNetworkLookupResultEvent, + timelineId: 'test', + })} + + ); + + expect(wrapper.text()).toEqual( + 'SYSTEM\\NT AUTHORITY@win2019-endpointasked forlogging.googleapis.comwith question typeAAAAviagoogle_osconfig_agent.exe(3272)dns' + ); + }); + test('it renders an Endgame DNS request_event', () => { const requestEvent = { ...mockEndgameDnsRequest, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.tsx index 431fc2592c8d1d..69a6317ebcd11a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.tsx @@ -63,11 +63,11 @@ export const createEndgameProcessRowRenderer = ({ isInstance: (ecs) => { const action: string | null | undefined = get('event.action[0]', ecs); const category: string | null | undefined = get('event.category[0]', ecs); + const dataset: string | null | undefined = get('event.dataset[0]', ecs); return ( - category != null && - category.toLowerCase() === 'process' && - action != null && - action.toLowerCase() === actionName + (category?.toLowerCase() === 'process' || + dataset?.toLowerCase() === 'endpoint.events.process') && + action?.toLowerCase() === actionName ); }, renderRow: ({ browserFields, data, timelineId }) => ( @@ -98,8 +98,7 @@ export const createFimRowRenderer = ({ const dataset: string | null | undefined = get('event.dataset[0]', ecs); return ( isFileEvent({ eventCategory: category, eventDataset: dataset }) && - action != null && - action.toLowerCase() === actionName + action?.toLowerCase() === actionName ); }, renderRow: ({ browserFields, data, timelineId }) => ( @@ -181,11 +180,11 @@ export const createSecurityEventRowRenderer = ({ isInstance: (ecs) => { const category: string | null | undefined = get('event.category[0]', ecs); const action: string | null | undefined = get('event.action[0]', ecs); + const dataset: string | null | undefined = get('event.dataset[0]', ecs); return ( - category != null && - category.toLowerCase() === 'authentication' && - action != null && - action.toLowerCase() === actionName + (category?.toLowerCase() === 'authentication' || + dataset?.toLowerCase() === 'endpoint.events.security') && + action?.toLowerCase() === actionName ); }, renderRow: ({ browserFields, data, timelineId }) => ( @@ -234,6 +233,11 @@ const endgameProcessStartedRowRenderer = createEndgameProcessRowRenderer({ text: i18n.PROCESS_STARTED, }); +const endpointProcessStartRowRenderer = createEndgameProcessRowRenderer({ + actionName: 'start', + text: i18n.PROCESS_STARTED, +}); + const systemProcessStoppedRowRenderer = createGenericFileRowRenderer({ actionName: 'process_stopped', text: i18n.PROCESS_STOPPED, @@ -244,11 +248,21 @@ const endgameProcessTerminationRowRenderer = createEndgameProcessRowRenderer({ text: i18n.TERMINATED_PROCESS, }); +const endpointProcessEndRowRenderer = createEndgameProcessRowRenderer({ + actionName: 'end', + text: i18n.TERMINATED_PROCESS, +}); + const endgameFileCreateEventRowRenderer = createFimRowRenderer({ actionName: 'file_create_event', text: i18n.CREATED_FILE, }); +const endpointFileCreationEventRowRenderer = createFimRowRenderer({ + actionName: 'creation', + text: i18n.CREATED_FILE, +}); + const fimFileCreateEventRowRenderer = createFimRowRenderer({ actionName: 'created', text: i18n.CREATED_FILE, @@ -259,6 +273,11 @@ const endgameFileDeleteEventRowRenderer = createFimRowRenderer({ text: i18n.DELETED_FILE, }); +const endpointFileDeletionEventRowRenderer = createFimRowRenderer({ + actionName: 'deletion', + text: i18n.DELETED_FILE, +}); + const fimFileDeletedEventRowRenderer = createFimRowRenderer({ actionName: 'deleted', text: i18n.DELETED_FILE, @@ -284,6 +303,11 @@ const endgameIpv4ConnectionAcceptEventRowRenderer = createSocketRowRenderer({ text: i18n.ACCEPTED_A_CONNECTION_VIA, }); +const endpointConnectionAcceptedEventRowRenderer = createSocketRowRenderer({ + actionName: 'connection_accepted', + text: i18n.ACCEPTED_A_CONNECTION_VIA, +}); + const endgameIpv6ConnectionAcceptEventRowRenderer = createSocketRowRenderer({ actionName: 'ipv6_connection_accept_event', text: i18n.ACCEPTED_A_CONNECTION_VIA, @@ -294,6 +318,11 @@ const endgameIpv4DisconnectReceivedEventRowRenderer = createSocketRowRenderer({ text: i18n.DISCONNECTED_VIA, }); +const endpointDisconnectReceivedEventRowRenderer = createSocketRowRenderer({ + actionName: 'disconnect_received', + text: i18n.DISCONNECTED_VIA, +}); + const endgameIpv6DisconnectReceivedEventRowRenderer = createSocketRowRenderer({ actionName: 'ipv6_disconnect_received_event', text: i18n.DISCONNECTED_VIA, @@ -315,6 +344,10 @@ const endgameUserLogonRowRenderer = createSecurityEventRowRenderer({ actionName: 'user_logon', }); +const endpointUserLogOnRowRenderer = createSecurityEventRowRenderer({ + actionName: 'log_on', +}); + const dnsRowRenderer = createDnsRowRenderer(); const systemExistingUserRowRenderer = createGenericSystemRowRenderer({ @@ -357,6 +390,10 @@ const systemLogoutRowRenderer = createGenericSystemRowRenderer({ text: i18n.LOGGED_OUT, }); +const endpointUserLogOffRowRenderer = createSecurityEventRowRenderer({ + actionName: 'log_off', +}); + const systemProcessErrorRowRenderer = createGenericFileRowRenderer({ actionName: 'process_error', text: i18n.PROCESS_ERROR, @@ -408,15 +445,22 @@ export const systemRowRenderers: RowRenderer[] = [ endgameAdminLogonRowRenderer, endgameExplicitUserLogonRowRenderer, endgameFileCreateEventRowRenderer, + endpointFileCreationEventRowRenderer, endgameFileDeleteEventRowRenderer, + endpointFileDeletionEventRowRenderer, endgameIpv4ConnectionAcceptEventRowRenderer, + endpointConnectionAcceptedEventRowRenderer, endgameIpv6ConnectionAcceptEventRowRenderer, endgameIpv4DisconnectReceivedEventRowRenderer, + endpointDisconnectReceivedEventRowRenderer, endgameIpv6DisconnectReceivedEventRowRenderer, endgameProcessStartedRowRenderer, + endpointProcessStartRowRenderer, endgameProcessTerminationRowRenderer, + endpointProcessEndRowRenderer, endgameUserLogoffRowRenderer, endgameUserLogonRowRenderer, + endpointUserLogOnRowRenderer, fimFileCreateEventRowRenderer, fimFileDeletedEventRowRenderer, systemAcceptedRowRenderer, @@ -431,6 +475,7 @@ export const systemRowRenderers: RowRenderer[] = [ systemInvalidRowRenderer, systemLoginRowRenderer, systemLogoutRowRenderer, + endpointUserLogOffRowRenderer, systemPackageInstalledRowRenderer, systemPackageUpdatedRowRenderer, systemPackageRemovedRowRenderer, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.test.ts index 6b58ca71f7f4e7..a2aff41b68df70 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.test.ts @@ -174,6 +174,9 @@ describe('test alerts route', () => { savedObjects: { client: mockSavedObjectClient, }, + elasticsearch: { + client: { asInternalUser: elasticsearchServiceMock.createInternalClient() }, + }, }, } as unknown) as SecuritySolutionRequestHandlerContext, mockRequest, @@ -218,6 +221,9 @@ describe('test alerts route', () => { savedObjects: { client: mockSavedObjectClient, }, + elasticsearch: { + client: { asInternalUser: elasticsearchServiceMock.createInternalClient() }, + }, }, } as unknown) as SecuritySolutionRequestHandlerContext, mockRequest, @@ -252,6 +258,9 @@ describe('test alerts route', () => { savedObjects: { client: mockSavedObjectClient, }, + elasticsearch: { + client: { asInternalUser: elasticsearchServiceMock.createInternalClient() }, + }, }, } as unknown) as SecuritySolutionRequestHandlerContext, mockRequest, @@ -280,6 +289,9 @@ describe('test alerts route', () => { savedObjects: { client: mockSavedObjectClient, }, + elasticsearch: { + client: { asInternalUser: elasticsearchServiceMock.createInternalClient() }, + }, }, } as unknown) as SecuritySolutionRequestHandlerContext, mockRequest, @@ -314,6 +326,9 @@ describe('test alerts route', () => { savedObjects: { client: mockSavedObjectClient, }, + elasticsearch: { + client: { asInternalUser: elasticsearchServiceMock.createInternalClient() }, + }, }, } as unknown) as SecuritySolutionRequestHandlerContext, mockRequest, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.ts b/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.ts index 95563c7c48ef58..3dbaa137bb9281 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.ts @@ -54,7 +54,11 @@ export function registerDownloadExceptionListRoute( // The ApiKey must be associated with an enrolled Fleet agent try { scopedSOClient = endpointContext.service.getScopedSavedObjectsClient(req); - await authenticateAgentWithAccessToken(scopedSOClient, req); + await authenticateAgentWithAccessToken( + scopedSOClient, + context.core.elasticsearch.client.asInternalUser, + req + ); } catch (err) { if ((err.isBoom ? err.output.statusCode : err.statusCode) === 401) { return res.unauthorized(); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts index 5a99728f83b577..40900fdccdb28f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts @@ -38,7 +38,7 @@ export const updateRules = async ({ const enabled = ruleUpdate.enabled ?? true; const newInternalRule: InternalRuleUpdate = { name: ruleUpdate.name, - tags: addTags(ruleUpdate.tags ?? [], existingRule.params.ruleId, false), + tags: addTags(ruleUpdate.tags ?? [], existingRule.params.ruleId, existingRule.params.immutable), params: { author: ruleUpdate.author ?? [], buildingBlockType: ruleUpdate.building_block_type, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index eb38c58d82ea15..6011c67376973c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -166,6 +166,12 @@ export const sampleDocWithSortId = ( ip: destIp ?? '127.0.0.1', }, }, + fields: { + someKey: ['someValue'], + '@timestamp': ['2020-04-20T21:27:45+0000'], + 'source.ip': ip ? (Array.isArray(ip) ? ip : [ip]) : ['127.0.0.1'], + 'destination.ip': destIp ? (Array.isArray(destIp) ? destIp : [destIp]) : ['127.0.0.1'], + }, sort: ['1234567891111'], }); @@ -185,6 +191,11 @@ export const sampleDocNoSortId = ( ip: ip ?? '127.0.0.1', }, }, + fields: { + someKey: ['someValue'], + '@timestamp': ['2020-04-20T21:27:45+0000'], + 'source.ip': [ip ?? '127.0.0.1'], + }, sort: [], }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts index dc8ed156d8dea6..8597667f646571 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts @@ -56,7 +56,12 @@ describe('create_signals', () => { ], }, }, - + fields: [ + { + field: '*', + include_unmapped: true, + }, + ], sort: [ { '@timestamp': { @@ -115,7 +120,12 @@ describe('create_signals', () => { ], }, }, - + fields: [ + { + field: '*', + include_unmapped: true, + }, + ], sort: [ { '@timestamp': { @@ -175,7 +185,12 @@ describe('create_signals', () => { ], }, }, - + fields: [ + { + field: '*', + include_unmapped: true, + }, + ], sort: [ { '@timestamp': { @@ -236,7 +251,12 @@ describe('create_signals', () => { ], }, }, - + fields: [ + { + field: '*', + include_unmapped: true, + }, + ], sort: [ { '@timestamp': { @@ -296,7 +316,12 @@ describe('create_signals', () => { ], }, }, - + fields: [ + { + field: '*', + include_unmapped: true, + }, + ], sort: [ { '@timestamp': { @@ -358,6 +383,12 @@ describe('create_signals', () => { ], }, }, + fields: [ + { + field: '*', + include_unmapped: true, + }, + ], aggregations: { tags: { terms: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts index dde284ed3beab9..f8fd4ed30d6ee5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts @@ -89,6 +89,12 @@ export const buildEventsSearchQuery = ({ ], }, }, + fields: [ + { + field: '*', + include_unmapped: true, + }, + ], ...(aggregations ? { aggregations } : {}), sort: [ { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.test.ts index f3da37c198ac2c..713178345361db 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.test.ts @@ -67,7 +67,7 @@ describe('transformThresholdResultsToEcs', () => { _id, _index: 'test', _source: { - '@timestamp': '2020-04-20T21:27:45+0000', + '@timestamp': ['2020-04-20T21:27:45+0000'], threshold_result: { count: 1, value: '127.0.0.1', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.ts index e0494c2e92b1c6..dd9e1e97a2b73a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.ts @@ -75,7 +75,7 @@ const getTransformedHits = ( } const source = { - '@timestamp': get(timestampOverride ?? '@timestamp', hit._source), + '@timestamp': get(timestampOverride ?? '@timestamp', hit.fields), threshold_result: { count: totalResults, value: ruleId, @@ -104,10 +104,10 @@ const getTransformedHits = ( } const source = { - '@timestamp': get(timestampOverride ?? '@timestamp', hit._source), + '@timestamp': get(timestampOverride ?? '@timestamp', hit.fields), threshold_result: { count: docCount, - value: get(threshold.field, hit._source), + value: key, }, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_field_and_set_tuples.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_field_and_set_tuples.test.ts index 6f744de469d5c6..aac0f47c282958 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_field_and_set_tuples.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_field_and_set_tuples.test.ts @@ -120,7 +120,7 @@ describe('filterEventsAgainstList', () => { exceptionItem, buildRuleMessage, }); - expect([...matchedSet]).toEqual([JSON.stringify('1.1.1.1')]); + expect([...matchedSet]).toEqual([JSON.stringify(['1.1.1.1'])]); }); test('it returns two matched sets as a JSON.stringify() set from the "events"', async () => { @@ -133,7 +133,7 @@ describe('filterEventsAgainstList', () => { exceptionItem, buildRuleMessage, }); - expect([...matchedSet]).toEqual([JSON.stringify('1.1.1.1'), JSON.stringify('2.2.2.2')]); + expect([...matchedSet]).toEqual([JSON.stringify(['1.1.1.1']), JSON.stringify(['2.2.2.2'])]); }); test('it returns an array as a set as a JSON.stringify() array from the "events"', async () => { @@ -282,7 +282,7 @@ describe('filterEventsAgainstList', () => { exceptionItem, buildRuleMessage, }); - expect([...matchedSet1]).toEqual([JSON.stringify('1.1.1.1'), JSON.stringify('2.2.2.2')]); - expect([...matchedSet2]).toEqual([JSON.stringify('3.3.3.3'), JSON.stringify('5.5.5.5')]); + expect([...matchedSet1]).toEqual([JSON.stringify(['1.1.1.1']), JSON.stringify(['2.2.2.2'])]); + expect([...matchedSet2]).toEqual([JSON.stringify(['3.3.3.3']), JSON.stringify(['5.5.5.5'])]); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_set_to_filter_against.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_set_to_filter_against.test.ts index aff372dc5bf3b5..aae4a7aae2b9ee 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_set_to_filter_against.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_set_to_filter_against.test.ts @@ -62,9 +62,9 @@ describe('createSetToFilterAgainst', () => { expect(listClient.searchListItemByValues).toHaveBeenCalledWith({ listId: 'list-123', type: 'ip', - value: ['1.1.1.1'], + value: [['1.1.1.1']], }); - expect([...field]).toEqual([JSON.stringify('1.1.1.1')]); + expect([...field]).toEqual([JSON.stringify(['1.1.1.1'])]); }); test('it returns 2 fields if the list returns 2 items', async () => { @@ -81,9 +81,9 @@ describe('createSetToFilterAgainst', () => { expect(listClient.searchListItemByValues).toHaveBeenCalledWith({ listId: 'list-123', type: 'ip', - value: ['1.1.1.1', '2.2.2.2'], + value: [['1.1.1.1'], ['2.2.2.2']], }); - expect([...field]).toEqual([JSON.stringify('1.1.1.1'), JSON.stringify('2.2.2.2')]); + expect([...field]).toEqual([JSON.stringify(['1.1.1.1']), JSON.stringify(['2.2.2.2'])]); }); test('it returns 0 fields if the field does not match up to a valid field within the event', async () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_set_to_filter_against.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_set_to_filter_against.ts index c9f98e1b1e4e36..d400cc901a3ed3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_set_to_filter_against.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_set_to_filter_against.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { get } from 'lodash/fp'; import { CreateSetToFilterAgainstOptions } from './types'; /** @@ -31,7 +30,7 @@ export const createSetToFilterAgainst = async ({ buildRuleMessage, }: CreateSetToFilterAgainstOptions): Promise> => { const valuesFromSearchResultField = events.reduce((acc, searchResultItem) => { - const valueField = get(field, searchResultItem._source); + const valueField = searchResultItem.fields ? searchResultItem.fields[field] : undefined; if (valueField != null) { acc.add(valueField); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events.test.ts index 092a684756ea39..eb5c69e8abfe8f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events.test.ts @@ -40,7 +40,7 @@ describe('filterEvents', () => { { field: 'source.ip', operator: 'included', - matchedSet: new Set([JSON.stringify('1.1.1.1')]), + matchedSet: new Set([JSON.stringify(['1.1.1.1'])]), }, ]; const field = filterEvents({ @@ -56,7 +56,7 @@ describe('filterEvents', () => { { field: 'source.ip', operator: 'excluded', - matchedSet: new Set([JSON.stringify('1.1.1.1')]), + matchedSet: new Set([JSON.stringify(['1.1.1.1'])]), }, ]; const field = filterEvents({ @@ -72,7 +72,7 @@ describe('filterEvents', () => { { field: 'madeup.nonexistent', // field does not exist operator: 'included', - matchedSet: new Set([JSON.stringify('1.1.1.1')]), + matchedSet: new Set([JSON.stringify(['1.1.1.1'])]), }, ]; const field = filterEvents({ @@ -88,12 +88,12 @@ describe('filterEvents', () => { { field: 'source.ip', operator: 'included', - matchedSet: new Set([JSON.stringify('1.1.1.1')]), + matchedSet: new Set([JSON.stringify(['1.1.1.1'])]), }, { field: 'source.ip', operator: 'excluded', - matchedSet: new Set([JSON.stringify('1.1.1.1')]), + matchedSet: new Set([JSON.stringify(['1.1.1.1'])]), }, ]; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events.ts index 316ef5eb74f41b..421ed91278f4cb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { get } from 'lodash/fp'; import { SearchResponse } from '../../../types'; import { FilterEventsOptions } from './types'; @@ -22,13 +21,17 @@ export const filterEvents = ({ return events.filter((item) => { return fieldAndSetTuples .map((tuple) => { - const eventItem = get(tuple.field, item._source); - if (eventItem == null) { - return true; - } else if (tuple.operator === 'included') { + const eventItem = item.fields ? item.fields[tuple.field] : undefined; + if (tuple.operator === 'included') { + if (eventItem == null) { + return true; + } // only create a signal if the event is not in the value list return !tuple.matchedSet.has(JSON.stringify(eventItem)); } else if (tuple.operator === 'excluded') { + if (eventItem == null) { + return false; + } // only create a signal if the event is in the value list return tuple.matchedSet.has(JSON.stringify(eventItem)); } else { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events_against_list.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events_against_list.test.ts index c1ba8eabf71101..5b2f3426cd8aac 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events_against_list.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events_against_list.test.ts @@ -162,12 +162,12 @@ describe('filterEventsAgainstList', () => { // this call represents an exception list with a value list containing ['2.2.2.2', '4.4.4.4'] (listClient.searchListItemByValues as jest.Mock).mockResolvedValueOnce([ - { ...getSearchListItemResponseMock(), value: '2.2.2.2' }, - { ...getSearchListItemResponseMock(), value: '4.4.4.4' }, + { ...getSearchListItemResponseMock(), value: ['2.2.2.2'] }, + { ...getSearchListItemResponseMock(), value: ['4.4.4.4'] }, ]); // this call represents an exception list with a value list containing ['6.6.6.6'] (listClient.searchListItemByValues as jest.Mock).mockResolvedValueOnce([ - { ...getSearchListItemResponseMock(), value: '6.6.6.6' }, + { ...getSearchListItemResponseMock(), value: ['6.6.6.6'] }, ]); const res = await filterEventsAgainstList({ @@ -224,11 +224,11 @@ describe('filterEventsAgainstList', () => { // this call represents an exception list with a value list containing ['2.2.2.2', '4.4.4.4'] (listClient.searchListItemByValues as jest.Mock).mockResolvedValueOnce([ - { ...getSearchListItemResponseMock(), value: '2.2.2.2' }, + { ...getSearchListItemResponseMock(), value: ['2.2.2.2'] }, ]); // this call represents an exception list with a value list containing ['6.6.6.6'] (listClient.searchListItemByValues as jest.Mock).mockResolvedValueOnce([ - { ...getSearchListItemResponseMock(), value: '6.6.6.6' }, + { ...getSearchListItemResponseMock(), value: ['6.6.6.6'] }, ]); const res = await filterEventsAgainstList({ @@ -283,11 +283,11 @@ describe('filterEventsAgainstList', () => { // this call represents an exception list with a value list containing ['2.2.2.2'] (listClient.searchListItemByValues as jest.Mock).mockResolvedValueOnce([ - { ...getSearchListItemResponseMock(), value: '2.2.2.2' }, + { ...getSearchListItemResponseMock(), value: ['2.2.2.2'] }, ]); // this call represents an exception list with a value list containing ['4.4.4.4'] (listClient.searchListItemByValues as jest.Mock).mockResolvedValueOnce([ - { ...getSearchListItemResponseMock(), value: '4.4.4.4' }, + { ...getSearchListItemResponseMock(), value: ['4.4.4.4'] }, ]); const res = await filterEventsAgainstList({ @@ -365,7 +365,7 @@ describe('filterEventsAgainstList', () => { // this call represents an exception list with a value list containing ['2.2.2.2', '4.4.4.4'] (listClient.searchListItemByValues as jest.Mock).mockResolvedValue([ - { ...getSearchListItemResponseMock(), value: '2.2.2.2' }, + { ...getSearchListItemResponseMock(), value: ['2.2.2.2'] }, ]); const res = await filterEventsAgainstList({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/find_threshold_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/find_threshold_signals.ts index 7d32ac6873eb20..6144f1f4b3823d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/find_threshold_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/find_threshold_signals.ts @@ -69,6 +69,12 @@ export const findThresholdSignals = async ({ }, }, ], + fields: [ + { + field: '*', + include_unmapped: true, + }, + ], size: 1, }, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 418d30711169e3..b506a2463a3113 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -310,9 +310,9 @@ describe('searchAfterAndBulkCreate', () => { test('should return success when all search results are in the allowlist and with sortId present', async () => { const searchListItems: SearchListItemArraySchema = [ - { ...getSearchListItemResponseMock(), value: '1.1.1.1' }, - { ...getSearchListItemResponseMock(), value: '2.2.2.2' }, - { ...getSearchListItemResponseMock(), value: '3.3.3.3' }, + { ...getSearchListItemResponseMock(), value: ['1.1.1.1'] }, + { ...getSearchListItemResponseMock(), value: ['2.2.2.2'] }, + { ...getSearchListItemResponseMock(), value: ['3.3.3.3'] }, ]; listClient.searchListItemByValues = jest.fn().mockResolvedValue(searchListItems); const sampleParams = sampleRuleAlertParams(30); @@ -374,10 +374,10 @@ describe('searchAfterAndBulkCreate', () => { test('should return success when all search results are in the allowlist and no sortId present', async () => { const searchListItems: SearchListItemArraySchema = [ - { ...getSearchListItemResponseMock(), value: '1.1.1.1' }, - { ...getSearchListItemResponseMock(), value: '2.2.2.2' }, - { ...getSearchListItemResponseMock(), value: '2.2.2.2' }, - { ...getSearchListItemResponseMock(), value: '2.2.2.2' }, + { ...getSearchListItemResponseMock(), value: ['1.1.1.1'] }, + { ...getSearchListItemResponseMock(), value: ['2.2.2.2'] }, + { ...getSearchListItemResponseMock(), value: ['2.2.2.2'] }, + { ...getSearchListItemResponseMock(), value: ['2.2.2.2'] }, ]; listClient.searchListItemByValues = jest.fn().mockResolvedValue(searchListItems); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts index 942db1e3b1aaa0..19aba907f0c849 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts @@ -316,19 +316,9 @@ describe('singleBulkCreate', () => { }); test('filter duplicate rules will return back search responses if they do not have a signal and will NOT filter the source out', () => { - const ancestors = sampleDocWithAncestors(); - ancestors.hits.hits[0]._source = { '@timestamp': '2020-04-20T21:27:45+0000' }; + const ancestors = sampleDocSearchResultsNoSortId(); const filtered = filterDuplicateRules('04128c15-0d1b-4716-a4c5-46997ac7f3bd', ancestors); - expect(filtered).toEqual([ - { - _index: 'myFakeSignalIndex', - _type: 'doc', - _score: 100, - _version: 1, - _id: 'e1e08ddc-5e37-49ff-a258-5393aa44435a', - _source: { '@timestamp': '2020-04-20T21:27:45+0000' }, - }, - ]); + expect(filtered).toEqual(ancestors.hits.hits); }); test('filter duplicate rules does not attempt filters when the signal is not an event type of signal but rather a "clash" from the source index having its own numeric signal type', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.mock.ts index a88d9061f7a1ff..12865e4dd47a90 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.mock.ts @@ -81,6 +81,7 @@ export const getThreatListSearchResponseMock = (): SearchResponse ({ }, }); +export const getThreatListItemFieldsMock = () => ({ + '@timestamp': ['2020-09-09T21:59:13Z'], + 'host.name': ['host-1'], + 'host.ip': ['192.168.0.0.1'], + 'source.ip': ['127.0.0.1'], + 'source.port': [1], + 'destination.ip': ['127.0.0.1'], + 'destination.port': [1], +}); + export const getFilterThreatMapping = (): ThreatMapping => [ { entries: [ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts index 792fa889e395d8..7a9c4b43b8f7af 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts @@ -133,10 +133,16 @@ describe('build_threat_mapping_filter', () => { }, ], threatListItem: { - '@timestamp': '2020-09-09T21:59:13Z', - host: { - name: 'host-1', - // since ip is missing this entire AND clause should be dropped + _source: { + '@timestamp': '2020-09-09T21:59:13Z', + host: { + name: 'host-1', + // since ip is missing this entire AND clause should be dropped + }, + }, + fields: { + '@timestamp': ['2020-09-09T21:59:13Z'], + 'host.name': ['host-1'], }, }, }); @@ -177,6 +183,10 @@ describe('build_threat_mapping_filter', () => { name: 'host-1', }, }, + fields: { + '@timestamp': ['2020-09-09T21:59:13Z'], + 'host.name': ['host-1'], + }, }, }); expect(item).toEqual([ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts index 180895877bdd22..cab01a602b8a95 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts @@ -55,7 +55,8 @@ export const filterThreatMapping = ({ threatMapping .map((threatMap) => { const atLeastOneItemMissingInThreatList = threatMap.entries.some((entry) => { - return get(entry.value, threatListItem._source) == null; + const itemValue = get(entry.value, threatListItem.fields); + return itemValue == null || itemValue.length !== 1; }); if (atLeastOneItemMissingInThreatList) { return { ...threatMap, entries: [] }; @@ -70,15 +71,15 @@ export const createInnerAndClauses = ({ threatListItem, }: CreateInnerAndClausesOptions): BooleanFilter[] => { return threatMappingEntries.reduce((accum, threatMappingEntry) => { - const value = get(threatMappingEntry.value, threatListItem._source); - if (value != null) { + const value = get(threatMappingEntry.value, threatListItem.fields); + if (value != null && value.length === 1) { // These values could be potentially 10k+ large so mutating the array intentionally accum.push({ bool: { should: [ { match: { - [threatMappingEntry.field]: value, + [threatMappingEntry.field]: value[0], }, }, ], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts index c646fee81f1b1d..92d4e5cf8a93b3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts @@ -55,6 +55,12 @@ export const getThreatList = async ({ const response: SearchResponse = await callCluster('search', { body: { query: queryFilter, + fields: [ + { + field: '*', + include_unmapped: true, + }, + ], search_after: searchAfter, sort: getSortWithTieBreaker({ sortField, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index 5444f084740538..75bd9f593a6ac1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -1166,6 +1166,9 @@ describe('utils', () => { test('It will not set an invalid date time stamp from a non-existent @timestamp when the index is not 100% ECS compliant', () => { const searchResult = sampleDocSearchResultsNoSortId(); (searchResult.hits.hits[0]._source['@timestamp'] as unknown) = undefined; + if (searchResult.hits.hits[0].fields != null) { + (searchResult.hits.hits[0].fields['@timestamp'] as unknown) = undefined; + } const { lastLookBackDate } = createSearchAfterReturnTypeFromResponse({ searchResult, timestampOverride: undefined, @@ -1176,6 +1179,9 @@ describe('utils', () => { test('It will not set an invalid date time stamp from a null @timestamp when the index is not 100% ECS compliant', () => { const searchResult = sampleDocSearchResultsNoSortId(); (searchResult.hits.hits[0]._source['@timestamp'] as unknown) = null; + if (searchResult.hits.hits[0].fields != null) { + (searchResult.hits.hits[0].fields['@timestamp'] as unknown) = null; + } const { lastLookBackDate } = createSearchAfterReturnTypeFromResponse({ searchResult, timestampOverride: undefined, @@ -1186,6 +1192,9 @@ describe('utils', () => { test('It will not set an invalid date time stamp from an invalid @timestamp string', () => { const searchResult = sampleDocSearchResultsNoSortId(); (searchResult.hits.hits[0]._source['@timestamp'] as unknown) = 'invalid'; + if (searchResult.hits.hits[0].fields != null) { + (searchResult.hits.hits[0].fields['@timestamp'] as unknown) = ['invalid']; + } const { lastLookBackDate } = createSearchAfterReturnTypeFromResponse({ searchResult, timestampOverride: undefined, @@ -1198,6 +1207,9 @@ describe('utils', () => { test('It returns undefined if the search result contains a null timestamp', () => { const searchResult = sampleDocSearchResultsNoSortId(); (searchResult.hits.hits[0]._source['@timestamp'] as unknown) = null; + if (searchResult.hits.hits[0].fields != null) { + (searchResult.hits.hits[0].fields['@timestamp'] as unknown) = null; + } const date = lastValidDate({ searchResult, timestampOverride: undefined }); expect(date).toEqual(undefined); }); @@ -1205,6 +1217,9 @@ describe('utils', () => { test('It returns undefined if the search result contains a undefined timestamp', () => { const searchResult = sampleDocSearchResultsNoSortId(); (searchResult.hits.hits[0]._source['@timestamp'] as unknown) = undefined; + if (searchResult.hits.hits[0].fields != null) { + (searchResult.hits.hits[0].fields['@timestamp'] as unknown) = undefined; + } const date = lastValidDate({ searchResult, timestampOverride: undefined }); expect(date).toEqual(undefined); }); @@ -1212,13 +1227,9 @@ describe('utils', () => { test('It returns undefined if the search result contains an invalid string value', () => { const searchResult = sampleDocSearchResultsNoSortId(); (searchResult.hits.hits[0]._source['@timestamp'] as unknown) = 'invalid value'; - const date = lastValidDate({ searchResult, timestampOverride: undefined }); - expect(date).toEqual(undefined); - }); - - test('It returns correct date time stamp if the search result contains an invalid string value', () => { - const searchResult = sampleDocSearchResultsNoSortId(); - (searchResult.hits.hits[0]._source['@timestamp'] as unknown) = 'invalid value'; + if (searchResult.hits.hits[0].fields != null) { + (searchResult.hits.hits[0].fields['@timestamp'] as unknown) = ['invalid value']; + } const date = lastValidDate({ searchResult, timestampOverride: undefined }); expect(date).toEqual(undefined); }); diff --git a/x-pack/plugins/security_solution/server/lib/machine_learning/index.ts b/x-pack/plugins/security_solution/server/lib/machine_learning/index.ts index a6f4c2086e47b0..962c44174d8914 100644 --- a/x-pack/plugins/security_solution/server/lib/machine_learning/index.ts +++ b/x-pack/plugins/security_solution/server/lib/machine_learning/index.ts @@ -60,6 +60,12 @@ export const getAnomalies = async ( })?.query, }, }, + fields: [ + { + field: '*', + include_unmapped: true, + }, + ], sort: [{ record_score: { order: 'desc' } }], }, }, diff --git a/x-pack/plugins/security_solution/server/lib/machine_learning/mocks.ts b/x-pack/plugins/security_solution/server/lib/machine_learning/mocks.ts index 630e9c9c884895..5d1b090e98a798 100644 --- a/x-pack/plugins/security_solution/server/lib/machine_learning/mocks.ts +++ b/x-pack/plugins/security_solution/server/lib/machine_learning/mocks.ts @@ -19,6 +19,7 @@ export const mlServicesMock = { (({ modulesProvider: jest.fn(), jobServiceProvider: jest.fn(), + anomalyDetectorsProvider: jest.fn(), mlSystemProvider: createMockMlSystemProvider(), mlClient: createMockClient(), } as unknown) as jest.Mocked), diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/constants.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/constants.ts index 649a36d01d47d3..5e9391df5b8a40 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/constants.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/constants.ts @@ -162,9 +162,12 @@ export const TIMELINE_EVENTS_FIELDS = [ 'tls.server_certificate.fingerprint.sha1', 'user.domain', 'winlog.event_id', + 'process.exit_code', 'process.hash.md5', 'process.hash.sha1', 'process.hash.sha256', + 'process.parent.name', + 'process.parent.pid', 'process.pid', 'process.name', 'process.ppid', diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/index.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/index.ts index 40867e566a7306..f5deb258fc1f48 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { cloneDeep, merge, unionBy } from 'lodash/fp'; +import { cloneDeep, merge } from 'lodash/fp'; import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; import { @@ -17,7 +17,7 @@ import { import { inspectStringifyObject } from '../../../../../utils/build_query'; import { SecuritySolutionTimelineFactory } from '../../types'; import { buildTimelineDetailsQuery } from './query.events_details.dsl'; -import { getDataFromFieldsHits, getDataFromSourceHits } from './helpers'; +import { getDataFromSourceHits } from './helpers'; export const timelineEventsDetails: SecuritySolutionTimelineFactory = { buildDsl: (options: TimelineEventsDetailsRequestOptions) => { @@ -29,7 +29,7 @@ export const timelineEventsDetails: SecuritySolutionTimelineFactory ): Promise => { const { indexName, eventId, docValueFields = [] } = options; - const { _source, fields, ...hitsData } = cloneDeep(response.rawResponse.hits.hits[0] ?? {}); + const { _source, ...hitsData } = cloneDeep(response.rawResponse.hits.hits[0] ?? {}); const inspect = { dsl: [inspectStringifyObject(buildTimelineDetailsQuery(indexName, eventId, docValueFields))], }; @@ -42,13 +42,11 @@ export const timelineEventsDetails: SecuritySolutionTimelineFactory { + it('returns the expected query', () => { + const indexName = '.siem-signals-default'; + const eventId = 'f0a936d50b5b3a5a193d415459c14587fe633f7e519df7b5dc151d56142680e3'; + const docValueFields = [ + { field: '@timestamp' }, + { field: 'agent.ephemeral_id' }, + { field: 'agent.id' }, + { field: 'agent.name' }, + ]; + + const query = buildTimelineDetailsQuery(indexName, eventId, docValueFields); + + expect(query).toMatchInlineSnapshot(` + Object { + "allowNoIndices": true, + "body": Object { + "docvalue_fields": Array [ + Object { + "field": "@timestamp", + }, + Object { + "field": "agent.ephemeral_id", + }, + Object { + "field": "agent.id", + }, + Object { + "field": "agent.name", + }, + ], + "query": Object { + "terms": Object { + "_id": Array [ + "f0a936d50b5b3a5a193d415459c14587fe633f7e519df7b5dc151d56142680e3", + ], + }, + }, + }, + "ignoreUnavailable": true, + "index": ".siem-signals-default", + "size": 1, + } + `); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/query.events_details.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/query.events_details.dsl.ts index a1265750271fad..e8890072c1aff6 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/query.events_details.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/query.events_details.dsl.ts @@ -22,8 +22,6 @@ export const buildTimelineDetailsQuery = ( _id: [id], }, }, - fields: ['*'], - _source: ['signal.*'], }, size: 1, }); diff --git a/x-pack/plugins/security_solution/server/usage/collector.ts b/x-pack/plugins/security_solution/server/usage/collector.ts index 9126029139ef4d..981101bf733c75 100644 --- a/x-pack/plugins/security_solution/server/usage/collector.ts +++ b/x-pack/plugins/security_solution/server/usage/collector.ts @@ -8,13 +8,19 @@ import { CoreSetup, SavedObjectsClientContract } from '../../../../../src/core/server'; import { CollectorFetchContext } from '../../../../../src/plugins/usage_collection/server'; import { CollectorDependencies } from './types'; -import { DetectionsUsage, fetchDetectionsUsage, defaultDetectionsUsage } from './detections'; +import { + DetectionsUsage, + fetchDetectionsUsage, + defaultDetectionsUsage, + fetchDetectionsMetrics, +} from './detections'; import { EndpointUsage, getEndpointTelemetryFromFleet } from './endpoints'; export type RegisterCollector = (deps: CollectorDependencies) => void; export interface UsageData { detections: DetectionsUsage; endpoints: EndpointUsage | {}; + detectionMetrics: {}; } export async function getInternalSavedObjectsClient(core: CoreSetup) { @@ -57,6 +63,53 @@ export const registerCollector: RegisterCollector = ({ }, }, }, + detectionMetrics: { + ml_jobs: { + type: 'array', + items: { + job_id: { type: 'keyword' }, + open_time: { type: 'keyword' }, + create_time: { type: 'keyword' }, + finished_time: { type: 'keyword' }, + state: { type: 'keyword' }, + data_counts: { + bucket_count: { type: 'long' }, + empty_bucket_count: { type: 'long' }, + input_bytes: { type: 'long' }, + input_record_count: { type: 'long' }, + last_data_time: { type: 'long' }, + processed_record_count: { type: 'long' }, + }, + model_size_stats: { + bucket_allocation_failures_count: { type: 'long' }, + model_bytes: { type: 'long' }, + model_bytes_exceeded: { type: 'long' }, + model_bytes_memory_limit: { type: 'long' }, + peak_model_bytes: { type: 'long' }, + }, + timing_stats: { + average_bucket_processing_time_ms: { type: 'long' }, + bucket_count: { type: 'long' }, + exponential_average_bucket_processing_time_ms: { type: 'long' }, + exponential_average_bucket_processing_time_per_hour_ms: { type: 'long' }, + maximum_bucket_processing_time_ms: { type: 'long' }, + minimum_bucket_processing_time_ms: { type: 'long' }, + total_bucket_processing_time_ms: { type: 'long' }, + }, + datafeed: { + datafeed_id: { type: 'keyword' }, + state: { type: 'keyword' }, + timing_stats: { + average_search_time_per_bucket_ms: { type: 'long' }, + bucket_count: { type: 'long' }, + exponential_average_search_time_per_hour_ms: { type: 'long' }, + search_count: { type: 'long' }, + total_search_time_ms: { type: 'long' }, + }, + }, + }, + }, + }, endpoints: { total_installed: { type: 'long' }, active_within_last_24_hours: { type: 'long' }, @@ -80,19 +133,17 @@ export const registerCollector: RegisterCollector = ({ }, isReady: () => kibanaIndex.length > 0, fetch: async ({ esClient }: CollectorFetchContext): Promise => { - const savedObjectsClient = await getInternalSavedObjectsClient(core); - const [detections, endpoints] = await Promise.allSettled([ - fetchDetectionsUsage( - kibanaIndex, - esClient, - ml, - (savedObjectsClient as unknown) as SavedObjectsClientContract - ), - getEndpointTelemetryFromFleet(savedObjectsClient), + const internalSavedObjectsClient = await getInternalSavedObjectsClient(core); + const savedObjectsClient = (internalSavedObjectsClient as unknown) as SavedObjectsClientContract; + const [detections, detectionMetrics, endpoints] = await Promise.allSettled([ + fetchDetectionsUsage(kibanaIndex, esClient, ml, savedObjectsClient), + fetchDetectionsMetrics(ml, savedObjectsClient), + getEndpointTelemetryFromFleet(internalSavedObjectsClient), ]); return { detections: detections.status === 'fulfilled' ? detections.value : defaultDetectionsUsage, + detectionMetrics: detectionMetrics.status === 'fulfilled' ? detectionMetrics.value : {}, endpoints: endpoints.status === 'fulfilled' ? endpoints.value : {}, }; }, diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts b/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts index 5601250ac1ecdb..f7fa59958abae4 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts @@ -175,3 +175,130 @@ export const getMockRulesResponse = () => ({ ], }, }); + +export const getMockMlJobDetailsResponse = () => ({ + count: 20, + jobs: [ + { + job_id: 'high_distinct_count_error_message', + job_type: 'anomaly_detector', + job_version: '8.0.0', + create_time: 1603838214983, + finished_time: 1611739871669, + model_snapshot_id: '1611740107', + custom_settings: { + created_by: undefined, + }, + groups: ['cloudtrail', 'security'], + description: + 'Security: Cloudtrail - Looks for a spike in the rate of an error message which may simply indicate an impending service failure but these can also be byproducts of attempted or successful persistence, privilege escalation, defense evasion, discovery, lateral movement, or collection activity by a threat actor.', + analysis_config: { + bucket_span: '15m', + detectors: [ + { + detector_description: 'high_distinct_count("aws.cloudtrail.error_message")', + function: 'high_distinct_count', + field_name: 'aws.cloudtrail.error_message', + detector_index: 0, + }, + ], + influencers: ['aws.cloudtrail.user_identity.arn', 'source.ip', 'source.geo.city_name'], + }, + analysis_limits: { + model_memory_limit: '16mb', + categorization_examples_limit: 4, + }, + data_description: { + time_field: '@timestamp', + time_format: 'epoch_ms', + }, + model_snapshot_retention_days: 10, + daily_model_snapshot_retention_after_days: 1, + results_index_name: 'custom-high_distinct_count_error_message', + }, + ], +}); + +export const getMockMlJobStatsResponse = () => ({ + count: 1, + jobs: [ + { + job_id: 'high_distinct_count_error_message', + data_counts: { + job_id: 'high_distinct_count_error_message', + processed_record_count: 162, + processed_field_count: 476, + input_bytes: 45957, + input_field_count: 476, + invalid_date_count: 0, + missing_field_count: 172, + out_of_order_timestamp_count: 0, + empty_bucket_count: 8590, + sparse_bucket_count: 0, + bucket_count: 8612, + earliest_record_timestamp: 1602648289000, + latest_record_timestamp: 1610399348000, + last_data_time: 1610470367123, + latest_empty_bucket_timestamp: 1610397900000, + input_record_count: 162, + log_time: 1610470367123, + }, + model_size_stats: { + job_id: 'high_distinct_count_error_message', + result_type: 'model_size_stats', + model_bytes: 72574, + peak_model_bytes: 78682, + model_bytes_exceeded: 0, + model_bytes_memory_limit: 16777216, + total_by_field_count: 4, + total_over_field_count: 0, + total_partition_field_count: 3, + bucket_allocation_failures_count: 0, + memory_status: 'ok', + assignment_memory_basis: 'current_model_bytes', + categorized_doc_count: 0, + total_category_count: 0, + frequent_category_count: 0, + rare_category_count: 0, + dead_category_count: 0, + failed_category_count: 0, + categorization_status: 'ok', + log_time: 1611740107843, + timestamp: 1611738900000, + }, + forecasts_stats: { + total: 0, + forecasted_jobs: 0, + }, + state: 'closed', + timing_stats: { + job_id: 'high_distinct_count_error_message', + bucket_count: 16236, + total_bucket_processing_time_ms: 7957.00000000008, + minimum_bucket_processing_time_ms: 0, + maximum_bucket_processing_time_ms: 392, + average_bucket_processing_time_ms: 0.4900837644740133, + exponential_average_bucket_processing_time_ms: 0.23614068552903306, + exponential_average_bucket_processing_time_per_hour_ms: 1.5551298175461634, + }, + }, + ], +}); + +export const getMockMlDatafeedStatsResponse = () => ({ + count: 1, + datafeeds: [ + { + datafeed_id: 'datafeed-high_distinct_count_error_message', + state: 'stopped', + timing_stats: { + job_id: 'high_distinct_count_error_message', + search_count: 7202, + bucket_count: 8612, + total_search_time_ms: 3107147, + average_search_time_per_bucket_ms: 360.7927310729215, + exponential_average_search_time_per_hour_ms: 86145.39799630083, + }, + }, + ], +}); diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts b/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts index 1804d7c756e53a..b53f90f40f6216 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts @@ -12,15 +12,18 @@ import { getMockJobSummaryResponse, getMockListModulesResponse, getMockRulesResponse, + getMockMlJobDetailsResponse, + getMockMlJobStatsResponse, + getMockMlDatafeedStatsResponse, } from './detections.mocks'; -import { fetchDetectionsUsage } from './index'; +import { fetchDetectionsUsage, fetchDetectionsMetrics } from './index'; -describe('Detections Usage', () => { - describe('fetchDetectionsUsage()', () => { - let esClientMock: jest.Mocked; - let savedObjectsClientMock: jest.Mocked; - let mlMock: ReturnType; +describe('Detections Usage and Metrics', () => { + let esClientMock: jest.Mocked; + let savedObjectsClientMock: jest.Mocked; + let mlMock: ReturnType; + describe('fetchDetectionsUsage()', () => { beforeEach(() => { esClientMock = elasticsearchServiceMock.createClusterClient().asInternalUser; mlMock = mlServicesMock.create(); @@ -102,4 +105,89 @@ describe('Detections Usage', () => { ); }); }); + + describe('fetchDetectionsMetrics()', () => { + beforeEach(() => { + mlMock = mlServicesMock.create(); + }); + + it('returns an empty array if there is no data', async () => { + mlMock.anomalyDetectorsProvider.mockReturnValue(({ + jobs: null, + jobStats: null, + } as unknown) as ReturnType); + const result = await fetchDetectionsMetrics(mlMock, savedObjectsClientMock); + + expect(result).toEqual( + expect.objectContaining({ + ml_jobs: [], + }) + ); + }); + + it('returns an ml job telemetry object from anomaly detectors provider', async () => { + const mockJobsResponse = jest.fn().mockResolvedValue(getMockMlJobDetailsResponse()); + const mockJobStatsResponse = jest.fn().mockResolvedValue(getMockMlJobStatsResponse()); + const mockDatafeedStatsResponse = jest + .fn() + .mockResolvedValue(getMockMlDatafeedStatsResponse()); + + mlMock.anomalyDetectorsProvider.mockReturnValue(({ + jobs: mockJobsResponse, + jobStats: mockJobStatsResponse, + datafeedStats: mockDatafeedStatsResponse, + } as unknown) as ReturnType); + + const result = await fetchDetectionsMetrics(mlMock, savedObjectsClientMock); + + expect(result).toEqual( + expect.objectContaining({ + ml_jobs: [ + { + job_id: 'high_distinct_count_error_message', + create_time: 1603838214983, + finished_time: 1611739871669, + state: 'closed', + data_counts: { + bucket_count: 8612, + empty_bucket_count: 8590, + input_bytes: 45957, + input_record_count: 162, + last_data_time: 1610470367123, + processed_record_count: 162, + }, + model_size_stats: { + bucket_allocation_failures_count: 0, + memory_status: 'ok', + model_bytes: 72574, + model_bytes_exceeded: 0, + model_bytes_memory_limit: 16777216, + peak_model_bytes: 78682, + }, + timing_stats: { + average_bucket_processing_time_ms: 0.4900837644740133, + bucket_count: 16236, + exponential_average_bucket_processing_time_ms: 0.23614068552903306, + exponential_average_bucket_processing_time_per_hour_ms: 1.5551298175461634, + maximum_bucket_processing_time_ms: 392, + minimum_bucket_processing_time_ms: 0, + total_bucket_processing_time_ms: 7957.00000000008, + }, + datafeed: { + datafeed_id: 'datafeed-high_distinct_count_error_message', + state: 'stopped', + timing_stats: { + average_search_time_per_bucket_ms: 360.7927310729215, + bucket_count: 8612, + exponential_average_search_time_per_hour_ms: 86145.39799630083, + search_count: 7202, + total_search_time_ms: 3107147, + }, + }, + }, + ], + }) + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts index 9ffd3e09117796..4236c782d6c685 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts @@ -13,7 +13,7 @@ import { } from '../../../../../../src/core/server'; import { MlPluginSetup } from '../../../../ml/server'; import { SIGNALS_ID, INTERNAL_IMMUTABLE_KEY } from '../../../common/constants'; -import { DetectionRulesUsage, MlJobsUsage } from './index'; +import { DetectionRulesUsage, MlJobsUsage, MlJobMetric } from './index'; import { isJobStarted } from '../../../common/machine_learning/helpers'; import { isSecurityJob } from '../../../common/machine_learning/is_security_job'; @@ -213,3 +213,93 @@ export const getMlJobsUsage = async ( return jobsUsage; }; + +export const getMlJobMetrics = async ( + ml: MlPluginSetup | undefined, + savedObjectClient: SavedObjectsClientContract +): Promise => { + if (ml) { + try { + const fakeRequest = { headers: {} } as KibanaRequest; + const jobsType = 'security'; + const securityJobStats = await ml + .anomalyDetectorsProvider(fakeRequest, savedObjectClient) + .jobStats(jobsType); + + const jobDetails = await ml + .anomalyDetectorsProvider(fakeRequest, savedObjectClient) + .jobs(jobsType); + + const jobDetailsCache = new Map(); + jobDetails.jobs.forEach((detail) => jobDetailsCache.set(detail.job_id, detail)); + + const datafeedStats = await ml + .anomalyDetectorsProvider(fakeRequest, savedObjectClient) + .datafeedStats(); + + const datafeedStatsCache = new Map(); + datafeedStats.datafeeds.forEach((datafeedStat) => + datafeedStatsCache.set(`${datafeedStat.datafeed_id}`, datafeedStat) + ); + + return securityJobStats.jobs.map((stat) => { + const jobId = stat.job_id; + const jobDetail = jobDetailsCache.get(stat.job_id); + const datafeed = datafeedStatsCache.get(`datafeed-${jobId}`); + + return { + job_id: jobId, + open_time: stat.open_time, + create_time: jobDetail?.create_time, + finished_time: jobDetail?.finished_time, + state: stat.state, + data_counts: { + bucket_count: stat.data_counts.bucket_count, + empty_bucket_count: stat.data_counts.empty_bucket_count, + input_bytes: stat.data_counts.input_bytes, + input_record_count: stat.data_counts.input_record_count, + last_data_time: stat.data_counts.last_data_time, + processed_record_count: stat.data_counts.processed_record_count, + }, + model_size_stats: { + bucket_allocation_failures_count: + stat.model_size_stats.bucket_allocation_failures_count, + memory_status: stat.model_size_stats.memory_status, + model_bytes: stat.model_size_stats.model_bytes, + model_bytes_exceeded: stat.model_size_stats.model_bytes_exceeded, + model_bytes_memory_limit: stat.model_size_stats.model_bytes_memory_limit, + peak_model_bytes: stat.model_size_stats.peak_model_bytes, + }, + timing_stats: { + average_bucket_processing_time_ms: stat.timing_stats.average_bucket_processing_time_ms, + bucket_count: stat.timing_stats.bucket_count, + exponential_average_bucket_processing_time_ms: + stat.timing_stats.exponential_average_bucket_processing_time_ms, + exponential_average_bucket_processing_time_per_hour_ms: + stat.timing_stats.exponential_average_bucket_processing_time_per_hour_ms, + maximum_bucket_processing_time_ms: stat.timing_stats.maximum_bucket_processing_time_ms, + minimum_bucket_processing_time_ms: stat.timing_stats.minimum_bucket_processing_time_ms, + total_bucket_processing_time_ms: stat.timing_stats.total_bucket_processing_time_ms, + }, + datafeed: { + datafeed_id: datafeed?.datafeed_id, + state: datafeed?.state, + timing_stats: { + average_search_time_per_bucket_ms: + datafeed?.timing_stats.average_search_time_per_bucket_ms, + bucket_count: datafeed?.timing_stats.bucket_count, + exponential_average_search_time_per_hour_ms: + datafeed?.timing_stats.exponential_average_search_time_per_hour_ms, + search_count: datafeed?.timing_stats.search_count, + total_search_time_ms: datafeed?.timing_stats.total_search_time_ms, + }, + }, + } as MlJobMetric; + }); + } catch (e) { + // ignore failure, usage will be zeroed + } + } + + return []; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/index.ts b/x-pack/plugins/security_solution/server/usage/detections/index.ts index 27f0b1acb2ee96..39c8f3159fe037 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/index.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/index.ts @@ -8,6 +8,7 @@ import { ElasticsearchClient, SavedObjectsClientContract } from '../../../../../../src/core/server'; import { getMlJobsUsage, + getMlJobMetrics, getRulesUsage, initialRulesUsage, initialMlJobsUsage, @@ -34,6 +35,47 @@ export interface DetectionsUsage { ml_jobs: MlJobsUsage; } +export interface DetectionMetrics { + ml_jobs: MlJobMetric[]; +} + +export interface MlJobDataCount { + bucket_count: number; + empty_bucket_count: number; + input_bytes: number; + input_record_count: number; + last_data_time: number; + processed_record_count: number; +} + +export interface MlJobModelSize { + bucket_allocation_failures_count: number; + memory_status: string; + model_bytes: number; + model_bytes_exceeded: number; + model_bytes_memory_limit: number; + peak_model_bytes: number; +} + +export interface MlTimingStats { + average_bucket_processing_time_ms: number; + bucket_count: number; + exponential_average_bucket_processing_time_ms: number; + exponential_average_bucket_processing_time_per_hour_ms: number; + maximum_bucket_processing_time_ms: number; + minimum_bucket_processing_time_ms: number; + total_bucket_processing_time_ms: number; +} + +export interface MlJobMetric { + job_id: string; + open_time: string; + state: string; + data_counts: MlJobDataCount; + model_size_stats: MlJobModelSize; + timing_stats: MlTimingStats; +} + export const defaultDetectionsUsage = { detection_rules: initialRulesUsage, ml_jobs: initialMlJobsUsage, @@ -55,3 +97,14 @@ export const fetchDetectionsUsage = async ( ml_jobs: mlJobsUsage.status === 'fulfilled' ? mlJobsUsage.value : initialMlJobsUsage, }; }; + +export const fetchDetectionsMetrics = async ( + ml: MlPluginSetup | undefined, + savedObjectClient: SavedObjectsClientContract +): Promise => { + const [mlJobMetrics] = await Promise.allSettled([getMlJobMetrics(ml, savedObjectClient)]); + + return { + ml_jobs: mlJobMetrics.status === 'fulfilled' ? mlJobMetrics.value : [], + }; +}; diff --git a/x-pack/plugins/stack_alerts/common/config.ts b/x-pack/plugins/stack_alerts/common/config.ts index 1bd7b2728a95c8..ebc12ee563350c 100644 --- a/x-pack/plugins/stack_alerts/common/config.ts +++ b/x-pack/plugins/stack_alerts/common/config.ts @@ -9,7 +9,6 @@ import { schema, TypeOf } from '@kbn/config-schema'; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), - enableGeoAlerting: schema.boolean({ defaultValue: false }), }); export type Config = TypeOf; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/readme.md b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/readme.md new file mode 100644 index 00000000000000..b48a28fbdf99ba --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/readme.md @@ -0,0 +1,121 @@ +## Instructions for loading & observing data + +There are several steps required to set up geo containment alerts for testing in a way +that allows you to view triggered alerts as they happen. These instructions outline +how to load test data, but really these steps can be used to load any data for geo +containment alerts so long as you have the following data: +- An index containing a`geo_point` field and a `date` field. This data is presumed to +be dynamic (updated). +- An index containing `geo_shape` data, such as boundary data, bounding box data, etc. +This data is presumed to be static (not updated). Shape data matching the query is +harvested once when the alert is created and anytime after when alert is re-enabled +after disablement +The ability for containment alerts to monitor data requires there be somewhat "real time" +data streaming in as indicated by the `date` field. + +### 1. Set experimental flag to enable containment alerts +- Your `kibana.yml` config file is located in the `config/` dir in the base of your kibana +project. To edit it, open this file in your editor of choice, add the line described in +the next step to the bottom of the file (or really anywhere) and save. For more details +on different config modifications or on how to make production config modifications, +see [the current docs](https://www.elastic.co/guide/en/kibana/current/settings.html) + +### 2. Run ES/Kibana dev env with ssl enabled +- In two terminals, run the normal commands to launch both elasticsearch and kibana but +append `--ssl` to the end of each as an arg, i.e.: + - `yarn es snapshot --ssl # Runs Elasticsearch` + - `yarn start --ssl # Runs Kibana` + +### 3. Get an MTA data api key +- You'll need to obtain an NYC MTA api key, you can request this + key [here](https://docs.google.com/forms/d/e/1FAIpQLSfGUZA6h4eHd2-ImaK5Q_I5Gb7C3UEP5vYDALyGd7r3h08YKg/viewform?hl=en&formkey=dG9kcGIxRFpSS0NhQWM4UjA0V0VkNGc6MQ#gid=0) + +### 4. Get trackable point data (MTA bus data) into elasticsearch +- You'll be using the script: `https://github.com/thomasneirynck/mtatracks` to harvest +live bus data to populate the system. Clone the repo and follow the instructions in +the readme to set up. +- Using the MTA key you obtained in the previous step, the final command to run +in a local terminal should look something like the following. This script loads large +quantities of data the frequency listed below (20000ms = 20s) or higher: +`node ./load_tracks.js -a -f 20000` + +### 5. Open required Kibana tabs +There are 3 separate tabs you'll need for a combination of loading and viewing the +data. Since you'll be jumping between them, it might be easiest to just open them +upfront. Each is preceded by `https://localhost:5601//app/`: +- Stack Management > Index Patterns: `management/kibana/indexPatterns` +- Stack Management > Alerts & Actions: `management/insightsAndAlerting/triggersActions/alerts` +- Maps: `maps` + +### 6 Create map to monitor alerts +- Go to the Maps app and create a new map +- Using GeoJSON Upload, upload the GeoJSON file located in the folder of the previously +cloned `mta_tracks` repo: `nyc-neighborhoods.geo.json`. Accept all of the default +settings and add the layer. +- You may want to click your newly added layer and select "Fit to data" so you can see the +boundaries you've added. +_ When finished uploading and adding the layer, save the map using a name of your +choice. +- Keep the Maps tab open, you'll come back to this + +### 7. Create index pattern for generated tracks +- Go to the index pattern tab to create a new index pattern. +- Give it the index name `mtatracks*` +- For `Time field` select `@timestamp` +- Click `Create index pattern` +- Leave this tab open, you'll come back to this + +### 8. Create containment alert +- Go to the Alerts tab and click `Create Alert` > `Tracking containment` +- Fill the side bar form top to bottom. This _should_ flow somewhat logically. In the top +section, set both `Check every` and `Notify every` to `1 minute`. + For `Notify`, leave +on default selected option `Only on status change`, this will notify only on newly +contained entities. + **Please note that `2 seconds` is an unusually quick interval but done here for demo + purposes. With real world data, setting an appropriate interval speed is highly dependent + upon the quantity, update frequency and complexity of data handled.** +- The default settings for `Select Entity` will mostly be correct. Select `mta_tracks*` +as the index you'd like to track. Use the defaults populated under +`Select entity` > `INDEX`, update `Select entity` > `BY` to `vehicle_ref`. +- For `Select boundary` > `INDEX`, select `nyc-neighborhoods` and all populated defaults. +- Under `Actions`, create an `Server log` action, then create a `Connector` which you can simply name +`Log test`. +- For `Run when`, the default `Tracking containment met` will work here. This will track +only points that are newly contained in the boundaries. +- Leave the log level at `Info` +- For the message, use the following sample message or one of your own: +``` +Entity: {{context.entityId}} with document ID: {{context.entityDocumentId}} has been recorded at location: {{context.entityLocation}} in boundary: {{context.containingBoundaryName}}({{context.containingBoundaryId}}) at {{context.entityDateTime}}. This was detected by the alerting framework at: {{context.detectionDateTime}}. +``` +- At the bottom right, click `Save`. Your alert should now be created! +- You should now be able to see alerts generated in your Kibana console log. + +### 9. Visually confirm your alerts with Maps +- Creating layers + - Using the source data below, you can create the following layers: + - Boundary data (`nyc-neighborhoods`) + - Boundary layer + - Original tracks data (`mtatracks*`) + - Last known location + - Geo-line track + - Boundary layer + - This layer should already be added from when you uploaded the GeoJSON + file earlier. If it's not already added, it can be added by selecting `Documents` + > `Index patterns` > `nyc-neighborhoods` then accept the defaults and add the layer. + - Vehicle tracks + - Add `Tracks` > `Index patterns` > `mtatracks*`, accept the defaults selected and set `Entity` > `entity_id`. Add the layer and style appropriately. + - Last known location + - Add `Documents` > `Index patterns` > `mtatracks*` and select `Show top hits per entity` + - For `Entity` select `entity_id` and add the layer. + - The only required setting on the following screen is to set `Sorting` to sort on `@timestamp` +- Update time scope of data + - Changing the refresh rate `Refresh every`: `4 seconds` keeps the layers updated and in particular + shows the latest values obtained in the `Top hits` layer + - The time picker should already be set to the default `15 minutes`, this is a good default but + can be adjusted up or down to see more or less data respectively +- General tips + - Style layers with contrasting colors to clearly see each + - Consider using icons for the `Top hits` vehicle movement layer + - Consider adding tooltips to layers to better understand the data in your layers. + - Save your Map anytime you've made any layer adjustments diff --git a/x-pack/plugins/stack_alerts/public/alert_types/index.ts b/x-pack/plugins/stack_alerts/public/alert_types/index.ts index 55819785d628bf..d6f9f97939b796 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/index.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/index.ts @@ -18,9 +18,7 @@ export function registerAlertTypes({ alertTypeRegistry: TriggersAndActionsUIPublicPluginSetup['alertTypeRegistry']; config: Config; }) { - if (config.enableGeoAlerting) { - alertTypeRegistry.register(getGeoContainmentAlertType()); - } + alertTypeRegistry.register(getGeoContainmentAlertType()); alertTypeRegistry.register(getThresholdAlertType()); alertTypeRegistry.register(getEsQueryAlertType()); } diff --git a/x-pack/plugins/stack_alerts/server/index.ts b/x-pack/plugins/stack_alerts/server/index.ts index 4834749ab5917e..bd10a486fa531b 100644 --- a/x-pack/plugins/stack_alerts/server/index.ts +++ b/x-pack/plugins/stack_alerts/server/index.ts @@ -11,16 +11,8 @@ import { configSchema, Config } from '../common/config'; export { ID as INDEX_THRESHOLD_ID } from './alert_types/index_threshold/alert_type'; export const config: PluginConfigDescriptor = { - exposeToBrowser: { - enableGeoAlerting: true, - }, + exposeToBrowser: {}, schema: configSchema, - deprecations: ({ renameFromRoot }) => [ - renameFromRoot( - 'xpack.triggers_actions_ui.enableGeoTrackingThresholdAlert', - 'xpack.stack_alerts.enableGeoAlerting' - ), - ], }; export const plugin = (ctx: PluginInitializerContext) => new AlertingBuiltinsPlugin(ctx); diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index c1674f8a926699..9e6a0c06808bc8 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -3244,6 +3244,127 @@ } } }, + "detectionMetrics": { + "properties": { + "ml_jobs": { + "type": "array", + "items": { + "properties": { + "job_id": { + "type": "keyword" + }, + "open_time": { + "type": "keyword" + }, + "create_time": { + "type": "keyword" + }, + "finished_time": { + "type": "keyword" + }, + "state": { + "type": "keyword" + }, + "data_counts": { + "properties": { + "bucket_count": { + "type": "long" + }, + "empty_bucket_count": { + "type": "long" + }, + "input_bytes": { + "type": "long" + }, + "input_record_count": { + "type": "long" + }, + "last_data_time": { + "type": "long" + }, + "processed_record_count": { + "type": "long" + } + } + }, + "model_size_stats": { + "properties": { + "bucket_allocation_failures_count": { + "type": "long" + }, + "model_bytes": { + "type": "long" + }, + "model_bytes_exceeded": { + "type": "long" + }, + "model_bytes_memory_limit": { + "type": "long" + }, + "peak_model_bytes": { + "type": "long" + } + } + }, + "timing_stats": { + "properties": { + "average_bucket_processing_time_ms": { + "type": "long" + }, + "bucket_count": { + "type": "long" + }, + "exponential_average_bucket_processing_time_ms": { + "type": "long" + }, + "exponential_average_bucket_processing_time_per_hour_ms": { + "type": "long" + }, + "maximum_bucket_processing_time_ms": { + "type": "long" + }, + "minimum_bucket_processing_time_ms": { + "type": "long" + }, + "total_bucket_processing_time_ms": { + "type": "long" + } + } + }, + "datafeed": { + "properties": { + "datafeed_id": { + "type": "keyword" + }, + "state": { + "type": "keyword" + }, + "timing_stats": { + "properties": { + "average_search_time_per_bucket_ms": { + "type": "long" + }, + "bucket_count": { + "type": "long" + }, + "exponential_average_search_time_per_hour_ms": { + "type": "long" + }, + "search_count": { + "type": "long" + }, + "total_search_time_ms": { + "type": "long" + } + } + } + } + } + } + } + } + } + }, "endpoints": { "properties": { "total_installed": { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0355da0d89ed03..bbd050d38691d8 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3367,14 +3367,14 @@ "tileMap.geohashLayer.mapTitle": "{mapType} マップタイプが認識されません", "tileMap.tooltipFormatter.latitudeLabel": "緯度", "tileMap.tooltipFormatter.longitudeLabel": "経度", - "tileMap.vis.editorConfig.legendPositions.bottomLeftText": "左下", - "tileMap.vis.editorConfig.legendPositions.bottomRightText": "右下", - "tileMap.vis.editorConfig.legendPositions.topLeftText": "左上", - "tileMap.vis.editorConfig.legendPositions.topRightText": "右上", - "tileMap.vis.editorConfig.mapTypes.heatmapText": "ヒートマップ", - "tileMap.vis.editorConfig.mapTypes.scaledCircleMarkersText": "スケーリングされた円マーカー", - "tileMap.vis.editorConfig.mapTypes.shadedCircleMarkersText": "影付き円マーカー", - "tileMap.vis.editorConfig.mapTypes.shadedGeohashGridText": "影付きジオハッシュグリッド", + "tileMap.legendPositions.bottomLeftText": "左下", + "tileMap.legendPositions.bottomRightText": "右下", + "tileMap.legendPositions.topLeftText": "左上", + "tileMap.legendPositions.topRightText": "右上", + "tileMap.mapTypes.heatmapText": "ヒートマップ", + "tileMap.mapTypes.scaledCircleMarkersText": "スケーリングされた円マーカー", + "tileMap.mapTypes.shadedCircleMarkersText": "影付き円マーカー", + "tileMap.mapTypes.shadedGeohashGridText": "影付きジオハッシュグリッド", "tileMap.vis.map.editorConfig.schemas.geoCoordinatesTitle": "座標", "tileMap.vis.map.editorConfig.schemas.metricTitle": "値", "tileMap.vis.mapDescription": "マップ上に緯度と経度の座標を表示します。", @@ -3967,12 +3967,12 @@ "visTypeTagCloud.function.metric.help": "メトリックディメンションの構成です。", "visTypeTagCloud.function.orientation.help": "タグクラウド内の単語の方向です。", "visTypeTagCloud.function.scale.help": "単語のフォントサイズを決定するスケールです", - "visTypeTagCloud.vis.editorConfig.orientations.multipleText": "複数", - "visTypeTagCloud.vis.editorConfig.orientations.rightAngledText": "直角", - "visTypeTagCloud.vis.editorConfig.orientations.singleText": "単一", - "visTypeTagCloud.vis.editorConfig.scales.linearText": "線形", - "visTypeTagCloud.vis.editorConfig.scales.logText": "ログ", - "visTypeTagCloud.vis.editorConfig.scales.squareRootText": "平方根", + "visTypeTagCloud.orientations.multipleText": "複数", + "visTypeTagCloud.orientations.rightAngledText": "直角", + "visTypeTagCloud.orientations.singleText": "単一", + "visTypeTagCloud.scales.linearText": "線形", + "visTypeTagCloud.scales.logText": "ログ", + "visTypeTagCloud.scales.squareRootText": "平方根", "visTypeTagCloud.vis.schemas.metricTitle": "タグサイズ", "visTypeTagCloud.vis.schemas.segmentTitle": "タグ", "visTypeTagCloud.vis.tagCloudDescription": "単語の頻度とフォントサイズを表示します。", @@ -5104,7 +5104,6 @@ "xpack.apm.localFilters.titles.serviceName": "サービス名", "xpack.apm.localFilters.titles.serviceVersion": "サービスバージョン", "xpack.apm.localFilters.titles.transactionResult": "トランザクション結果", - "xpack.apm.localFilters.titles.transactionType": "トランザクションタイプ", "xpack.apm.localFilters.titles.transactionUrl": "Url", "xpack.apm.localFiltersTitle": "フィルター", "xpack.apm.metadataTable.section.agentLabel": "エージェント", @@ -11181,8 +11180,6 @@ "xpack.lens.dimensionContainer.close": "閉じる", "xpack.lens.dimensionContainer.closeConfiguration": "構成を閉じる", "xpack.lens.discover.visualizeFieldLegend": "Visualize フィールド", - "xpack.lens.dragDrop.elementLifted": "位置 {position} のアイテム {itemLabel} を持ち上げました。", - "xpack.lens.dragDrop.elementMoved": "位置 {prevPosition} から位置 {position} までアイテム {itemLabel} を移動しました", "xpack.lens.editLayerSettings": "レイヤー設定を編集", "xpack.lens.editLayerSettingsChartType": "レイヤー設定を編集、{chartType}", "xpack.lens.editorFrame.buildExpressionError": "グラフの準備中に予期しないエラーが発生しました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 97dfa9af723923..9175f94c48af8e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3371,14 +3371,14 @@ "tileMap.geohashLayer.mapTitle": "{mapType} 地图类型无法识别", "tileMap.tooltipFormatter.latitudeLabel": "纬度", "tileMap.tooltipFormatter.longitudeLabel": "经度", - "tileMap.vis.editorConfig.legendPositions.bottomLeftText": "左下方", - "tileMap.vis.editorConfig.legendPositions.bottomRightText": "右下方", - "tileMap.vis.editorConfig.legendPositions.topLeftText": "左上方", - "tileMap.vis.editorConfig.legendPositions.topRightText": "右上方", - "tileMap.vis.editorConfig.mapTypes.heatmapText": "热图", - "tileMap.vis.editorConfig.mapTypes.scaledCircleMarkersText": "缩放式圆形标记", - "tileMap.vis.editorConfig.mapTypes.shadedCircleMarkersText": "带阴影圆形标记", - "tileMap.vis.editorConfig.mapTypes.shadedGeohashGridText": "带阴影 geohash 网格", + "tileMap.legendPositions.bottomLeftText": "左下方", + "tileMap.legendPositions.bottomRightText": "右下方", + "tileMap.legendPositions.topLeftText": "左上方", + "tileMap.legendPositions.topRightText": "右上方", + "tileMap.mapTypes.heatmapText": "热图", + "tileMap.mapTypes.scaledCircleMarkersText": "缩放式圆形标记", + "tileMap.mapTypes.shadedCircleMarkersText": "带阴影圆形标记", + "tileMap.mapTypes.shadedGeohashGridText": "带阴影 geohash 网格", "tileMap.vis.map.editorConfig.schemas.geoCoordinatesTitle": "地理坐标", "tileMap.vis.map.editorConfig.schemas.metricTitle": "值", "tileMap.vis.mapDescription": "在地图上绘制纬度和经度坐标", @@ -3971,12 +3971,12 @@ "visTypeTagCloud.function.metric.help": "指标维度配置", "visTypeTagCloud.function.orientation.help": "标签云图内的字方向", "visTypeTagCloud.function.scale.help": "缩放以确定字体大小", - "visTypeTagCloud.vis.editorConfig.orientations.multipleText": "多个", - "visTypeTagCloud.vis.editorConfig.orientations.rightAngledText": "直角", - "visTypeTagCloud.vis.editorConfig.orientations.singleText": "单个", - "visTypeTagCloud.vis.editorConfig.scales.linearText": "线性", - "visTypeTagCloud.vis.editorConfig.scales.logText": "对数", - "visTypeTagCloud.vis.editorConfig.scales.squareRootText": "平方根", + "visTypeTagCloud.orientations.multipleText": "多个", + "visTypeTagCloud.orientations.rightAngledText": "直角", + "visTypeTagCloud.orientations.singleText": "单个", + "visTypeTagCloud.scales.linearText": "线性", + "visTypeTagCloud.scales.logText": "对数", + "visTypeTagCloud.scales.squareRootText": "平方根", "visTypeTagCloud.vis.schemas.metricTitle": "标签大小", "visTypeTagCloud.vis.schemas.segmentTitle": "标签", "visTypeTagCloud.vis.tagCloudDescription": "使用字体大小显示词频。", @@ -5112,7 +5112,6 @@ "xpack.apm.localFilters.titles.serviceName": "服务名称", "xpack.apm.localFilters.titles.serviceVersion": "服务版本", "xpack.apm.localFilters.titles.transactionResult": "事务结果", - "xpack.apm.localFilters.titles.transactionType": "事务类型", "xpack.apm.localFilters.titles.transactionUrl": "URL", "xpack.apm.localFiltersTitle": "筛选", "xpack.apm.metadataTable.section.agentLabel": "代理", @@ -11210,8 +11209,6 @@ "xpack.lens.dimensionContainer.close": "关闭", "xpack.lens.dimensionContainer.closeConfiguration": "关闭配置", "xpack.lens.discover.visualizeFieldLegend": "可视化字段", - "xpack.lens.dragDrop.elementLifted": "您已将项目 {itemLabel} 提升到位置 {position}", - "xpack.lens.dragDrop.elementMoved": "您已将项目 {itemLabel} 从位置 {prevPosition} 移到位置 {position}", "xpack.lens.editLayerSettings": "编辑图层设置", "xpack.lens.editLayerSettingsChartType": "编辑图层设置 {chartType}", "xpack.lens.editorFrame.buildExpressionError": "准备图表时发生意外错误", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx index 85f3818484a130..0a59cff98ce26b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx @@ -10,6 +10,7 @@ import { Switch, Route, Redirect, Router } from 'react-router-dom'; import { ChromeBreadcrumb, CoreStart, ScopedHistory } from 'kibana/public'; import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; +import useObservable from 'react-use/lib/useObservable'; import { KibanaFeature } from '../../../features/common'; import { Section, routeToAlertDetails } from './constants'; import { ActionTypeRegistryContract, AlertTypeRegistryContract } from '../types'; @@ -18,6 +19,7 @@ import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; import { PluginStartContract as AlertingStart } from '../../../alerts/public'; import { suspendedComponentWithProps } from './lib/suspended_component_with_props'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; +import { EuiThemeProvider } from '../../../../../src/plugins/kibana_react/common'; import { setSavedObjectsClient } from '../common/lib/data_apis'; import { KibanaContextProvider } from '../common/lib/kibana'; @@ -41,25 +43,31 @@ export interface TriggersAndActionsUiServices extends CoreStart { } export const renderApp = (deps: TriggersAndActionsUiServices) => { - const { element, savedObjects } = deps; + const { element } = deps; + render(, element); + return () => { + unmountComponentAtNode(element); + }; +}; + +export const App = ({ deps }: { deps: TriggersAndActionsUiServices }) => { + const { savedObjects, uiSettings } = deps; const sections: Section[] = ['alerts', 'connectors']; + const isDarkMode = useObservable(uiSettings.get$('theme:darkMode')); const sectionsRegex = sections.join('|'); setSavedObjectsClient(savedObjects.client); - - render( + return ( - - - - - - , - element + + + + + + + + ); - return () => { - unmountComponentAtNode(element); - }; }; export const AppWithoutRouter = ({ sectionsRegex }: { sectionsRegex: string }) => { diff --git a/x-pack/test/accessibility/apps/kibana_overview.ts b/x-pack/test/accessibility/apps/kibana_overview.ts index 395da78f6049c6..068b600d2adf2f 100644 --- a/x-pack/test/accessibility/apps/kibana_overview.ts +++ b/x-pack/test/accessibility/apps/kibana_overview.ts @@ -11,8 +11,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'home']); const a11y = getService('a11y'); - // FLAKY: https://github.com/elastic/kibana/issues/82226 - describe.skip('Kibana overview', () => { + describe('Kibana overview', () => { const esArchiver = getService('esArchiver'); before(async () => { diff --git a/x-pack/test/accessibility/apps/lens.ts b/x-pack/test/accessibility/apps/lens.ts index 229bc76a229ee0..2f5ebe3c1a2dc6 100644 --- a/x-pack/test/accessibility/apps/lens.ts +++ b/x-pack/test/accessibility/apps/lens.ts @@ -44,8 +44,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.configureDimension({ dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', - operation: 'date_histogram', - field: 'timestamp', + operation: 'terms', + field: 'DestCityName', }); await PageObjects.lens.configureDimension({ diff --git a/x-pack/test/accessibility/apps/ml.ts b/x-pack/test/accessibility/apps/ml.ts index baa5e9df617686..0dbc7cbb041d75 100644 --- a/x-pack/test/accessibility/apps/ml.ts +++ b/x-pack/test/accessibility/apps/ml.ts @@ -235,7 +235,9 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsResults.assertOutlierTablePanelExists(); await ml.dataFrameAnalyticsResults.assertResultsTableExists(); await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); - await a11y.testAppSnapshot(); + // EuiDataGrid does not have row roles + // https://github.com/elastic/eui/issues/4471 + // await a11y.testAppSnapshot(); }); it('data frame analytics create job select index pattern modal', async () => { @@ -251,7 +253,9 @@ export default function ({ getService }: FtrProviderContext) { ); await ml.jobSourceSelection.selectSourceForAnalyticsJob(ihpIndexPattern); await ml.dataFrameAnalyticsCreation.assertConfigurationStepActive(); - await a11y.testAppSnapshot(); + // EuiDataGrid does not have row roles + // https://github.com/elastic/eui/issues/4471 + // await a11y.testAppSnapshot(); }); it('data frame analytics create job configuration step for outlier job', async () => { @@ -264,7 +268,9 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.enableSourceDataPreviewHistogramCharts(); await ml.testExecution.logTestStep('displays the include fields selection'); await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); - await a11y.testAppSnapshot(); + // EuiDataGrid does not have row roles + // https://github.com/elastic/eui/issues/4471 + // await a11y.testAppSnapshot(); }); it('data frame analytics create job additional options step for outlier job', async () => { diff --git a/x-pack/test/accessibility/apps/uptime.ts b/x-pack/test/accessibility/apps/uptime.ts index ec1f37ca02be2f..d7a9cfc0d08b40 100644 --- a/x-pack/test/accessibility/apps/uptime.ts +++ b/x-pack/test/accessibility/apps/uptime.ts @@ -18,7 +18,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const es = getService('es'); - describe('uptime', () => { + // FLAKY: https://github.com/elastic/kibana/issues/90555 + describe.skip('uptime', () => { before(async () => { await esArchiver.load('uptime/blank'); await makeChecks(es, A11Y_TEST_MONITOR_ID, 150, 1, 1000, { diff --git a/x-pack/test/api_integration/apis/management/index_management/indices.js b/x-pack/test/api_integration/apis/management/index_management/indices.js index cef1bdbba754b8..3653d9916466d0 100644 --- a/x-pack/test/api_integration/apis/management/index_management/indices.js +++ b/x-pack/test/api_integration/apis/management/index_management/indices.js @@ -34,7 +34,8 @@ export default function ({ getService }) { clearCache, } = registerHelpers({ supertest }); - describe('indices', () => { + // Failing: See https://github.com/elastic/kibana/issues/64473 + describe.skip('indices', () => { after(() => Promise.all([cleanUpEsResources()])); describe('clear cache', () => { diff --git a/x-pack/test/api_integration/apis/search/session.ts b/x-pack/test/api_integration/apis/search/session.ts index 28b63788a8cfb9..984f3e3f7dd4e6 100644 --- a/x-pack/test/api_integration/apis/search/session.ts +++ b/x-pack/test/api_integration/apis/search/session.ts @@ -45,11 +45,11 @@ export default function ({ getService }: FtrProviderContext) { await supertest.get(`/internal/session/${sessionId}`).set('kbn-xsrf', 'foo').expect(200); }); - it('should fail to cancel an unknown session', async () => { + it('should fail to delete an unknown session', async () => { await supertest.delete(`/internal/session/123`).set('kbn-xsrf', 'foo').expect(404); }); - it('should create and cancel a session', async () => { + it('should create and delete a session', async () => { const sessionId = `my-session-${Math.random()}`; await supertest .post(`/internal/session`) @@ -65,6 +65,28 @@ export default function ({ getService }: FtrProviderContext) { await supertest.delete(`/internal/session/${sessionId}`).set('kbn-xsrf', 'foo').expect(200); + await supertest.get(`/internal/session/${sessionId}`).set('kbn-xsrf', 'foo').expect(404); + }); + + it('should create and cancel a session', async () => { + const sessionId = `my-session-${Math.random()}`; + await supertest + .post(`/internal/session`) + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(200); + + await supertest + .post(`/internal/session/${sessionId}/cancel`) + .set('kbn-xsrf', 'foo') + .expect(200); + const resp = await supertest .get(`/internal/session/${sessionId}`) .set('kbn-xsrf', 'foo') diff --git a/x-pack/test/api_integration/apis/security_solution/timeline_details.ts b/x-pack/test/api_integration/apis/security_solution/timeline_details.ts index c204ec3b28cf0d..2705406009062d 100644 --- a/x-pack/test/api_integration/apis/security_solution/timeline_details.ts +++ b/x-pack/test/api_integration/apis/security_solution/timeline_details.ts @@ -16,464 +16,373 @@ const INDEX_NAME = 'filebeat-7.0.0-iot-2019.06'; const ID = 'QRhG1WgBqd-n62SwZYDT'; const EXPECTED_DATA = [ { - category: 'file', - field: 'file.path', - values: [ - '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', - ], - originalValue: [ - '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', - ], - }, - { - category: 'traefik', - field: 'traefik.access.geoip.region_iso_code', - values: ['US-WA'], - originalValue: ['US-WA'], - }, - { - category: 'host', - field: 'host.hostname', - values: ['raspberrypi'], - originalValue: ['raspberrypi'], - }, - { - category: 'traefik', - field: 'traefik.access.geoip.location', - values: ['{"long":-122.3341,"lat":47.6103}'], - originalValue: ['{"coordinates":[-122.3341,47.6103],"type":"Point"}'], + category: 'base', + field: '@timestamp', + values: ['2019-02-10T02:39:44.107Z'], + originalValue: '2019-02-10T02:39:44.107Z', }, { - category: 'suricata', - field: 'suricata.eve.src_port', - values: ['80'], - originalValue: ['80'], + category: '@version', + field: '@version', + values: ['1'], + originalValue: '1', }, { - category: 'traefik', - field: 'traefik.access.geoip.city_name', - values: ['Seattle'], - originalValue: ['Seattle'], + category: 'agent', + field: 'agent.ephemeral_id', + values: ['909cd6a1-527d-41a5-9585-a7fb5386f851'], + originalValue: '909cd6a1-527d-41a5-9585-a7fb5386f851', }, { - category: 'service', - field: 'service.type', - values: ['suricata'], - originalValue: ['suricata'], + category: 'agent', + field: 'agent.hostname', + values: ['raspberrypi'], + originalValue: 'raspberrypi', }, { - category: 'http', - field: 'http.request.method', - values: ['get'], - originalValue: ['get'], + category: 'agent', + field: 'agent.id', + values: ['4d3ea604-27e5-4ec7-ab64-44f82285d776'], + originalValue: '4d3ea604-27e5-4ec7-ab64-44f82285d776', }, { - category: 'host', - field: 'host.os.version', - values: ['9 (stretch)'], - originalValue: ['9 (stretch)'], + category: 'agent', + field: 'agent.type', + values: ['filebeat'], + originalValue: 'filebeat', }, { - category: 'source', - field: 'source.geo.region_name', - values: ['Washington'], - originalValue: ['Washington'], + category: 'agent', + field: 'agent.version', + values: ['7.0.0'], + originalValue: '7.0.0', }, { - category: 'suricata', - field: 'suricata.eve.http.protocol', - values: ['HTTP/1.1'], - originalValue: ['HTTP/1.1'], + category: 'destination', + field: 'destination.domain', + values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], + originalValue: 's3-iad-2.cf.dash.row.aiv-cdn.net', }, { - category: 'host', - field: 'host.os.name', - values: ['Raspbian GNU/Linux'], - originalValue: ['Raspbian GNU/Linux'], + category: 'destination', + field: 'destination.ip', + values: ['10.100.7.196'], + originalValue: '10.100.7.196', }, { - category: 'source', - field: 'source.ip', - values: ['54.239.219.210'], - originalValue: ['54.239.219.210'], + category: 'destination', + field: 'destination.port', + values: [40684], + originalValue: 40684, }, { - category: 'host', - field: 'host.name', - values: ['raspberrypi'], - originalValue: ['raspberrypi'], + category: 'ecs', + field: 'ecs.version', + values: ['1.0.0-beta2'], + originalValue: '1.0.0-beta2', }, { - category: 'source', - field: 'source.geo.region_iso_code', - values: ['US-WA'], - originalValue: ['US-WA'], + category: 'event', + field: 'event.dataset', + values: ['suricata.eve'], + originalValue: 'suricata.eve', }, { - category: 'http', - field: 'http.response.status_code', - values: ['206'], - originalValue: ['206'], + category: 'event', + field: 'event.end', + values: ['2019-02-10T02:39:44.107Z'], + originalValue: '2019-02-10T02:39:44.107Z', }, { category: 'event', field: 'event.kind', values: ['event'], - originalValue: ['event'], + originalValue: 'event', }, { - category: 'suricata', - field: 'suricata.eve.flow_id', - values: ['196625917175466'], - originalValue: ['196625917175466'], - }, - { - category: 'source', - field: 'source.geo.city_name', - values: ['Seattle'], - originalValue: ['Seattle'], + category: 'event', + field: 'event.module', + values: ['suricata'], + originalValue: 'suricata', }, { - category: 'suricata', - field: 'suricata.eve.proto', - values: ['tcp'], - originalValue: ['tcp'], + category: 'event', + field: 'event.type', + values: ['fileinfo'], + originalValue: 'fileinfo', }, { - category: 'flow', - field: 'flow.locality', - values: ['public'], - originalValue: ['public'], + category: 'file', + field: 'file.path', + values: [ + '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], + originalValue: + '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', }, { - category: 'traefik', - field: 'traefik.access.geoip.country_iso_code', - values: ['US'], - originalValue: ['US'], + category: 'file', + field: 'file.size', + values: [48277], + originalValue: 48277, }, { category: 'fileset', field: 'fileset.name', values: ['eve'], - originalValue: ['eve'], + originalValue: 'eve', }, { - category: 'input', - field: 'input.type', - values: ['log'], - originalValue: ['log'], - }, - { - category: 'log', - field: 'log.offset', - values: ['1856288115'], - originalValue: ['1856288115'], + category: 'flow', + field: 'flow.locality', + values: ['public'], + originalValue: 'public', }, { - category: 'destination', - field: 'destination.domain', - values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], - originalValue: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], + category: 'host', + field: 'host.architecture', + values: ['armv7l'], + originalValue: 'armv7l', }, { - category: 'agent', - field: 'agent.hostname', + category: 'host', + field: 'host.hostname', values: ['raspberrypi'], - originalValue: ['raspberrypi'], - }, - { - category: 'suricata', - field: 'suricata.eve.http.hostname', - values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], - originalValue: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], + originalValue: 'raspberrypi', }, { - category: 'suricata', - field: 'suricata.eve.in_iface', - values: ['eth0'], - originalValue: ['eth0'], - }, - { - category: 'base', - field: 'tags', - values: ['suricata'], - originalValue: ['suricata'], + category: 'host', + field: 'host.id', + values: ['b19a781f683541a7a25ee345133aa399'], + originalValue: 'b19a781f683541a7a25ee345133aa399', }, { category: 'host', - field: 'host.architecture', - values: ['armv7l'], - originalValue: ['armv7l'], + field: 'host.name', + values: ['raspberrypi'], + originalValue: 'raspberrypi', }, { - category: 'suricata', - field: 'suricata.eve.http.status', - values: ['206'], - originalValue: ['206'], + category: 'host', + field: 'host.os.codename', + values: ['stretch'], + originalValue: 'stretch', }, { - category: 'suricata', - field: 'suricata.eve.http.url', - values: [ - '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', - ], - originalValue: [ - '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', - ], + category: 'host', + field: 'host.os.family', + values: [''], + originalValue: '', }, { - category: 'url', - field: 'url.path', - values: [ - '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', - ], - originalValue: [ - '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', - ], + category: 'host', + field: 'host.os.kernel', + values: ['4.14.50-v7+'], + originalValue: '4.14.50-v7+', }, { - category: 'source', - field: 'source.port', - values: ['80'], - originalValue: ['80'], + category: 'host', + field: 'host.os.name', + values: ['Raspbian GNU/Linux'], + originalValue: 'Raspbian GNU/Linux', }, { - category: 'agent', - field: 'agent.id', - values: ['4d3ea604-27e5-4ec7-ab64-44f82285d776'], - originalValue: ['4d3ea604-27e5-4ec7-ab64-44f82285d776'], + category: 'host', + field: 'host.os.platform', + values: ['raspbian'], + originalValue: 'raspbian', }, { category: 'host', - field: 'host.containerized', - values: ['false'], - originalValue: ['false'], + field: 'host.os.version', + values: ['9 (stretch)'], + originalValue: '9 (stretch)', }, { - category: 'ecs', - field: 'ecs.version', - values: ['1.0.0-beta2'], - originalValue: ['1.0.0-beta2'], + category: 'http', + field: 'http.request.method', + values: ['get'], + originalValue: 'get', }, { - category: 'agent', - field: 'agent.version', - values: ['7.0.0'], - originalValue: ['7.0.0'], + category: 'http', + field: 'http.response.body.bytes', + values: [48277], + originalValue: 48277, }, { - category: 'suricata', - field: 'suricata.eve.fileinfo.stored', - values: ['false'], - originalValue: ['false'], + category: 'http', + field: 'http.response.status_code', + values: [206], + originalValue: 206, }, { - category: 'host', - field: 'host.os.family', - values: [''], - originalValue: [''], + category: 'input', + field: 'input.type', + values: ['log'], + originalValue: 'log', }, { category: 'base', field: 'labels.pipeline', values: ['filebeat-7.0.0-suricata-eve-pipeline'], - originalValue: ['filebeat-7.0.0-suricata-eve-pipeline'], + originalValue: 'filebeat-7.0.0-suricata-eve-pipeline', }, { - category: 'suricata', - field: 'suricata.eve.src_ip', - values: ['54.239.219.210'], - originalValue: ['54.239.219.210'], + category: 'log', + field: 'log.file.path', + values: ['/var/log/suricata/eve.json'], + originalValue: '/var/log/suricata/eve.json', }, { - category: 'suricata', - field: 'suricata.eve.fileinfo.state', - values: ['CLOSED'], - originalValue: ['CLOSED'], + category: 'log', + field: 'log.offset', + values: [1856288115], + originalValue: 1856288115, }, { - category: 'destination', - field: 'destination.port', - values: ['40684'], - originalValue: ['40684'], + category: 'network', + field: 'network.name', + values: ['iot'], + originalValue: 'iot', }, { - category: 'traefik', - field: 'traefik.access.geoip.region_name', - values: ['Washington'], - originalValue: ['Washington'], + category: 'network', + field: 'network.protocol', + values: ['http'], + originalValue: 'http', }, { - category: 'source', - field: 'source.as.num', - values: ['16509'], - originalValue: ['16509'], + category: 'network', + field: 'network.transport', + values: ['tcp'], + originalValue: 'tcp', }, { - category: 'event', - field: 'event.end', - values: ['2019-02-10T02:39:44.107Z'], - originalValue: ['2019-02-10T02:39:44.107Z'], + category: 'service', + field: 'service.type', + values: ['suricata'], + originalValue: 'suricata', }, { category: 'source', - field: 'source.geo.location', - values: ['{"long":-122.3341,"lat":47.6103}'], - originalValue: ['{"coordinates":[-122.3341,47.6103],"type":"Point"}'], + field: 'source.as.num', + values: [16509], + originalValue: 16509, }, { category: 'source', - field: 'source.domain', - values: ['server-54-239-219-210.jfk51.r.cloudfront.net'], - originalValue: ['server-54-239-219-210.jfk51.r.cloudfront.net'], - }, - { - category: 'suricata', - field: 'suricata.eve.fileinfo.size', - values: ['48277'], - originalValue: ['48277'], - }, - { - category: 'suricata', - field: 'suricata.eve.app_proto', - values: ['http'], - originalValue: ['http'], - }, - { - category: 'agent', - field: 'agent.type', - values: ['filebeat'], - originalValue: ['filebeat'], - }, - { - category: 'suricata', - field: 'suricata.eve.fileinfo.tx_id', - values: ['301'], - originalValue: ['301'], + field: 'source.as.org', + values: ['Amazon.com, Inc.'], + originalValue: 'Amazon.com, Inc.', }, { - category: 'event', - field: 'event.module', - values: ['suricata'], - originalValue: ['suricata'], + category: 'source', + field: 'source.domain', + values: ['server-54-239-219-210.jfk51.r.cloudfront.net'], + originalValue: 'server-54-239-219-210.jfk51.r.cloudfront.net', }, { - category: 'network', - field: 'network.protocol', - values: ['http'], - originalValue: ['http'], + category: 'source', + field: 'source.geo.city_name', + values: ['Seattle'], + originalValue: 'Seattle', }, { - category: 'host', - field: 'host.os.kernel', - values: ['4.14.50-v7+'], - originalValue: ['4.14.50-v7+'], + category: 'source', + field: 'source.geo.continent_name', + values: ['North America'], + originalValue: 'North America', }, { category: 'source', field: 'source.geo.country_iso_code', values: ['US'], - originalValue: ['US'], + originalValue: 'US', }, { - category: '@version', - field: '@version', - values: ['1'], - originalValue: ['1'], - }, - { - category: 'host', - field: 'host.id', - values: ['b19a781f683541a7a25ee345133aa399'], - originalValue: ['b19a781f683541a7a25ee345133aa399'], + category: 'source', + field: 'source.geo.location.lat', + values: [47.6103], + originalValue: 47.6103, }, { category: 'source', - field: 'source.as.org', - values: ['Amazon.com, Inc.'], - originalValue: ['Amazon.com, Inc.'], + field: 'source.geo.location.lon', + values: [-122.3341], + originalValue: -122.3341, }, { - category: 'suricata', - field: 'suricata.eve.timestamp', - values: ['2019-02-10T02:39:44.107Z'], - originalValue: ['2019-02-10T02:39:44.107Z'], + category: 'source', + field: 'source.geo.region_iso_code', + values: ['US-WA'], + originalValue: 'US-WA', }, { - category: 'host', - field: 'host.os.codename', - values: ['stretch'], - originalValue: ['stretch'], + category: 'source', + field: 'source.geo.region_name', + values: ['Washington'], + originalValue: 'Washington', }, { category: 'source', - field: 'source.geo.continent_name', - values: ['North America'], - originalValue: ['North America'], + field: 'source.ip', + values: ['54.239.219.210'], + originalValue: '54.239.219.210', }, { - category: 'network', - field: 'network.name', - values: ['iot'], - originalValue: ['iot'], + category: 'source', + field: 'source.port', + values: [80], + originalValue: 80, }, { category: 'suricata', - field: 'suricata.eve.http.http_method', - values: ['get'], - originalValue: ['get'], - }, - { - category: 'traefik', - field: 'traefik.access.geoip.continent_name', - values: ['North America'], - originalValue: ['North America'], + field: 'suricata.eve.fileinfo.state', + values: ['CLOSED'], + originalValue: 'CLOSED', }, { - category: 'file', - field: 'file.size', - values: ['48277'], - originalValue: ['48277'], + category: 'suricata', + field: 'suricata.eve.fileinfo.tx_id', + values: [301], + originalValue: 301, }, { - category: 'destination', - field: 'destination.ip', - values: ['10.100.7.196'], - originalValue: ['10.100.7.196'], + category: 'suricata', + field: 'suricata.eve.flow_id', + values: [196625917175466], + originalValue: 196625917175466, }, { category: 'suricata', - field: 'suricata.eve.http.length', - values: ['48277'], - originalValue: ['48277'], + field: 'suricata.eve.http.http_content_type', + values: ['video/mp4'], + originalValue: 'video/mp4', }, { - category: 'http', - field: 'http.response.body.bytes', - values: ['48277'], - originalValue: ['48277'], + category: 'suricata', + field: 'suricata.eve.http.protocol', + values: ['HTTP/1.1'], + originalValue: 'HTTP/1.1', }, { category: 'suricata', - field: 'suricata.eve.fileinfo.filename', - values: [ - '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', - ], - originalValue: [ - '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', - ], + field: 'suricata.eve.in_iface', + values: ['eth0'], + originalValue: 'eth0', }, { - category: 'suricata', - field: 'suricata.eve.dest_ip', - values: ['10.100.7.196'], - originalValue: ['10.100.7.196'], + category: 'base', + field: 'tags', + values: ['suricata'], + originalValue: ['suricata'], }, { - category: 'network', - field: 'network.transport', - values: ['tcp'], - originalValue: ['tcp'], + category: 'url', + field: 'url.domain', + values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], + originalValue: 's3-iad-2.cf.dash.row.aiv-cdn.net', }, { category: 'url', @@ -481,81 +390,35 @@ const EXPECTED_DATA = [ values: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', ], - originalValue: [ + originalValue: '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', - ], - }, - { - category: 'base', - field: '@timestamp', - values: ['2019-02-10T02:39:44.107Z'], - originalValue: ['2019-02-10T02:39:44.107Z'], - }, - { - category: 'host', - field: 'host.os.platform', - values: ['raspbian'], - originalValue: ['raspbian'], - }, - { - category: 'suricata', - field: 'suricata.eve.dest_port', - values: ['40684'], - originalValue: ['40684'], - }, - { - category: 'event', - field: 'event.type', - values: ['fileinfo'], - originalValue: ['fileinfo'], - }, - { - category: 'log', - field: 'log.file.path', - values: ['/var/log/suricata/eve.json'], - originalValue: ['/var/log/suricata/eve.json'], }, { category: 'url', - field: 'url.domain', - values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], - originalValue: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], - }, - { - category: 'agent', - field: 'agent.ephemeral_id', - values: ['909cd6a1-527d-41a5-9585-a7fb5386f851'], - originalValue: ['909cd6a1-527d-41a5-9585-a7fb5386f851'], - }, - { - category: 'suricata', - field: 'suricata.eve.http.http_content_type', - values: ['video/mp4'], - originalValue: ['video/mp4'], - }, - { - category: 'event', - field: 'event.dataset', - values: ['suricata.eve'], - originalValue: ['suricata.eve'], + field: 'url.path', + values: [ + '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], + originalValue: + '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', }, { category: '_index', field: '_index', values: ['filebeat-7.0.0-iot-2019.06'], - originalValue: ['filebeat-7.0.0-iot-2019.06'], + originalValue: 'filebeat-7.0.0-iot-2019.06', }, { category: '_id', field: '_id', values: ['QRhG1WgBqd-n62SwZYDT'], - originalValue: ['QRhG1WgBqd-n62SwZYDT'], + originalValue: 'QRhG1WgBqd-n62SwZYDT', }, { category: '_score', field: '_score', - values: ['1'], - originalValue: ['1'], + values: [1], + originalValue: 1, }, ]; @@ -589,12 +452,8 @@ export default function ({ getService }: FtrProviderContext) { eventId: ID, }) .expect(200); - expect( - sortBy(detailsData, 'name').map((item) => { - const { __typename, ...rest } = item; - return rest; - }) - ).to.eql(sortBy(EXPECTED_DATA, 'name')); + + expect(sortBy(detailsData, 'name')).to.eql(sortBy(EXPECTED_DATA, 'name')); }); it('Make sure that we get kpi data', async () => { diff --git a/x-pack/test/api_integration/apis/security_solution/users.ts b/x-pack/test/api_integration/apis/security_solution/users.ts index 178bb3810b087c..45e06ab72adbb9 100644 --- a/x-pack/test/api_integration/apis/security_solution/users.ts +++ b/x-pack/test/api_integration/apis/security_solution/users.ts @@ -22,7 +22,8 @@ const IP = '0.0.0.0'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); - describe('Users', () => { + // Failing: See https://github.com/elastic/kibana/issues/90135 + describe.skip('Users', () => { describe('With auditbeat', () => { before(() => esArchiver.load('auditbeat/default')); after(() => esArchiver.unload('auditbeat/default')); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts index b1d6a13b773002..1ae6aa80b219f4 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts @@ -34,6 +34,8 @@ import { createExceptionListItem, waitForSignalsToBePresent, getSignalsByIds, + findImmutableRuleById, + getPrePackagedRulesStatus, } from '../../utils'; // eslint-disable-next-line import/no-default-export @@ -394,6 +396,83 @@ export default ({ getService }: FtrProviderContext) => { ]); }); + it('should not change the immutable tags when adding a second exception list to an immutable rule through patch', async () => { + await installPrePackagedRules(supertest); + + const { id, list_id, namespace_type, type } = await createExceptionList( + supertest, + getCreateExceptionListMinimalSchemaMock() + ); + + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json + // This rule has an existing exceptions_list that we are going to use + const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + expect(immutableRule.exceptions_list.length).greaterThan(0); // make sure we have at least one + + // add a second exceptions list as a user is allowed to add a second list to an immutable rule + await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ + rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', + exceptions_list: [ + ...immutableRule.exceptions_list, + { + id, + list_id, + namespace_type, + type, + }, + ], + }) + .expect(200); + + const body = await findImmutableRuleById(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + expect(body.data.length).to.eql(1); // should have only one length to the data set, otherwise we have duplicates or the tags were removed and that is incredibly bad. + + const bodyToCompare = removeServerGeneratedProperties(body.data[0]); + expect(bodyToCompare.rule_id).to.eql(immutableRule.rule_id); // Rule id should not change with a a patch + expect(bodyToCompare.immutable).to.eql(immutableRule.immutable); // Immutable should always stay the same which is true and never flip to false. + expect(bodyToCompare.version).to.eql(immutableRule.version); // The version should never update on a patch + }); + + it('should not change count of prepacked rules when adding a second exception list to an immutable rule through patch. If this fails, suspect the immutable tags are not staying on the rule correctly.', async () => { + await installPrePackagedRules(supertest); + + const { id, list_id, namespace_type, type } = await createExceptionList( + supertest, + getCreateExceptionListMinimalSchemaMock() + ); + + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json + // This rule has an existing exceptions_list that we are going to use + const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + expect(immutableRule.exceptions_list.length).greaterThan(0); // make sure we have at least one + + // add a second exceptions list as a user is allowed to add a second list to an immutable rule + await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ + rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', + exceptions_list: [ + ...immutableRule.exceptions_list, + { + id, + list_id, + namespace_type, + type, + }, + ], + }) + .expect(200); + + const status = await getPrePackagedRulesStatus(supertest); + expect(status.rules_not_installed).to.eql(0); + }); + describe('tests with auditbeat data', () => { beforeEach(async () => { await createSignalsIndex(supertest); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts index 7f299fc5801388..b6d88b657f25c1 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts @@ -14,6 +14,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { this.tags('ciGroup11'); loadTestFile(require.resolve('./add_actions')); + loadTestFile(require.resolve('./update_actions')); loadTestFile(require.resolve('./add_prepackaged_rules')); loadTestFile(require.resolve('./create_rules')); loadTestFile(require.resolve('./create_rules_bulk')); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_actions.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_actions.ts new file mode 100644 index 00000000000000..257c6a4286982d --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_actions.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { CreateRulesSchema } from '../../../../plugins/security_solution/common/detection_engine/schemas/request'; +import { DETECTION_ENGINE_RULES_URL } from '../../../../plugins/security_solution/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + removeServerGeneratedProperties, + getRuleWithWebHookAction, + getSimpleRuleOutputWithWebHookAction, + waitForRuleSuccessOrStatus, + createRule, + getSimpleRule, + updateRule, + installPrePackagedRules, + getRule, + createNewAction, + findImmutableRuleById, + getPrePackagedRulesStatus, +} from '../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('update_actions', () => { + describe('updating actions', () => { + beforeEach(async () => { + await esArchiver.load('auditbeat/hosts'); + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload('auditbeat/hosts'); + }); + + it('should be able to create a new webhook action and update a rule with the webhook action', async () => { + const hookAction = await createNewAction(supertest); + const rule = getSimpleRule(); + await createRule(supertest, rule); + const ruleToUpdate = getRuleWithWebHookAction(hookAction.id, false, rule); + const updatedRule = await updateRule(supertest, ruleToUpdate); + const bodyToCompare = removeServerGeneratedProperties(updatedRule); + + const expected = { + ...getSimpleRuleOutputWithWebHookAction(`${bodyToCompare.actions?.[0].id}`), + version: 2, // version bump is required since this is an updated rule and this is part of the testing that we do bump the version number on update + }; + expect(bodyToCompare).to.eql(expected); + }); + + it('should be able to create a new webhook action and attach it to a rule without a meta field and run it correctly', async () => { + const hookAction = await createNewAction(supertest); + const rule = getSimpleRule(); + await createRule(supertest, rule); + const ruleToUpdate = getRuleWithWebHookAction(hookAction.id, true, rule); + const updatedRule = await updateRule(supertest, ruleToUpdate); + await waitForRuleSuccessOrStatus(supertest, updatedRule.id); + + // expected result for status should be 'succeeded' + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_find_statuses`) + .set('kbn-xsrf', 'true') + .send({ ids: [updatedRule.id] }) + .expect(200); + expect(body[updatedRule.id].current_status.status).to.eql('succeeded'); + }); + + it('should be able to create a new webhook action and attach it to a rule with a meta field and run it correctly', async () => { + const hookAction = await createNewAction(supertest); + const rule = getSimpleRule(); + await createRule(supertest, rule); + const ruleToUpdate: CreateRulesSchema = { + ...getRuleWithWebHookAction(hookAction.id, true, rule), + meta: {}, // create a rule with the action attached and a meta field + }; + const updatedRule = await updateRule(supertest, ruleToUpdate); + await waitForRuleSuccessOrStatus(supertest, updatedRule.id); + + // expected result for status should be 'succeeded' + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_find_statuses`) + .set('kbn-xsrf', 'true') + .send({ ids: [updatedRule.id] }) + .expect(200); + expect(body[updatedRule.id].current_status.status).to.eql('succeeded'); + }); + + it('should be able to create a new webhook action and attach it to an immutable rule', async () => { + await installPrePackagedRules(supertest); + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json + const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + const hookAction = await createNewAction(supertest); + const newRuleToUpdate = getSimpleRule(immutableRule.rule_id); + const ruleToUpdate = getRuleWithWebHookAction(hookAction.id, false, newRuleToUpdate); + const updatedRule = await updateRule(supertest, ruleToUpdate); + const bodyToCompare = removeServerGeneratedProperties(updatedRule); + + const expected = { + ...getSimpleRuleOutputWithWebHookAction(`${bodyToCompare.actions?.[0].id}`), + rule_id: immutableRule.rule_id, // Rule id should match the same as the immutable rule + version: immutableRule.version, // This version number should not change when an immutable rule is updated + immutable: true, // It should stay immutable true when returning + }; + expect(bodyToCompare).to.eql(expected); + }); + + it('should be able to create a new webhook action, attach it to an immutable rule and the count of prepackaged rules should not increase. If this fails, suspect the immutable tags are not staying on the rule correctly.', async () => { + await installPrePackagedRules(supertest); + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json + const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + const hookAction = await createNewAction(supertest); + const newRuleToUpdate = getSimpleRule(immutableRule.rule_id); + const ruleToUpdate = getRuleWithWebHookAction(hookAction.id, false, newRuleToUpdate); + await updateRule(supertest, ruleToUpdate); + + const status = await getPrePackagedRulesStatus(supertest); + expect(status.rules_not_installed).to.eql(0); + }); + + it('should be able to create a new webhook action, attach it to an immutable rule and the rule should stay immutable when searching against immutable tags', async () => { + await installPrePackagedRules(supertest); + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json + const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + const hookAction = await createNewAction(supertest); + const newRuleToUpdate = getSimpleRule(immutableRule.rule_id); + const ruleToUpdate = getRuleWithWebHookAction(hookAction.id, false, newRuleToUpdate); + await updateRule(supertest, ruleToUpdate); + const body = await findImmutableRuleById(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + + expect(body.data.length).to.eql(1); // should have only one length to the data set, otherwise we have duplicates or the tags were removed and that is incredibly bad. + const bodyToCompare = removeServerGeneratedProperties(body.data[0]); + const expected = { + ...getSimpleRuleOutputWithWebHookAction(`${bodyToCompare.actions?.[0].id}`), + rule_id: immutableRule.rule_id, // Rule id should match the same as the immutable rule + version: immutableRule.version, // This version number should not change when an immutable rule is updated + immutable: true, // It should stay immutable true when returning + }; + expect(bodyToCompare).to.eql(expected); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index 71390400c359be..158247ee244ddc 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -11,6 +11,7 @@ import { SuperTest } from 'supertest'; import supertestAsPromised from 'supertest-as-promised'; import { Context } from '@elastic/elasticsearch/lib/Transport'; import { SearchResponse } from 'elasticsearch'; +import { PrePackagedRulesAndTimelinesStatusSchema } from '../../plugins/security_solution/common/detection_engine/schemas/response'; import { NonEmptyEntriesArray } from '../../plugins/lists/common/schemas'; import { getCreateExceptionListDetectionSchemaMock } from '../../plugins/lists/common/schemas/request/create_exception_list_schema.mock'; import { @@ -38,6 +39,7 @@ import { DETECTION_ENGINE_PREPACKAGED_URL, DETECTION_ENGINE_QUERY_SIGNALS_URL, DETECTION_ENGINE_RULES_URL, + INTERNAL_IMMUTABLE_KEY, INTERNAL_RULE_ID_KEY, } from '../../plugins/security_solution/common/constants'; import { getCreateExceptionListItemMinimalSchemaMockWithoutId } from '../../plugins/lists/common/schemas/request/create_exception_list_item_schema.mock'; @@ -674,20 +676,27 @@ export const getWebHookAction = () => ({ name: 'Some connector', }); -export const getRuleWithWebHookAction = (id: string, enabled = false): CreateRulesSchema => ({ - ...getSimpleRule('rule-1', enabled), - throttle: 'rule', - actions: [ - { - group: 'default', - id, - params: { - body: '{}', +export const getRuleWithWebHookAction = ( + id: string, + enabled = false, + rule?: QueryCreateSchema +): CreateRulesSchema | UpdateRulesSchema => { + const finalRule = rule != null ? { ...rule, enabled } : getSimpleRule('rule-1', enabled); + return { + ...finalRule, + throttle: 'rule', + actions: [ + { + group: 'default', + id, + params: { + body: '{}', + }, + action_type_id: '.webhook', }, - action_type_id: '.webhook', - }, - ], -}); + ], + }; +}; export const getSimpleRuleOutputWithWebHookAction = (actionId: string): Partial => ({ ...getSimpleRuleOutput(), @@ -830,6 +839,78 @@ export const createRule = async ( return body; }; +/** + * Helper to cut down on the noise in some of the tests. This checks for + * an expected 200 still and does not do any retries. + * @param supertest The supertest deps + * @param rule The rule to create + */ +export const updateRule = async ( + supertest: SuperTest, + updatedRule: UpdateRulesSchema +): Promise => { + const { body } = await supertest + .put(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(updatedRule) + .expect(200); + return body; +}; + +/** + * Helper to cut down on the noise in some of the tests. This + * creates a new action and expects a 200 and does not do any retries. + * @param supertest The supertest deps + */ +export const createNewAction = async (supertest: SuperTest) => { + const { body } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send(getWebHookAction()) + .expect(200); + return body; +}; + +/** + * Helper to cut down on the noise in some of the tests. This + * creates a new action and expects a 200 and does not do any retries. + * @param supertest The supertest deps + */ +export const findImmutableRuleById = async ( + supertest: SuperTest, + ruleId: string +): Promise<{ + page: number; + perPage: number; + total: number; + data: FullResponseSchema[]; +}> => { + const { body } = await supertest + .get( + `${DETECTION_ENGINE_RULES_URL}/_find?filter=alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true" AND alert.attributes.tags: "${INTERNAL_RULE_ID_KEY}:${ruleId}"` + ) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + return body; +}; + +/** + * Helper to cut down on the noise in some of the tests. This + * creates a new action and expects a 200 and does not do any retries. + * @param supertest The supertest deps + */ +export const getPrePackagedRulesStatus = async ( + supertest: SuperTest +): Promise => { + const { body } = await supertest + .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + return body; +}; + /** * Helper to cut down on the noise in some of the tests. This checks for * an expected 200 still and does not try to any retries. Creates exception lists diff --git a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts index 0d0749aa8e913a..9f016ab044a90e 100644 --- a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts +++ b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts @@ -26,8 +26,10 @@ export default function ({ getService }: FtrProviderContext) { }); describe('POST /api/fleet/agent_policies', () => { - it('should work with valid values', async () => { - await supertest + it('should work with valid minimum required values', async () => { + const { + body: { item: createdPolicy }, + } = await supertest .post(`/api/fleet/agent_policies`) .set('kbn-xsrf', 'xxxx') .send({ @@ -35,6 +37,28 @@ export default function ({ getService }: FtrProviderContext) { namespace: 'default', }) .expect(200); + + const getRes = await supertest.get(`/api/fleet/agent_policies/${createdPolicy.id}`); + const json = getRes.body; + expect(json.item.is_managed).to.equal(false); + }); + + it('sets given is_managed value', async () => { + const { + body: { item: createdPolicy }, + } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'TEST2', + namespace: 'default', + is_managed: true, + }) + .expect(200); + + const getRes = await supertest.get(`/api/fleet/agent_policies/${createdPolicy.id}`); + const json = getRes.body; + expect(json.item.is_managed).to.equal(true); }); it('should return a 400 with an empty namespace', async () => { @@ -108,6 +132,7 @@ export default function ({ getService }: FtrProviderContext) { expect(newPolicy).to.eql({ name: 'Copied policy', description: 'Test', + is_managed: false, namespace: 'default', monitoring_enabled: ['logs', 'metrics'], revision: 1, @@ -161,6 +186,7 @@ export default function ({ getService }: FtrProviderContext) { }); describe('PUT /api/fleet/agent_policies/{agentPolicyId}', () => { + let agentPolicyId: undefined | string; it('should work with valid values', async () => { const { body: { item: originalPolicy }, @@ -173,11 +199,11 @@ export default function ({ getService }: FtrProviderContext) { namespace: 'default', }) .expect(200); - + agentPolicyId = originalPolicy.id; const { body: { item: updatedPolicy }, } = await supertest - .put(`/api/fleet/agent_policies/${originalPolicy.id}`) + .put(`/api/fleet/agent_policies/${agentPolicyId}`) .set('kbn-xsrf', 'xxxx') .send({ name: 'Updated name', @@ -193,12 +219,31 @@ export default function ({ getService }: FtrProviderContext) { name: 'Updated name', description: 'Updated description', namespace: 'default', + is_managed: false, revision: 2, updated_by: 'elastic', package_policies: [], }); }); + it('sets given is_managed value', async () => { + const { + body: { item: createdPolicy }, + } = await supertest + .put(`/api/fleet/agent_policies/${agentPolicyId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'TEST2', + namespace: 'default', + is_managed: true, + }) + .expect(200); + + const getRes = await supertest.get(`/api/fleet/agent_policies/${createdPolicy.id}`); + const json = getRes.body; + expect(json.item.is_managed).to.equal(true); + }); + it('should return a 409 if policy already exists with name given', async () => { const sharedBody = { name: 'Initial name', diff --git a/x-pack/test/fleet_api_integration/apis/agents/reassign.ts b/x-pack/test/fleet_api_integration/apis/agents/reassign.ts index e17e779e4217b0..a31fa862f74205 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/reassign.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/reassign.ts @@ -16,10 +16,10 @@ export default function (providerContext: FtrProviderContext) { describe('fleet_reassign_agent', () => { setupFleetAndAgents(providerContext); - before(async () => { + beforeEach(async () => { await esArchiver.loadIfNeeded('fleet/agents'); }); - after(async () => { + afterEach(async () => { await esArchiver.unload('fleet/agents'); }); @@ -31,7 +31,7 @@ export default function (providerContext: FtrProviderContext) { policy_id: 'policy2', }) .expect(200); - const { body } = await supertest.get(`/api/fleet/agents/agent1`).set('kbn-xsrf', 'xxx'); + const { body } = await supertest.get(`/api/fleet/agents/agent1`); expect(body.item.policy_id).to.eql('policy2'); }); @@ -88,5 +88,34 @@ export default function (providerContext: FtrProviderContext) { }) .expect(404); }); + + it('can reassign from unmanaged policy to unmanaged', async () => { + // policy2 is not managed + // reassign succeeds + await supertest + .put(`/api/fleet/agents/agent1/reassign`) + .set('kbn-xsrf', 'xxx') + .send({ + policy_id: 'policy2', + }) + .expect(200); + }); + it('cannot reassign from unmanaged policy to managed', async () => { + // agent1 is enrolled in policy1. set policy1 to managed + await supertest + .put(`/api/fleet/agent_policies/policy1`) + .set('kbn-xsrf', 'xxx') + .send({ name: 'Test policy', namespace: 'default', is_managed: true }) + .expect(200); + + // reassign fails + await supertest + .put(`/api/fleet/agents/agent1/reassign`) + .set('kbn-xsrf', 'xxx') + .send({ + policy_id: 'policy2', + }) + .expect(400); + }); }); } diff --git a/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts b/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts index 3cafc86602d3b2..85bcce824dd51b 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts @@ -65,17 +65,28 @@ export default function (providerContext: FtrProviderContext) { await esArchiver.unload('fleet/agents'); }); - it('should allow to unenroll single agent', async () => { + it('/agents/{agent_id}/unenroll should fail for managed policy', async () => { + // set policy to managed await supertest - .post(`/api/fleet/agents/agent1/unenroll`) + .put(`/api/fleet/agent_policies/policy1`) .set('kbn-xsrf', 'xxx') - .send({ - force: true, - }) + .send({ name: 'Test policy', namespace: 'default', is_managed: true }) + .expect(200); + + await supertest.post(`/api/fleet/agents/agent1/unenroll`).set('kbn-xsrf', 'xxx').expect(400); + }); + + it('/agents/{agent_id}/unenroll should allow from unmanaged policy', async () => { + // set policy to unmanaged + await supertest + .put(`/api/fleet/agent_policies/policy1`) + .set('kbn-xsrf', 'xxx') + .send({ name: 'Test policy', namespace: 'default', is_managed: false }) .expect(200); + await supertest.post(`/api/fleet/agents/agent1/unenroll`).set('kbn-xsrf', 'xxx').expect(200); }); - it('should invalidate related API keys', async () => { + it('/agents/{agent_id}/unenroll { force: true } should invalidate related API keys', async () => { await supertest .post(`/api/fleet/agents/agent1/unenroll`) .set('kbn-xsrf', 'xxx') @@ -97,7 +108,44 @@ export default function (providerContext: FtrProviderContext) { expect(outputAPIKeys[0].invalidated).eql(true); }); - it('should allow to unenroll multiple agents by id', async () => { + it('/agents/{agent_id}/bulk_unenroll should not allow unenroll from managed policy', async () => { + // set policy to managed + await supertest + .put(`/api/fleet/agent_policies/policy1`) + .set('kbn-xsrf', 'xxx') + .send({ name: 'Test policy', namespace: 'default', is_managed: true }) + .expect(200); + + // try to unenroll + await supertest + .post(`/api/fleet/agents/bulk_unenroll`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: ['agent2', 'agent3'], + }) + // http request succeeds + .expect(200); + + // but agents are still enrolled + const [agent2data, agent3data] = await Promise.all([ + supertest.get(`/api/fleet/agents/agent2`), + supertest.get(`/api/fleet/agents/agent3`), + ]); + expect(typeof agent2data.body.item.unenrollment_started_at).to.eql('undefined'); + expect(typeof agent2data.body.item.unenrolled_at).to.eql('undefined'); + expect(agent2data.body.item.active).to.eql(true); + expect(typeof agent3data.body.item.unenrollment_started_at).to.be('undefined'); + expect(typeof agent3data.body.item.unenrolled_at).to.be('undefined'); + expect(agent2data.body.item.active).to.eql(true); + }); + + it('/agents/{agent_id}/bulk_unenroll should allow to unenroll multiple agents by id from an unmanaged policy', async () => { + // set policy to unmanaged + await supertest + .put(`/api/fleet/agent_policies/policy1`) + .set('kbn-xsrf', 'xxx') + .send({ name: 'Test policy', namespace: 'default', is_managed: false }) + .expect(200); await supertest .post(`/api/fleet/agents/bulk_unenroll`) .set('kbn-xsrf', 'xxx') @@ -106,8 +154,8 @@ export default function (providerContext: FtrProviderContext) { }) .expect(200); const [agent2data, agent3data] = await Promise.all([ - supertest.get(`/api/fleet/agents/agent2`).set('kbn-xsrf', 'xxx'), - supertest.get(`/api/fleet/agents/agent3`).set('kbn-xsrf', 'xxx'), + supertest.get(`/api/fleet/agents/agent2`), + supertest.get(`/api/fleet/agents/agent3`), ]); expect(typeof agent2data.body.item.unenrollment_started_at).to.eql('string'); expect(agent2data.body.item.active).to.eql(true); @@ -115,7 +163,7 @@ export default function (providerContext: FtrProviderContext) { expect(agent2data.body.item.active).to.eql(true); }); - it('should allow to unenroll multiple agents by kuery', async () => { + it('/agents/{agent_id}/bulk_unenroll should allow to unenroll multiple agents by kuery', async () => { await supertest .post(`/api/fleet/agents/bulk_unenroll`) .set('kbn-xsrf', 'xxx') @@ -125,7 +173,7 @@ export default function (providerContext: FtrProviderContext) { }) .expect(200); - const { body } = await supertest.get(`/api/fleet/agents`).set('kbn-xsrf', 'xxx'); + const { body } = await supertest.get(`/api/fleet/agents`); expect(body.total).to.eql(0); }); }); diff --git a/x-pack/test/functional/apps/dashboard/_async_dashboard.ts b/x-pack/test/functional/apps/dashboard/_async_dashboard.ts index 55474bd5a76884..5b2632ef710e4d 100644 --- a/x-pack/test/functional/apps/dashboard/_async_dashboard.ts +++ b/x-pack/test/functional/apps/dashboard/_async_dashboard.ts @@ -13,6 +13,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const browser = getService('browser'); const kibanaServer = getService('kibanaServer'); + const esArchiver = getService('esArchiver'); const log = getService('log'); const pieChart = getService('pieChart'); const find = getService('find'); @@ -30,6 +31,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('sample data dashboard', function describeIndexTests() { before(async () => { + await esArchiver.emptyKibanaIndex(); await PageObjects.common.sleep(5000); await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { useActualUrl: true, diff --git a/x-pack/test/functional/apps/lens/drag_and_drop.ts b/x-pack/test/functional/apps/lens/drag_and_drop.ts index 7f8d60f9ffccfc..5b3a984f005192 100644 --- a/x-pack/test/functional/apps/lens/drag_and_drop.ts +++ b/x-pack/test/functional/apps/lens/drag_and_drop.ts @@ -53,7 +53,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { }); it('should reorder the elements for the table', async () => { - await PageObjects.lens.reorderDimensions('lnsDatatable_column', 2, 0); + await PageObjects.lens.reorderDimensions('lnsDatatable_column', 3, 1); await PageObjects.header.waitUntilLoadingHasFinished(); expect(await PageObjects.lens.getDimensionTriggersTexts('lnsDatatable_column')).to.eql([ 'Top values of @message.raw', @@ -83,6 +83,129 @@ export default function ({ getPageObjects }: FtrProviderContext) { await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') ).to.eql(['Top values of @message.raw']); }); + + it('should move the column to non-compatible dimension group', async () => { + expect( + await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') + ).to.eql(['Top values of @message.raw']); + + await PageObjects.lens.dragDimensionToDimension( + 'lnsXY_splitDimensionPanel > lns-dimensionTrigger', + 'lnsXY_yDimensionPanel > lns-dimensionTrigger' + ); + + expect( + await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') + ).to.eql([]); + expect( + await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') + ).to.eql([]); + expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([ + 'Unique count of @message.raw', + ]); + }); + it('should duplicate the column when dragging to empty dimension in the same group', async () => { + await PageObjects.lens.dragDimensionToDimension( + 'lnsXY_yDimensionPanel > lns-dimensionTrigger', + 'lnsXY_yDimensionPanel > lns-empty-dimension' + ); + await PageObjects.lens.dragDimensionToDimension( + 'lnsXY_yDimensionPanel > lns-dimensionTrigger', + 'lnsXY_yDimensionPanel > lns-empty-dimension' + ); + expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([ + 'Unique count of @message.raw', + 'Unique count of @message.raw [1]', + 'Unique count of @message.raw [2]', + ]); + }); + it('should duplicate the column when dragging to empty dimension in the same group', async () => { + await PageObjects.lens.dragDimensionToDimension( + 'lnsXY_yDimensionPanel > lns-dimensionTrigger', + 'lnsXY_xDimensionPanel > lns-empty-dimension' + ); + expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([ + 'Unique count of @message.raw', + 'Unique count of @message.raw [1]', + ]); + expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_xDimensionPanel')).to.eql([ + 'Top values of @message.raw', + ]); + }); + }); + describe('keyboard drag and drop', () => { + it('should drop a field to workspace', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.dragFieldWithKeyboard('@timestamp'); + expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_xDimensionPanel')).to.eql( + '@timestamp' + ); + }); + it('should drop a field to empty dimension', async () => { + await PageObjects.lens.dragFieldWithKeyboard('bytes', 4); + expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([ + 'Count of records', + 'Average of bytes', + ]); + await PageObjects.lens.dragFieldWithKeyboard('@message.raw', 1, true); + expect( + await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') + ).to.eql(['Top values of @message.raw']); + }); + it('should drop a field to an existing dimension replacing the old one', async () => { + await PageObjects.lens.dragFieldWithKeyboard('clientip', 1, true); + expect( + await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') + ).to.eql(['Top values of clientip']); + }); + it('should duplicate an element in a group', async () => { + await PageObjects.lens.dimensionKeyboardDragDrop('lnsXY_yDimensionPanel', 0, 1); + expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([ + 'Count of records', + 'Average of bytes', + 'Count of records [1]', + ]); + }); + + it('should move dimension to compatible dimension', async () => { + await PageObjects.lens.dimensionKeyboardDragDrop('lnsXY_xDimensionPanel', 0, 5); + expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_xDimensionPanel')).to.eql( + [] + ); + expect( + await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') + ).to.eql(['@timestamp']); + + await PageObjects.lens.dimensionKeyboardDragDrop('lnsXY_splitDimensionPanel', 0, 5, true); + expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_xDimensionPanel')).to.eql([ + '@timestamp', + ]); + expect( + await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') + ).to.eql([]); + }); + it('should move dimension to incompatible dimension', async () => { + await PageObjects.lens.dimensionKeyboardDragDrop('lnsXY_yDimensionPanel', 1, 2); + expect( + await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') + ).to.eql(['bytes']); + + await PageObjects.lens.dimensionKeyboardDragDrop('lnsXY_xDimensionPanel', 0, 2); + expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([ + 'Count of records', + 'Unique count of @timestamp', + ]); + }); + it('should reorder elements with keyboard', async () => { + await PageObjects.lens.dimensionKeyboardReorder('lnsXY_yDimensionPanel', 0, 1); + expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([ + 'Unique count of @timestamp', + 'Count of records', + ]); + }); }); describe('workspace drop', () => { diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index 6cbd18bdeef045..10b1f4d30145f8 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -29,6 +29,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { this.tags(['ciGroup4', 'skipFirefox']); loadTestFile(require.resolve('./smokescreen')); + loadTestFile(require.resolve('./table')); loadTestFile(require.resolve('./dashboard')); loadTestFile(require.resolve('./persistent_context')); loadTestFile(require.resolve('./colors')); diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 73c5838259f6ef..a86a67d7c8d0df 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']); const find = getService('find'); + const retry = getService('retry'); const listingTable = getService('listingTable'); const testSubjects = getService('testSubjects'); const elasticChart = getService('elasticChart'); @@ -589,13 +590,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should able to use filters cell actions in table', async () => { const firstCellContent = await PageObjects.lens.getDatatableCellText(0, 0); - await PageObjects.lens.clickTableCellAction(0, 0, 'lensDatatableFilterOut'); - await PageObjects.header.waitUntilLoadingHasFinished(); - expect( - await find.existsByCssSelector( - `[data-test-subj*="filter-value-${firstCellContent}"][data-test-subj*="filter-negated"]` - ) - ).to.eql(true); + await retry.try(async () => { + await PageObjects.lens.clickTableCellAction(0, 0, 'lensDatatableFilterOut'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect( + await find.existsByCssSelector( + `[data-test-subj*="filter-value-${firstCellContent}"][data-test-subj*="filter-negated"]` + ) + ).to.eql(true); + }); }); }); } diff --git a/x-pack/test/functional/apps/lens/table.ts b/x-pack/test/functional/apps/lens/table.ts new file mode 100644 index 00000000000000..3f9cdf06da8aba --- /dev/null +++ b/x-pack/test/functional/apps/lens/table.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']); + const listingTable = getService('listingTable'); + const find = getService('find'); + const retry = getService('retry'); + + describe('lens datatable', () => { + it('should able to sort a table by a column', async () => { + await PageObjects.visualize.gotoVisualizationLandingPage(); + await listingTable.searchForItemWithName('lnsXYvis'); + await PageObjects.lens.clickVisualizeListItemTitle('lnsXYvis'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('lnsDatatable'); + // Sort by number + await PageObjects.lens.changeTableSortingBy(2, 'asc'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.getDatatableCellText(0, 2)).to.eql('17,246'); + // Now sort by IP + await PageObjects.lens.changeTableSortingBy(0, 'asc'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('78.83.247.30'); + // Change the sorting + await PageObjects.lens.changeTableSortingBy(0, 'desc'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('169.228.188.120'); + // Remove the sorting + await PageObjects.lens.changeTableSortingBy(0, 'none'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.isDatatableHeaderSorted(0)).to.eql(false); + }); + + it('should able to use filters cell actions in table', async () => { + const firstCellContent = await PageObjects.lens.getDatatableCellText(0, 0); + await retry.try(async () => { + await PageObjects.lens.clickTableCellAction(0, 0, 'lensDatatableFilterOut'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect( + await find.existsByCssSelector( + `[data-test-subj*="filter-value-${firstCellContent}"][data-test-subj*="filter-negated"]` + ) + ).to.eql(true); + }); + }); + + it('should allow to configure column visibility', async () => { + expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal('Top values of ip'); + expect(await PageObjects.lens.getDatatableHeaderText(1)).to.equal('@timestamp per 3 hours'); + expect(await PageObjects.lens.getDatatableHeaderText(2)).to.equal('Average of bytes'); + + await PageObjects.lens.toggleColumnVisibility('lnsDatatable_column > lns-dimensionTrigger'); + + expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal('@timestamp per 3 hours'); + expect(await PageObjects.lens.getDatatableHeaderText(1)).to.equal('Average of bytes'); + + await PageObjects.lens.toggleColumnVisibility('lnsDatatable_column > lns-dimensionTrigger'); + + expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal('Top values of ip'); + expect(await PageObjects.lens.getDatatableHeaderText(1)).to.equal('@timestamp per 3 hours'); + expect(await PageObjects.lens.getDatatableHeaderText(2)).to.equal('Average of bytes'); + }); + }); +} diff --git a/x-pack/test/functional/apps/maps/import_geojson/add_layer_import_panel.js b/x-pack/test/functional/apps/maps/import_geojson/add_layer_import_panel.js index 390c7af98c653e..46b87b1c4195c3 100644 --- a/x-pack/test/functional/apps/maps/import_geojson/add_layer_import_panel.js +++ b/x-pack/test/functional/apps/maps/import_geojson/add_layer_import_panel.js @@ -8,18 +8,24 @@ import expect from '@kbn/expect'; import path from 'path'; -export default function ({ getPageObjects }) { +export default function ({ getPageObjects, getService }) { const PageObjects = getPageObjects(['maps', 'common']); const IMPORT_FILE_PREVIEW_NAME = 'Import File'; const FILE_LOAD_DIR = 'test_upload_files'; const DEFAULT_LOAD_FILE_NAME = 'point.json'; + const security = getService('security'); describe('GeoJSON import layer panel', () => { before(async () => { + await security.testUser.setRoles(['global_maps_all', 'geoall_data_writer']); await PageObjects.maps.openNewMap(); }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + beforeEach(async () => { await PageObjects.maps.clickAddLayer(); await PageObjects.maps.selectGeoJsonUploadSource(); diff --git a/x-pack/test/functional/apps/maps/import_geojson/file_indexing_panel.js b/x-pack/test/functional/apps/maps/import_geojson/file_indexing_panel.js index ea8366d809fb78..4496b59393eece 100644 --- a/x-pack/test/functional/apps/maps/import_geojson/file_indexing_panel.js +++ b/x-pack/test/functional/apps/maps/import_geojson/file_indexing_panel.js @@ -13,6 +13,7 @@ export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['maps', 'common']); const testSubjects = getService('testSubjects'); const log = getService('log'); + const security = getService('security'); const IMPORT_FILE_PREVIEW_NAME = 'Import File'; const FILE_LOAD_DIR = 'test_upload_files'; @@ -37,9 +38,17 @@ export default function ({ getService, getPageObjects }) { describe('On GeoJSON index name & pattern operation complete', () => { before(async () => { + await security.testUser.setRoles( + ['global_maps_all', 'geoall_data_writer', 'global_index_pattern_management_all'], + false + ); await PageObjects.maps.openNewMap(); }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + beforeEach(async () => { await PageObjects.maps.clickAddLayer(); await PageObjects.maps.selectGeoJsonUploadSource(); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/feature_importance.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/feature_importance.ts index 49728603c246c7..b8bdc7de16e1e1 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/feature_importance.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/feature_importance.ts @@ -14,7 +14,8 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - describe('total feature importance panel and decision path popover', function () { + // Failing: See https://github.com/elastic/kibana/issues/90526 + describe.skip('total feature importance panel and decision path popover', function () { const testDataList: Array<{ suiteTitle: string; archive: string; 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 c09bb0c5553222..65bc68db25aa15 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 @@ -222,6 +222,7 @@ export default function ({ getService }: FtrProviderContext) { fieldRow.fieldName, fieldRow.docCountFormatted, fieldRow.topValuesCount, + false, false ); } @@ -230,7 +231,8 @@ export default function ({ getService }: FtrProviderContext) { fieldRow.type, fieldRow.fieldName!, fieldRow.docCountFormatted, - fieldRow.exampleCount + fieldRow.exampleCount, + false ); } diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts index ffd22dd176ed56..609cf05dad541a 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts @@ -13,11 +13,13 @@ interface MetricFieldVisConfig extends FieldVisConfig { statsMaxDecimalPlaces: number; docCountFormatted: string; topValuesCount: number; + viewableInLens: boolean; } interface NonMetricFieldVisConfig extends FieldVisConfig { docCountFormatted: string; exampleCount: number; + viewableInLens: boolean; } interface TestData { @@ -69,6 +71,7 @@ export default function ({ getService }: FtrProviderContext) { docCountFormatted: '5000 (100%)', statsMaxDecimalPlaces: 3, topValuesCount: 10, + viewableInLens: true, }, ], nonMetricFields: [ @@ -80,6 +83,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, docCountFormatted: '5000 (100%)', exampleCount: 2, + viewableInLens: true, }, { fieldName: '@version', @@ -89,6 +93,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, exampleCount: 1, docCountFormatted: '', + viewableInLens: false, }, { fieldName: '@version.keyword', @@ -98,6 +103,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, exampleCount: 1, docCountFormatted: '5000 (100%)', + viewableInLens: true, }, { fieldName: 'airline', @@ -107,6 +113,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, exampleCount: 10, docCountFormatted: '5000 (100%)', + viewableInLens: true, }, { fieldName: 'type', @@ -116,6 +123,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, exampleCount: 1, docCountFormatted: '', + viewableInLens: false, }, { fieldName: 'type.keyword', @@ -125,6 +133,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, exampleCount: 1, docCountFormatted: '5000 (100%)', + viewableInLens: true, }, ], emptyFields: ['sourcetype'], @@ -158,6 +167,7 @@ export default function ({ getService }: FtrProviderContext) { docCountFormatted: '5000 (100%)', statsMaxDecimalPlaces: 3, topValuesCount: 10, + viewableInLens: true, }, ], nonMetricFields: [ @@ -169,6 +179,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, docCountFormatted: '5000 (100%)', exampleCount: 2, + viewableInLens: true, }, { fieldName: '@version', @@ -178,6 +189,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, exampleCount: 1, docCountFormatted: '', + viewableInLens: false, }, { fieldName: '@version.keyword', @@ -187,6 +199,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, exampleCount: 1, docCountFormatted: '5000 (100%)', + viewableInLens: true, }, { fieldName: 'airline', @@ -196,6 +209,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, exampleCount: 5, docCountFormatted: '5000 (100%)', + viewableInLens: true, }, { fieldName: 'type', @@ -205,6 +219,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, exampleCount: 1, docCountFormatted: '', + viewableInLens: false, }, { fieldName: 'type.keyword', @@ -214,6 +229,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, exampleCount: 1, docCountFormatted: '5000 (100%)', + viewableInLens: true, }, ], emptyFields: ['sourcetype'], @@ -247,6 +263,7 @@ export default function ({ getService }: FtrProviderContext) { docCountFormatted: '5000 (100%)', statsMaxDecimalPlaces: 3, topValuesCount: 10, + viewableInLens: true, }, ], nonMetricFields: [ @@ -258,6 +275,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, docCountFormatted: '5000 (100%)', exampleCount: 2, + viewableInLens: true, }, { fieldName: '@version', @@ -267,6 +285,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, exampleCount: 1, docCountFormatted: '', + viewableInLens: false, }, { fieldName: '@version.keyword', @@ -276,6 +295,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, exampleCount: 1, docCountFormatted: '5000 (100%)', + viewableInLens: true, }, { fieldName: 'airline', @@ -285,6 +305,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, exampleCount: 5, docCountFormatted: '5000 (100%)', + viewableInLens: true, }, { fieldName: 'type', @@ -294,6 +315,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, exampleCount: 1, docCountFormatted: '', + viewableInLens: false, }, { fieldName: 'type.keyword', @@ -303,6 +325,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, exampleCount: 1, docCountFormatted: '5000 (100%)', + viewableInLens: true, }, ], emptyFields: ['sourcetype'], @@ -334,6 +357,7 @@ export default function ({ getService }: FtrProviderContext) { loading: false, docCountFormatted: '408 (100%)', exampleCount: 10, + viewableInLens: false, }, ], emptyFields: [], @@ -417,7 +441,8 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataVisualizerTable.assertNumberFieldContents( fieldRow.fieldName, fieldRow.docCountFormatted, - fieldRow.topValuesCount + fieldRow.topValuesCount, + fieldRow.viewableInLens ); } @@ -426,7 +451,8 @@ export default function ({ getService }: FtrProviderContext) { fieldRow.type, fieldRow.fieldName!, fieldRow.docCountFormatted, - fieldRow.exampleCount + fieldRow.exampleCount, + fieldRow.viewableInLens ); } diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts index 6e2e9cfb858c32..ce00ee79e9075a 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts @@ -11,7 +11,7 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - describe('index based actions panel', function () { + describe('index based actions panel on trial license', function () { this.tags(['mlqa']); const indexPatternName = 'ft_farequote'; @@ -28,6 +28,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await esArchiver.loadIfNeeded('ml/farequote'); await ml.testResources.createIndexPatternIfNeeded(indexPatternName, '@timestamp'); + await ml.testResources.createSavedSearchFarequoteKueryIfNeeded(); await ml.testResources.setKibanaTimeZoneToUTC(); await ml.securityUI.loginAsMlPowerUser(); @@ -59,5 +60,38 @@ export default function ({ getService }: FtrProviderContext) { await ml.jobWizardAdvanced.assertDatafeedQueryEditorValue(advancedJobWizardDatafeedQuery); }); }); + + describe('view in discover page action', function () { + const savedSearch = 'ft_farequote_kuery'; + const expectedQuery = 'airline: A* and responsetime > 5'; + const docCountFormatted = '34,415'; + + it('loads the source data in the data visualizer', async () => { + await ml.testExecution.logTestStep('loads the data visualizer selector page'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToDataVisualizer(); + + await ml.testExecution.logTestStep('loads the saved search selection page'); + await ml.dataVisualizer.navigateToIndexPatternSelection(); + + await ml.testExecution.logTestStep('loads the index data visualizer page'); + await ml.jobSourceSelection.selectSourceForIndexBasedDataVisualizer(savedSearch); + + await ml.testExecution.logTestStep(`loads data for full time range`); + await ml.dataVisualizerIndexBased.assertTimeRangeSelectorSectionExists(); + await ml.dataVisualizerIndexBased.clickUseFullDataButton(docCountFormatted); + }); + + it('navigates to Discover page', async () => { + await ml.testExecution.logTestStep('displays the actions panel with view in Discover card'); + await ml.dataVisualizerIndexBased.assertActionsPanelExists(); + await ml.dataVisualizerIndexBased.assertViewInDiscoverCardExists(); + + await ml.testExecution.logTestStep('retains the query in Discover page'); + await ml.dataVisualizerIndexBased.clickViewInDiscoverButton(); + await ml.dataVisualizerIndexBased.assertDiscoverPageQuery(expectedQuery); + await ml.dataVisualizerIndexBased.assertDiscoverHitCount(docCountFormatted); + }); + }); }); } diff --git a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts index 261e0547210f16..7b4c646f379dee 100644 --- a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts @@ -357,8 +357,13 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('should display the data visualizer table'); await ml.dataVisualizerIndexBased.assertDataVisualizerTableExist(); - await ml.testExecution.logTestStep('should display the actions panel with cards'); + await ml.testExecution.logTestStep( + 'should display the actions panel with Discover card' + ); await ml.dataVisualizerIndexBased.assertActionsPanelExists(); + await ml.dataVisualizerIndexBased.assertViewInDiscoverCardExists(); + + await ml.testExecution.logTestStep('should display job cards'); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardExists(); await ml.dataVisualizerIndexBased.assertRecognizerCardExists(ecExpectedModuleId); }); diff --git a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts index 98b743192c1609..69ae3961dfd4dd 100644 --- a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts @@ -99,6 +99,7 @@ export default function ({ getService }: FtrProviderContext) { const ecIndexPattern = 'ft_module_sample_ecommerce'; const ecExpectedTotalCount = '287'; + const ecExpectedModuleId = 'sample_data_ecommerce'; const uploadFilePath = path.join( __dirname, @@ -349,8 +350,15 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('should display the data visualizer table'); await ml.dataVisualizerIndexBased.assertDataVisualizerTableExist(); - await ml.testExecution.logTestStep('should not display the actions panel'); - await ml.dataVisualizerIndexBased.assertActionsPanelNotExists(); + await ml.testExecution.logTestStep( + 'should display the actions panel with Discover card' + ); + await ml.dataVisualizerIndexBased.assertActionsPanelExists(); + await ml.dataVisualizerIndexBased.assertViewInDiscoverCardExists(); + + await ml.testExecution.logTestStep('should not display job cards'); + await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); + await ml.dataVisualizerIndexBased.assertRecognizerCardNotExists(ecExpectedModuleId); }); it('should display elements on File Data Visualizer page correctly', async () => { diff --git a/x-pack/test/functional/apps/saved_objects_management/feature_controls/saved_objects_management_security.ts b/x-pack/test/functional/apps/saved_objects_management/feature_controls/saved_objects_management_security.ts index bb60de86aef82b..95ebc7b2ff5d57 100644 --- a/x-pack/test/functional/apps/saved_objects_management/feature_controls/saved_objects_management_security.ts +++ b/x-pack/test/functional/apps/saved_objects_management/feature_controls/saved_objects_management_security.ts @@ -70,7 +70,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows all saved objects', async () => { const objects = await PageObjects.savedObjects.getRowTitles(); expect(objects).to.eql([ - 'Advanced Settings [6.0.0]', `Advanced Settings [${version}]`, 'A Dashboard', 'logstash-*', @@ -81,10 +80,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('can view all saved objects in applications', async () => { const bools = await PageObjects.savedObjects.getTableSummary(); expect(bools).to.eql([ - { - title: 'Advanced Settings [6.0.0]', - canViewInApp: false, - }, { title: `Advanced Settings [${version}]`, canViewInApp: false, @@ -189,7 +184,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows all saved objects', async () => { const objects = await PageObjects.savedObjects.getRowTitles(); expect(objects).to.eql([ - 'Advanced Settings [6.0.0]', `Advanced Settings [${version}]`, 'A Dashboard', 'logstash-*', @@ -200,10 +194,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('cannot view any saved objects in applications', async () => { const bools = await PageObjects.savedObjects.getTableSummary(); expect(bools).to.eql([ - { - title: 'Advanced Settings [6.0.0]', - canViewInApp: false, - }, { title: `Advanced Settings [${version}]`, canViewInApp: false, 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 9c8b22803ccbef..c28b3cfec85ac3 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -453,10 +453,12 @@ export default function ({ getService }: FtrProviderContext) { await transform.testExecution.logTestStep('shows the transform preview'); await transform.wizard.assertPivotPreviewChartHistogramButtonMissing(); - await transform.wizard.assertPivotPreviewColumnValues( - testData.expected.transformPreview.column, - testData.expected.transformPreview.values - ); + // cell virtualization means the last column is cutoff in the functional tests + // https://github.com/elastic/eui/issues/4470 + // await transform.wizard.assertPivotPreviewColumnValues( + // testData.expected.transformPreview.column, + // testData.expected.transformPreview.values + // ); await transform.testExecution.logTestStep('loads the details step'); await transform.wizard.advanceToDetailsStep(); diff --git a/x-pack/test/functional/apps/transform/creation_saved_search.ts b/x-pack/test/functional/apps/transform/creation_saved_search.ts index 620dd6e0823acb..673f5b3217fb59 100644 --- a/x-pack/test/functional/apps/transform/creation_saved_search.ts +++ b/x-pack/test/functional/apps/transform/creation_saved_search.ts @@ -292,10 +292,12 @@ export default function ({ getService }: FtrProviderContext) { await transform.testExecution.logTestStep( 'displays the transform preview in the expanded row' ); - await transform.table.assertTransformsExpandedRowPreviewColumnValues( - testData.expected.transformPreview.column, - testData.expected.transformPreview.values - ); + // cell virtualization means the last column is cutoff in the functional tests + // https://github.com/elastic/eui/issues/4470 + // await transform.table.assertTransformsExpandedRowPreviewColumnValues( + // testData.expected.transformPreview.column, + // testData.expected.transformPreview.values + // ); }); }); } diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index f2be2974986fb8..4d63f033f8756b 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -377,6 +377,28 @@ export default async function ({ readConfigFile }) { }, }, + geoall_data_writer: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['create', 'read', 'view_index_metadata', 'monitor', 'create_index'], + }, + ], + }, + }, + + global_index_pattern_management_all: { + kibana: [ + { + feature: { + indexPatterns: ['all'], + }, + spaces: ['*'], + }, + ], + }, + global_devtools_read: { kibana: [ { diff --git a/x-pack/test/functional/es_archives/saved_objects_management/feature_controls/security/data.json b/x-pack/test/functional/es_archives/saved_objects_management/feature_controls/security/data.json index f085bad4c507ec..b63ae2295f70b5 100644 --- a/x-pack/test/functional/es_archives/saved_objects_management/feature_controls/security/data.json +++ b/x-pack/test/functional/es_archives/saved_objects_management/feature_controls/security/data.json @@ -66,20 +66,3 @@ } } } - -{ - "type": "doc", - "value": { - "index": ".kibana", - "type": "doc", - "id": "config:6.0.0", - "source": { - "config": { - "buildNum": 9007199254740991, - "defaultIndex": "logstash-*" - }, - "type": "config", - "updated_at": "2019-01-22T19:32:02.235Z" - } - } -} diff --git a/x-pack/test/functional/es_archives/visualize/default/data.json b/x-pack/test/functional/es_archives/visualize/default/data.json index fe29bad0fa3814..26b033e28b4da7 100644 --- a/x-pack/test/functional/es_archives/visualize/default/data.json +++ b/x-pack/test/functional/es_archives/visualize/default/data.json @@ -125,26 +125,8 @@ { "type": "doc", "value": { - "id": "custom-space:index-pattern:metricbeat-*", - "index": ".kibana_1", - "source": { - "index-pattern": { - "fieldFormatMap": "{\"aerospike.namespace.device.available.pct\":{\"id\":\"percent\",\"params\":{}},\"aerospike.namespace.device.free.pct\":{\"id\":\"percent\",\"params\":{}},\"aerospike.namespace.device.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aerospike.namespace.device.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aerospike.namespace.memory.free.pct\":{\"id\":\"percent\",\"params\":{}},\"aerospike.namespace.memory.used.data.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aerospike.namespace.memory.used.index.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aerospike.namespace.memory.used.sindex.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aerospike.namespace.memory.used.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.ec2.diskio.read.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.ec2.diskio.write.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.ec2.network.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.ec2.network.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.cpu.total.pct\":{\"id\":\"percent\",\"params\":{}},\"aws.rds.disk_usage.bin_log.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.free_local_storage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.free_storage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.freeable_memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.latency.commit\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.ddl\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.dml\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.insert\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.read\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.select\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.update\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.write\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.replica_lag.sec\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.swap_usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.volume_used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.s3_daily_storage.bucket.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.s3_request.downloaded.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.s3_request.latency.first_byte.ms\":{\"id\":\"duration\",\"params\":{}},\"aws.s3_request.latency.total_request.ms\":{\"id\":\"duration\",\"params\":{}},\"aws.s3_request.requests.select_returned.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.s3_request.requests.select_scanned.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.s3_request.uploaded.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.sqs.oldest_message_age.sec\":{\"id\":\"duration\",\"params\":{}},\"aws.sqs.sent_message_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_disk.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_disk.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_disk.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.degraded.ratio\":{\"id\":\"percent\",\"params\":{}},\"ceph.cluster_status.misplace.ratio\":{\"id\":\"percent\",\"params\":{}},\"ceph.cluster_status.pg.avail_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.pg.data_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.pg.total_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.pg.used_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.traffic.read_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.traffic.write_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.monitor_health.store_stats.log.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.monitor_health.store_stats.misc.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.monitor_health.store_stats.sst.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.monitor_health.store_stats.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.osd_df.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.osd_df.total.byte\":{\"id\":\"bytes\",\"params\":{}},\"ceph.osd_df.used.byte\":{\"id\":\"bytes\",\"params\":{}},\"ceph.osd_df.used.pct\":{\"id\":\"percent\",\"params\":{}},\"ceph.pool_disk.stats.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.pool_disk.stats.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"client.bytes\":{\"id\":\"bytes\",\"params\":{}},\"client.nat.port\":{\"id\":\"string\",\"params\":{}},\"client.port\":{\"id\":\"string\",\"params\":{}},\"coredns.stats.dns.request.duration.ns.sum\":{\"id\":\"duration\",\"params\":{}},\"couchbase.bucket.data.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.bucket.disk.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.bucket.memory.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.bucket.quota.ram.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.bucket.quota.use.pct\":{\"id\":\"percent\",\"params\":{}},\"couchbase.cluster.hdd.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.hdd.quota.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.hdd.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.hdd.used.by_data.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.hdd.used.value.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.quota.total.per_node.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.quota.total.value.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.quota.used.per_node.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.quota.used.value.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.used.by_data.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.used.value.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.node.couch.docs.data_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.node.couch.docs.disk_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.node.mcd_memory.allocated.bytes\":{\"id\":\"bytes\",\"params\":{}},\"destination.bytes\":{\"id\":\"bytes\",\"params\":{}},\"destination.nat.port\":{\"id\":\"string\",\"params\":{}},\"destination.port\":{\"id\":\"string\",\"params\":{}},\"docker.cpu.core.*.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.cpu.kernel.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.cpu.system.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.cpu.total.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.cpu.user.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.diskio.read.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.diskio.summary.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.diskio.write.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.commit.peak\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.commit.total\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.limit\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.private_working_set.total\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.rss.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.memory.rss.total\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.usage.max\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.usage.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.memory.usage.total\":{\"id\":\"bytes\",\"params\":{}},\"docker.network.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.network.inbound.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.network.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.network.outbound.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.summary.primaries.segments.memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.summary.primaries.store.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.summary.total.segments.memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.summary.total.store.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.total.segments.memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.total.store.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.jvm.memory.heap.init.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.jvm.memory.heap.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.jvm.memory.nonheap.init.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.jvm.memory.nonheap.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.fs.summary.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.fs.summary.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.fs.summary.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.indices.segments.memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.old.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.old.peak.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.old.peak_max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.old.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.survivor.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.survivor.peak.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.survivor.peak_max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.survivor.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.young.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.young.peak.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.young.peak_max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.young.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"etcd.disk.mvcc_db_total_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"etcd.memory.go_memstats_alloc.bytes\":{\"id\":\"bytes\",\"params\":{}},\"etcd.network.client_grpc_received.bytes\":{\"id\":\"bytes\",\"params\":{}},\"etcd.network.client_grpc_sent.bytes\":{\"id\":\"bytes\",\"params\":{}},\"event.duration\":{\"id\":\"duration\",\"params\":{\"inputFormat\":\"nanoseconds\",\"outputFormat\":\"asMilliseconds\",\"outputPrecision\":1}},\"event.sequence\":{\"id\":\"string\",\"params\":{}},\"event.severity\":{\"id\":\"string\",\"params\":{}},\"golang.heap.allocations.active\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.allocations.allocated\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.allocations.idle\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.allocations.total\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.gc.next_gc_limit\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.system.obtained\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.system.released\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.system.stack\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.system.total\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.info.idle.pct\":{\"id\":\"percent\",\"params\":{}},\"haproxy.info.memory.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.info.ssl.frontend.session_reuse.pct\":{\"id\":\"percent\",\"params\":{}},\"haproxy.stat.compressor.bypassed.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.compressor.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.compressor.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.compressor.response.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.throttle.pct\":{\"id\":\"percent\",\"params\":{}},\"http.request.body.bytes\":{\"id\":\"bytes\",\"params\":{}},\"http.request.bytes\":{\"id\":\"bytes\",\"params\":{}},\"http.response.body.bytes\":{\"id\":\"bytes\",\"params\":{}},\"http.response.bytes\":{\"id\":\"bytes\",\"params\":{}},\"http.response.status_code\":{\"id\":\"string\",\"params\":{}},\"kibana.stats.process.memory.heap.size_limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kibana.stats.process.memory.heap.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kibana.stats.process.memory.heap.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.apiserver.http.request.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.apiserver.http.response.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.apiserver.process.memory.resident.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.apiserver.process.memory.virtual.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.cpu.usage.limit.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.container.cpu.usage.node.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.container.logs.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.logs.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.logs.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.request.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.usage.limit.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.container.memory.usage.node.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.container.memory.workingset.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.rootfs.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.rootfs.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.rootfs.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.controllermanager.http.request.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.controllermanager.http.response.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.controllermanager.process.memory.resident.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.controllermanager.process.memory.virtual.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.fs.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.fs.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.fs.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.allocatable.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.workingset.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.network.rx.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.network.tx.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.runtime.imagefs.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.runtime.imagefs.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.runtime.imagefs.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.cpu.usage.limit.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.pod.cpu.usage.node.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.pod.memory.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.memory.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.memory.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.memory.usage.limit.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.pod.memory.usage.node.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.pod.memory.working_set.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.network.rx.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.network.tx.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.proxy.http.request.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.proxy.http.response.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.proxy.process.memory.resident.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.proxy.process.memory.virtual.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.scheduler.http.request.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.scheduler.http.response.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.scheduler.process.memory.resident.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.scheduler.process.memory.virtual.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.system.memory.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.system.memory.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.system.memory.workingset.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.volume.fs.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.volume.fs.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.volume.fs.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.avg_obj_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.data_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.extent_free_list.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.file_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.index_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.storage_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.replstatus.headroom.max\":{\"id\":\"duration\",\"params\":{}},\"mongodb.replstatus.headroom.min\":{\"id\":\"duration\",\"params\":{}},\"mongodb.replstatus.lag.max\":{\"id\":\"duration\",\"params\":{}},\"mongodb.replstatus.lag.min\":{\"id\":\"duration\",\"params\":{}},\"mongodb.replstatus.oplog.size.allocated\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.replstatus.oplog.size.used\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.extra_info.heap_usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.network.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.network.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.cache.dirty.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.cache.maximum.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.cache.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.log.max_file_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.log.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.log.write.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mysql.status.bytes.received\":{\"id\":\"bytes\",\"params\":{}},\"mysql.status.bytes.sent\":{\"id\":\"bytes\",\"params\":{}},\"nats.stats.cpu\":{\"id\":\"percent\",\"params\":{}},\"nats.stats.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"nats.stats.mem.bytes\":{\"id\":\"bytes\",\"params\":{}},\"nats.stats.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"nats.stats.uptime\":{\"id\":\"duration\",\"params\":{}},\"nats.subscriptions.cache.hit_rate\":{\"id\":\"percent\",\"params\":{}},\"network.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.data_file.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.data_file.size.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.data_file.size.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.space.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.space.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.space.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"process.pgid\":{\"id\":\"string\",\"params\":{}},\"process.pid\":{\"id\":\"string\",\"params\":{}},\"process.ppid\":{\"id\":\"string\",\"params\":{}},\"process.thread.id\":{\"id\":\"string\",\"params\":{}},\"rabbitmq.connection.frame_max\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.disk.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.disk.free.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.gc.reclaimed.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.io.read.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.io.write.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.mem.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.queue.consumers.utilisation.pct\":{\"id\":\"percent\",\"params\":{}},\"rabbitmq.queue.memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.allocator_stats.active\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.allocator_stats.allocated\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.allocator_stats.fragmentation.bytes\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.allocator_stats.resident\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.allocator_stats.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.fragmentation.bytes\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.max.value\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.used.dataset\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.used.lua\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.used.peak\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.used.rss\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.used.value\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.aof.buffer.size\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.aof.copy_on_write.last_size\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.aof.rewrite.buffer.size\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.aof.rewrite.current_time.sec\":{\"id\":\"duration\",\"params\":{}},\"redis.info.persistence.aof.rewrite.last_time.sec\":{\"id\":\"duration\",\"params\":{}},\"redis.info.persistence.aof.size.base\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.aof.size.current\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.rdb.bgsave.current_time.sec\":{\"id\":\"duration\",\"params\":{}},\"redis.info.persistence.rdb.bgsave.last_time.sec\":{\"id\":\"duration\",\"params\":{}},\"redis.info.persistence.rdb.copy_on_write.last_size\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.replication.backlog.size\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.replication.master.last_io_seconds_ago\":{\"id\":\"duration\",\"params\":{}},\"redis.info.replication.master.sync.last_io_seconds_ago\":{\"id\":\"duration\",\"params\":{}},\"redis.info.replication.master.sync.left_bytes\":{\"id\":\"bytes\",\"params\":{}},\"server.bytes\":{\"id\":\"bytes\",\"params\":{}},\"server.nat.port\":{\"id\":\"string\",\"params\":{}},\"server.port\":{\"id\":\"string\",\"params\":{}},\"source.bytes\":{\"id\":\"bytes\",\"params\":{}},\"source.nat.port\":{\"id\":\"string\",\"params\":{}},\"source.port\":{\"id\":\"string\",\"params\":{}},\"system.core.idle.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.iowait.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.irq.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.nice.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.softirq.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.steal.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.system.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.user.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.idle.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.idle.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.iowait.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.iowait.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.irq.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.irq.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.nice.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.nice.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.softirq.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.softirq.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.steal.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.steal.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.system.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.system.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.total.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.total.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.user.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.user.pct\":{\"id\":\"percent\",\"params\":{}},\"system.diskio.iostat.read.per_sec.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.diskio.iostat.write.per_sec.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.diskio.read.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.diskio.write.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.entropy.pct\":{\"id\":\"percent\",\"params\":{}},\"system.filesystem.available\":{\"id\":\"bytes\",\"params\":{}},\"system.filesystem.free\":{\"id\":\"bytes\",\"params\":{}},\"system.filesystem.total\":{\"id\":\"bytes\",\"params\":{}},\"system.filesystem.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.filesystem.used.pct\":{\"id\":\"percent\",\"params\":{}},\"system.fsstat.total_size.free\":{\"id\":\"bytes\",\"params\":{}},\"system.fsstat.total_size.total\":{\"id\":\"bytes\",\"params\":{}},\"system.fsstat.total_size.used\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.actual.free\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.actual.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.actual.used.pct\":{\"id\":\"percent\",\"params\":{}},\"system.memory.free\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.hugepages.default_size\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.hugepages.free\":{\"id\":\"number\",\"params\":{}},\"system.memory.hugepages.reserved\":{\"id\":\"number\",\"params\":{}},\"system.memory.hugepages.surplus\":{\"id\":\"number\",\"params\":{}},\"system.memory.hugepages.total\":{\"id\":\"number\",\"params\":{}},\"system.memory.hugepages.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.hugepages.used.pct\":{\"id\":\"percent\",\"params\":{}},\"system.memory.swap.free\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.swap.total\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.swap.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.swap.used.pct\":{\"id\":\"percent\",\"params\":{}},\"system.memory.total\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.used.pct\":{\"id\":\"percent\",\"params\":{}},\"system.network.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.network.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.blkio.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem.usage.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem_tcp.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem_tcp.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem_tcp.usage.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.mem.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.mem.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.mem.usage.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.memsw.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.memsw.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.memsw.usage.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.active_anon.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.active_file.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.cache.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.hierarchical_memory_limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.hierarchical_memsw_limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.inactive_anon.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.inactive_file.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.mapped_file.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.rss_huge.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.swap.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.unevictable.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cpu.total.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.process.cpu.total.pct\":{\"id\":\"percent\",\"params\":{}},\"system.process.memory.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.memory.rss.pct\":{\"id\":\"percent\",\"params\":{}},\"system.process.memory.share\":{\"id\":\"bytes\",\"params\":{}},\"system.process.memory.size\":{\"id\":\"bytes\",\"params\":{}},\"system.socket.summary.tcp.memory\":{\"id\":\"bytes\",\"params\":{}},\"system.socket.summary.udp.memory\":{\"id\":\"bytes\",\"params\":{}},\"system.uptime.duration.ms\":{\"id\":\"duration\",\"params\":{\"inputFormat\":\"milliseconds\"}},\"url.port\":{\"id\":\"string\",\"params\":{}},\"vsphere.datastore.capacity.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.datastore.capacity.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.datastore.capacity.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.datastore.capacity.used.pct\":{\"id\":\"percent\",\"params\":{}},\"vsphere.host.memory.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.host.memory.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.host.memory.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.virtualmachine.memory.free.guest.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.virtualmachine.memory.total.guest.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.virtualmachine.memory.used.guest.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.virtualmachine.memory.used.host.bytes\":{\"id\":\"bytes\",\"params\":{}},\"windows.service.uptime.ms\":{\"id\":\"duration\",\"params\":{\"inputFormat\":\"milliseconds\"}}}", - "timeFieldName": "@timestamp", - "title": "metricbeat-*" - }, - "migrationVersion": { - "index-pattern": "7.6.0" - }, - "type": "index-pattern", - "updated_at": "2020-01-22T15:34:59.061Z" - } - } -} - -{ - "type": "doc", - "value": { + "index": ".kibana", + "type": "doc", "id": "index-pattern:logstash-*", "index": ".kibana_1", "source": { @@ -297,4 +279,4 @@ "updated_at": "2019-07-17T17:54:26.378Z" } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 37d97cd014c9f1..aae161ef9fcf19 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -163,6 +163,73 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont await PageObjects.header.waitUntilLoadingHasFinished(); }, + /** + * Copies field to chosen destination that is defined by distance of `steps` + * (right arrow presses) from it + * + * @param fieldName - the desired field for the dimension + * @param steps - number of steps user has to press right + * @param reverse - defines the direction of going through drops + * */ + async dragFieldWithKeyboard(fieldName: string, steps = 1, reverse = false) { + const field = await find.byCssSelector( + `[data-test-subj="lnsDragDrop_draggable-${fieldName}"] [data-test-subj="lnsDragDrop-keyboardHandler"]` + ); + await field.focus(); + await browser.pressKeys(browser.keys.ENTER); + for (let i = 0; i < steps; i++) { + await browser.pressKeys(reverse ? browser.keys.LEFT : browser.keys.RIGHT); + } + await browser.pressKeys(browser.keys.ENTER); + + await PageObjects.header.waitUntilLoadingHasFinished(); + }, + + /** + * Selects draggable element and moves it by number of `steps` + * + * @param group - the group of the element + * @param index - the index of the element in the group + * @param steps - number of steps of presses right or left + * @param reverse - defines the direction of going through drops + * */ + async dimensionKeyboardDragDrop(group: string, index = 0, steps = 1, reverse = false) { + const elements = await find.allByCssSelector( + `[data-test-subj="${group}"] [data-test-subj="lnsDragDrop-keyboardHandler"]` + ); + const el = elements[index]; + await el.focus(); + await browser.pressKeys(browser.keys.ENTER); + for (let i = 0; i < steps; i++) { + await browser.pressKeys(reverse ? browser.keys.LEFT : browser.keys.RIGHT); + } + await browser.pressKeys(browser.keys.ENTER); + + await PageObjects.header.waitUntilLoadingHasFinished(); + }, + /** + * Selects draggable element and reorders it by number of `steps` + * + * @param group - the group of the element + * @param index - the index of the element in the group + * @param steps - number of steps of presses right or left + * @param reverse - defines the direction of going through drops + * */ + async dimensionKeyboardReorder(group: string, index = 0, steps = 1, reverse = false) { + const elements = await find.allByCssSelector( + `[data-test-subj="${group}"] [data-test-subj="lnsDragDrop-keyboardHandler"]` + ); + const el = elements[index]; + await el.focus(); + await browser.pressKeys(browser.keys.ENTER); + for (let i = 0; i < steps; i++) { + await browser.pressKeys(reverse ? browser.keys.ARROW_UP : browser.keys.ARROW_DOWN); + } + await browser.pressKeys(browser.keys.ENTER); + + await PageObjects.header.waitUntilLoadingHasFinished(); + }, + /** * Drags field to dimension trigger * @@ -194,16 +261,12 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont /** * Reorder elements within the group * - * @param startIndex - the index of dragging element - * @param endIndex - the index of drop + * @param startIndex - the index of dragging element starting from 1 + * @param endIndex - the index of drop starting from 1 * */ async reorderDimensions(dimension: string, startIndex: number, endIndex: number) { - const dragging = `[data-test-subj='${dimension}']:nth-of-type(${ - startIndex + 1 - }) .lnsDragDrop`; - const dropping = `[data-test-subj='${dimension}']:nth-of-type(${ - endIndex + 1 - }) [data-test-subj='lnsDragDrop-reorderableDropLayer'`; + const dragging = `[data-test-subj='${dimension}']:nth-of-type(${startIndex}) .lnsDragDrop`; + const dropping = `[data-test-subj='${dimension}']:nth-of-type(${endIndex}) [data-test-subj='lnsDragDrop-reorderableDropLayer'`; await browser.html5DragAndDrop(dragging, dropping); await PageObjects.header.waitUntilLoadingHasFinished(); }, @@ -350,6 +413,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont async switchToVisualization(subVisualizationId: string) { await this.openChartSwitchPopover(); await testSubjects.click(`lnsChartSwitchPopover_${subVisualizationId}`); + await PageObjects.header.waitUntilLoadingHasFinished(); }, async openChartSwitchPopover() { @@ -531,10 +595,13 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont }, async getDatatableCell(rowIndex = 0, colIndex = 0) { + const table = await find.byCssSelector('.euiDataGrid'); + const $ = await table.parseDomContent(); + const columnNumber = $('.euiDataGridHeaderCell__content').length; return await find.byCssSelector( - `[data-test-subj="lnsDataTable"] [data-test-subj="dataGridRow"]:nth-child(${ - rowIndex + 2 // this is a bit specific for EuiDataGrid: the first row is the Header - }) [data-test-subj="dataGridRowCell"]:nth-child(${colIndex + 1})` + `[data-test-subj="lnsDataTable"] [data-test-subj="dataGridRowCell"]:nth-child(${ + rowIndex * columnNumber + colIndex + 2 + })` ); }, @@ -562,6 +629,15 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont return buttonEl.click(); }, + async toggleColumnVisibility(dimension: string) { + await this.openDimensionEditor(dimension); + const id = 'lns-table-column-hidden'; + const isChecked = await testSubjects.isEuiSwitchChecked(id); + await testSubjects.setEuiSwitch(id, isChecked ? 'uncheck' : 'check'); + await this.closeDimensionEditor(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }, + async clickTableCellAction(rowIndex = 0, colIndex = 0, actionTestSub: string) { const el = await this.getDatatableCell(rowIndex, colIndex); await el.focus(); diff --git a/x-pack/test/functional/page_objects/search_sessions_management_page.ts b/x-pack/test/functional/page_objects/search_sessions_management_page.ts index a5ffa914eac22d..df4e99dd595d90 100644 --- a/x-pack/test/functional/page_objects/search_sessions_management_page.ts +++ b/x-pack/test/functional/page_objects/search_sessions_management_page.ts @@ -49,11 +49,11 @@ export function SearchSessionsPageProvider({ getService, getPageObjects }: FtrPr '[data-test-subj="sessionManagementPopoverAction-reload"]' ); }, - cancel: async () => { - log.debug('management ui: cancel the session'); + delete: async () => { + log.debug('management ui: delete the session'); await actionsCell.click(); await find.clickByCssSelector( - '[data-test-subj="sessionManagementPopoverAction-cancel"]' + '[data-test-subj="sessionManagementPopoverAction-delete"]' ); await PageObjects.common.clickConfirmOnModal(); }, diff --git a/x-pack/test/functional/page_objects/upgrade_assistant_page.ts b/x-pack/test/functional/page_objects/upgrade_assistant_page.ts index da1518ed72b489..1c4a85450a8da4 100644 --- a/x-pack/test/functional/page_objects/upgrade_assistant_page.ts +++ b/x-pack/test/functional/page_objects/upgrade_assistant_page.ts @@ -24,7 +24,7 @@ export function UpgradeAssistantPageProvider({ getPageObjects, getService }: Ftr return await retry.try(async () => { await common.navigateToApp('settings'); await testSubjects.click('upgrade_assistant'); - retry.waitFor('url to contain /upgrade_assistant', async () => { + await retry.waitFor('url to contain /upgrade_assistant', async () => { const url = await browser.getCurrentUrl(); return url.includes('/upgrade_assistant'); }); @@ -61,7 +61,7 @@ export function UpgradeAssistantPageProvider({ getPageObjects, getService }: Ftr async waitForTelemetryHidden() { const self = this; - retry.waitFor('Telemetry to disappear.', async () => { + await retry.waitFor('Telemetry to disappear.', async () => { return (await self.isTelemetryExists()) === false; }); } diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_results.ts b/x-pack/test/functional/services/ml/data_frame_analytics_results.ts index f1d9b08cc2438b..b6aba13054f75d 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_results.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_results.ts @@ -53,7 +53,9 @@ export function MachineLearningDataFrameAnalyticsResultsProvider({ }, async getResultTableRows() { - return await testSubjects.findAll('mlExplorationDataGrid loaded > dataGridRow'); + return (await testSubjects.find('mlExplorationDataGrid loaded')).findAllByTestSubject( + 'dataGridRowCell' + ); }, async assertResultsTableNotEmpty() { @@ -88,6 +90,7 @@ export function MachineLearningDataFrameAnalyticsResultsProvider({ this.assertResultsTableNotEmpty(); const featureImportanceCell = await this.getFirstFeatureImportanceCell(); + await featureImportanceCell.focus(); const interactionButton = await featureImportanceCell.findByTagName('button'); // simulate hover and wait for button to appear @@ -101,11 +104,9 @@ export function MachineLearningDataFrameAnalyticsResultsProvider({ async getFirstFeatureImportanceCell(): Promise { // get first row of the data grid - const firstDataGridRow = await testSubjects.find( - 'mlExplorationDataGrid loaded > dataGridRow' - ); + const dataGrid = await testSubjects.find('mlExplorationDataGrid loaded'); // find the feature importance cell in that row - const featureImportanceCell = await firstDataGridRow.findByCssSelector( + const featureImportanceCell = await dataGrid.findByCssSelector( '[data-test-subj="dataGridRowCell"][class*="featureImportance"]' ); return featureImportanceCell; diff --git a/x-pack/test/functional/services/ml/data_visualizer_index_based.ts b/x-pack/test/functional/services/ml/data_visualizer_index_based.ts index 373b1aa20a4bb3..d8ec8ed49f011b 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_index_based.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_index_based.ts @@ -10,9 +10,12 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export function MachineLearningDataVisualizerIndexBasedProvider({ getService, + getPageObjects, }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const retry = getService('retry'); + const PageObjects = getPageObjects(['discover']); + const queryBar = getService('queryBar'); return { async assertTimeRangeSelectorSectionExists() { @@ -149,5 +152,42 @@ export function MachineLearningDataVisualizerIndexBasedProvider({ async clickCreateAdvancedJobButton() { await testSubjects.clickWhenNotDisabled('mlDataVisualizerCreateAdvancedJobCard'); }, + + async assertViewInDiscoverCardExists() { + await testSubjects.existOrFail('mlDataVisualizerViewInDiscoverCard'); + }, + + async assertViewInDiscoverCardNotExists() { + await testSubjects.missingOrFail('mlDataVisualizerViewInDiscoverCard'); + }, + + async clickViewInDiscoverButton() { + await retry.tryForTime(5000, async () => { + await testSubjects.clickWhenNotDisabled('mlDataVisualizerViewInDiscoverCard'); + await PageObjects.discover.waitForDiscoverAppOnScreen(); + }); + }, + + async assertDiscoverPageQuery(expectedQueryString: string) { + await PageObjects.discover.waitForDiscoverAppOnScreen(); + await retry.tryForTime(5000, async () => { + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql( + expectedQueryString, + `Expected Discover global query bar to have query '${expectedQueryString}', got '${queryString}'` + ); + }); + }, + + async assertDiscoverHitCount(expectedHitCountFormatted: string) { + await PageObjects.discover.waitForDiscoverAppOnScreen(); + await retry.tryForTime(5000, async () => { + const hitCount = await PageObjects.discover.getHitCount(); + expect(hitCount).to.eql( + expectedHitCountFormatted, + `Expected Discover hit count to be '${expectedHitCountFormatted}' (got '${hitCount}')` + ); + }); + }, }; } diff --git a/x-pack/test/functional/services/ml/data_visualizer_table.ts b/x-pack/test/functional/services/ml/data_visualizer_table.ts index 36f5b94dc52dd5..3bd3b7e2e783a8 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_table.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_table.ts @@ -133,6 +133,17 @@ export function MachineLearningDataVisualizerTableProvider( ); } + public async assertViewInLensActionEnabled(fieldName: string) { + const actionButton = this.rowSelector(fieldName, 'mlActionButtonViewInLens'); + await testSubjects.existOrFail(actionButton); + await testSubjects.isEnabled(actionButton); + } + + public async assertViewInLensActionNotExists(fieldName: string) { + const actionButton = this.rowSelector(fieldName, 'mlActionButtonViewInLens'); + await testSubjects.missingOrFail(actionButton); + } + public async assertFieldDistinctValuesExist(fieldName: string) { const selector = this.rowSelector(fieldName, 'mlDataVisualizerTableColumnDistinctValues'); await testSubjects.existOrFail(selector); @@ -249,6 +260,7 @@ export function MachineLearningDataVisualizerTableProvider( fieldName: string, docCountFormatted: string, topValuesCount: number, + viewableInLens: boolean, checkDistributionPreviewExist = true ) { await this.assertRowExists(fieldName); @@ -263,6 +275,11 @@ export function MachineLearningDataVisualizerTableProvider( if (checkDistributionPreviewExist) { await this.assertDistributionPreviewExist(fieldName); } + if (viewableInLens) { + await this.assertViewInLensActionEnabled(fieldName); + } else { + await this.assertViewInLensActionNotExists(fieldName); + } await this.ensureDetailsClosed(fieldName); } @@ -307,6 +324,7 @@ export function MachineLearningDataVisualizerTableProvider( ) { await this.assertRowExists(fieldName); await this.assertFieldDocCount(fieldName, docCountFormatted); + await this.ensureDetailsOpen(fieldName); await this.assertExamplesList(fieldName, expectedExamplesCount); @@ -320,6 +338,7 @@ export function MachineLearningDataVisualizerTableProvider( ) { await this.assertRowExists(fieldName); await this.assertFieldDocCount(fieldName, docCountFormatted); + await this.ensureDetailsOpen(fieldName); await this.assertExamplesList(fieldName, expectedExamplesCount); @@ -332,6 +351,7 @@ export function MachineLearningDataVisualizerTableProvider( public async assertUnknownFieldContents(fieldName: string, docCountFormatted: string) { await this.assertRowExists(fieldName); await this.assertFieldDocCount(fieldName, docCountFormatted); + await this.ensureDetailsOpen(fieldName); await testSubjects.existOrFail(this.detailsSelector(fieldName, 'mlDVDocumentStatsContent')); @@ -343,7 +363,8 @@ export function MachineLearningDataVisualizerTableProvider( fieldType: string, fieldName: string, docCountFormatted: string, - exampleCount: number + exampleCount: number, + viewableInLens: boolean ) { // Currently the data used in the data visualizer tests only contains these field types. if (fieldType === ML_JOB_FIELD_TYPES.DATE) { @@ -357,6 +378,12 @@ export function MachineLearningDataVisualizerTableProvider( } else if (fieldType === ML_JOB_FIELD_TYPES.UNKNOWN) { await this.assertUnknownFieldContents(fieldName, docCountFormatted); } + + if (viewableInLens) { + await this.assertViewInLensActionEnabled(fieldName); + } else { + await this.assertViewInLensActionNotExists(fieldName); + } } public async ensureNumRowsPerPage(n: 10 | 25 | 50) { diff --git a/x-pack/test/functional/services/transform/wizard.ts b/x-pack/test/functional/services/transform/wizard.ts index 7223d210cfb15e..518accdeaf47eb 100644 --- a/x-pack/test/functional/services/transform/wizard.ts +++ b/x-pack/test/functional/services/transform/wizard.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { chunk } from 'lodash'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -88,18 +89,24 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { async parseEuiDataGrid(tableSubj: string) { const table = await testSubjects.find(`~${tableSubj}`); const $ = await table.parseDomContent(); - const rows = []; - - // For each row, get the content of each cell and - // add its values as an array to each row. - for (const tr of $.findTestSubjects(`~dataGridRow`).toArray()) { - rows.push( - $(tr) - .find('.euiDataGridRowCell__truncate') - .toArray() - .map((cell) => $(cell).text().trim()) + + // find columns to help determine number of rows + const columns = $('.euiDataGridHeaderCell__content') + .toArray() + .map((cell) => $(cell).text()); + + // Get the content of each cell and divide them up into rows + const cells = $.findTestSubjects('dataGridRowCell') + .find('.euiDataGridRowCell__truncate') + .toArray() + .map((cell) => + $(cell) + .text() + .trim() + .replace(/Row: \d+, Column: \d+:$/g, '') ); - } + + const rows = chunk(cells, columns.length); return rows; }, @@ -139,12 +146,14 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { `EuiDataGrid rows should be '${expectedNumberOfRows}' (got '${rowsData.length}')` ); - rowsData.map((r, i) => - expect(r).to.length( - columns, - `EuiDataGrid row #${i + 1} column count should be '${columns}' (got '${r.length}')` - ) - ); + // cell virtualization means the last column is cutoff in the functional tests + // https://github.com/elastic/eui/issues/4470 + // rowsData.map((r, i) => + // expect(r).to.length( + // columns, + // `EuiDataGrid row #${i + 1} column count should be '${columns}' (got '${r.length}')` + // ) + // ); }); }, diff --git a/x-pack/test/functional_basic/apps/ml/data_visualizer/index.ts b/x-pack/test/functional_basic/apps/ml/data_visualizer/index.ts index 007b8be272f5db..57a44a0b7952da 100644 --- a/x-pack/test/functional_basic/apps/ml/data_visualizer/index.ts +++ b/x-pack/test/functional_basic/apps/ml/data_visualizer/index.ts @@ -15,9 +15,10 @@ export default function ({ loadTestFile }: FtrProviderContext) { ); // The data visualizer should work the same as with a trial license, except the missing create actions - // That's why 'index_data_visualizer_actions_panel' is not loaded here + // That's why the 'basic' version of 'index_data_visualizer_actions_panel' is loaded here loadTestFile( require.resolve('../../../../functional/apps/ml/data_visualizer/index_data_visualizer') ); + loadTestFile(require.resolve('./index_data_visualizer_actions_panel')); }); } diff --git a/x-pack/test/functional_basic/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts b/x-pack/test/functional_basic/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts new file mode 100644 index 00000000000000..8a59d6ed3ce2a1 --- /dev/null +++ b/x-pack/test/functional_basic/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + + describe('index based actions panel on basic license', function () { + this.tags(['mlqa']); + + const indexPatternName = 'ft_farequote'; + const savedSearch = 'ft_farequote_kuery'; + const expectedQuery = 'airline: A* and responsetime > 5'; + + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await ml.testResources.createIndexPatternIfNeeded(indexPatternName, '@timestamp'); + await ml.testResources.createSavedSearchFarequoteKueryIfNeeded(); + await ml.testResources.setKibanaTimeZoneToUTC(); + + await ml.securityUI.loginAsMlPowerUser(); + }); + + describe('view in discover page action', function () { + it('loads the source data in the data visualizer', async () => { + await ml.testExecution.logTestStep('loads the data visualizer selector page'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToDataVisualizer(); + + await ml.testExecution.logTestStep('loads the saved search selection page'); + await ml.dataVisualizer.navigateToIndexPatternSelection(); + + await ml.testExecution.logTestStep('loads the index data visualizer page'); + await ml.jobSourceSelection.selectSourceForIndexBasedDataVisualizer(savedSearch); + }); + + it('navigates to Discover page', async () => { + await ml.testExecution.logTestStep('should not display create job card'); + await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); + + await ml.testExecution.logTestStep('displays the actions panel with view in Discover card'); + await ml.dataVisualizerIndexBased.assertActionsPanelExists(); + await ml.dataVisualizerIndexBased.assertViewInDiscoverCardExists(); + + await ml.testExecution.logTestStep('retains the query in Discover page'); + await ml.dataVisualizerIndexBased.clickViewInDiscoverButton(); + await ml.dataVisualizerIndexBased.assertDiscoverPageQuery(expectedQuery); + }); + }); + }); +} diff --git a/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts b/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts index 36cc1b1771e8b1..b09270b1d0f780 100644 --- a/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts +++ b/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts @@ -127,8 +127,11 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('should display the data visualizer table'); await ml.dataVisualizerIndexBased.assertDataVisualizerTableExist(); - await ml.testExecution.logTestStep('should not display the actions panel with cards'); - await ml.dataVisualizerIndexBased.assertActionsPanelNotExists(); + await ml.testExecution.logTestStep('should display the actions panel with Discover card'); + await ml.dataVisualizerIndexBased.assertActionsPanelExists(); + await ml.dataVisualizerIndexBased.assertViewInDiscoverCardExists(); + + await ml.testExecution.logTestStep('should not display job cards'); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); await ml.dataVisualizerIndexBased.assertRecognizerCardNotExists(ecExpectedModuleId); }); diff --git a/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts index f302be40a0e98f..14cc4e93b37ab7 100644 --- a/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts +++ b/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts @@ -127,8 +127,11 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('should display the data visualizer table'); await ml.dataVisualizerIndexBased.assertDataVisualizerTableExist(); - await ml.testExecution.logTestStep('should not display the actions panel with cards'); - await ml.dataVisualizerIndexBased.assertActionsPanelNotExists(); + await ml.testExecution.logTestStep('should display the actions panel with Discover card'); + await ml.dataVisualizerIndexBased.assertActionsPanelExists(); + await ml.dataVisualizerIndexBased.assertViewInDiscoverCardExists(); + + await ml.testExecution.logTestStep('should not display job cards'); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); await ml.dataVisualizerIndexBased.assertRecognizerCardNotExists(ecExpectedModuleId); }); diff --git a/x-pack/test/reporting_api_integration/reporting_without_security.config.ts b/x-pack/test/reporting_api_integration/reporting_without_security.config.ts index 5946a502a4ce37..59d6074d9d8caf 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security.config.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security.config.ts @@ -33,7 +33,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { kbnTestServer: { ...apiConfig.get('kbnTestServer'), serverArgs: [ - `--migrations.enableV2=false`, `--elasticsearch.hosts=${formatUrl(esTestConfig.getUrlParts())}`, `--logging.json=false`, `--server.maxPayloadBytes=1679958`, diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/delete.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/delete.ts index 415cdf4814176c..ed4bc8f4f8c7b7 100644 --- a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/delete.ts +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/delete.ts @@ -13,7 +13,8 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); - describe('DELETE /api/saved_objects_tagging/tags/{id}', () => { + // FLAKY: https://github.com/elastic/kibana/issues/90552 + describe.skip('DELETE /api/saved_objects_tagging/tags/{id}', () => { beforeEach(async () => { await esArchiver.load('delete_with_references'); }); diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management.ts b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management.ts index 7e09c6b0fe05cb..f925cfb78a8c6e 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management.ts @@ -88,15 +88,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await searchSessions.expectState('completed'); }); - it('Cancels a session from management', async () => { + it('Deletes a session from management', async () => { await PageObjects.searchSessionsManagement.goTo(); const searchSessionList = await PageObjects.searchSessionsManagement.getList(); expect(searchSessionList.length).to.be(1); - await searchSessionList[0].cancel(); + await searchSessionList[0].delete(); - // TODO: update this once canceling doesn't delete the object! await retry.waitFor(`wait for list to be empty`, async function () { const s = await PageObjects.searchSessionsManagement.getList(); diff --git a/yarn.lock b/yarn.lock index 9be907922c2a65..fa7ebacb1cd708 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2204,10 +2204,10 @@ resolved "https://registry.yarnpkg.com/@elastic/eslint-plugin-eui/-/eslint-plugin-eui-0.0.2.tgz#56b9ef03984a05cc213772ae3713ea8ef47b0314" integrity sha512-IoxURM5zraoQ7C8f+mJb9HYSENiZGgRVcG4tLQxE61yHNNRDXtGDWTZh8N1KIHcsqN1CEPETjuzBXkJYF/fDiQ== -"@elastic/eui@31.3.0": - version "31.3.0" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-31.3.0.tgz#f39eecc09d588e4b22150faceb67e5e169afbbd8" - integrity sha512-1Sjhf5HVakx7VGWQkKP8wzGUf7HzyoNnAxjg5P3NH8k+ctJFagS1Wlz9zogwClEuj3FMTMC4tzbJyo06OgHECw== +"@elastic/eui@31.4.0": + version "31.4.0" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-31.4.0.tgz#d2c8cc91fc538f7b1c5e5229663e186fa0c9207c" + integrity sha512-ADdUeNxj2uiN13U7AkF0ishLAN0xcqFWHC+xjEmx8Wedyaj5DFrmmJEuH9aXv+XSQG5l8ppMgZQb3pMDjR2mKw== dependencies: "@types/chroma-js" "^2.0.0" "@types/lodash" "^4.14.160" @@ -5968,6 +5968,11 @@ resolved "https://registry.yarnpkg.com/@types/json-stable-stringify/-/json-stable-stringify-1.0.32.tgz#121f6917c4389db3923640b2e68de5fa64dda88e" integrity sha512-q9Q6+eUEGwQkv4Sbst3J4PNgDOvpuVuKj79Hl/qnmBMEIPzB5QoFRUtjcgcg2xNUZyYUGXBk5wYIBKHt0A+Mxw== +"@types/json5@^0.0.29": + version "0.0.29" + resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" + integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= + "@types/json5@^0.0.30": version "0.0.30" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.30.tgz#44cb52f32a809734ca562e685c6473b5754a7818" @@ -13614,6 +13619,14 @@ eslint-import-resolver-node@0.3.2, eslint-import-resolver-node@^0.3.2: debug "^2.6.9" resolve "^1.5.0" +eslint-import-resolver-node@^0.3.4: + version "0.3.4" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz#85ffa81942c25012d8231096ddf679c03042c717" + integrity sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA== + dependencies: + debug "^2.6.9" + resolve "^1.13.1" + eslint-import-resolver-webpack@0.11.1: version "0.11.1" resolved "https://registry.yarnpkg.com/eslint-import-resolver-webpack/-/eslint-import-resolver-webpack-0.11.1.tgz#fcf1fd57a775f51e18f442915f85dd6ba45d2f26" @@ -13638,6 +13651,14 @@ eslint-module-utils@2.5.0, eslint-module-utils@^2.4.1: debug "^2.6.9" pkg-dir "^2.0.0" +eslint-module-utils@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.6.0.tgz#579ebd094f56af7797d19c9866c9c9486629bfa6" + integrity sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA== + dependencies: + debug "^2.6.9" + pkg-dir "^2.0.0" + eslint-plugin-babel@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/eslint-plugin-babel/-/eslint-plugin-babel-5.3.1.tgz#75a2413ffbf17e7be57458301c60291f2cfbf560" @@ -13693,6 +13714,25 @@ eslint-plugin-import@^2.19.1: read-pkg-up "^2.0.0" resolve "^1.12.0" +eslint-plugin-import@^2.22.1: + version "2.22.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz#0896c7e6a0cf44109a2d97b95903c2bb689d7702" + integrity sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw== + dependencies: + array-includes "^3.1.1" + array.prototype.flat "^1.2.3" + contains-path "^0.1.0" + debug "^2.6.9" + doctrine "1.5.0" + eslint-import-resolver-node "^0.3.4" + eslint-module-utils "^2.6.0" + has "^1.0.3" + minimatch "^3.0.4" + object.values "^1.1.1" + read-pkg-up "^2.0.0" + resolve "^1.17.0" + tsconfig-paths "^3.9.0" + eslint-plugin-jest@^24.0.2: version "24.0.2" resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-24.0.2.tgz#4bf0fcdc86289d702a7dacb430b4363482af773b" @@ -16960,11 +17000,16 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= -immer@1.10.0, immer@^1.5.0: +immer@1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg== +immer@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656" + integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA== + import-cwd@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" @@ -25534,7 +25579,7 @@ resolve@1.8.1: dependencies: path-parse "^1.0.5" -resolve@^1.1.10, resolve@^1.1.4, resolve@^1.1.5, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.12.0, resolve@^1.17.0, resolve@^1.18.1, resolve@^1.3.2, resolve@^1.3.3, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.7.1, resolve@^1.8.1: +resolve@^1.1.10, resolve@^1.1.4, resolve@^1.1.5, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.17.0, resolve@^1.18.1, resolve@^1.3.2, resolve@^1.3.3, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.7.1, resolve@^1.8.1: version "1.19.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.19.0.tgz#1af5bf630409734a067cae29318aac7fa29a267c" integrity sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg== @@ -28427,6 +28472,16 @@ ts-pnp@^1.1.6: resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92" integrity sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw== +tsconfig-paths@^3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz#098547a6c4448807e8fcb8eae081064ee9a3c90b" + integrity sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw== + dependencies: + "@types/json5" "^0.0.29" + json5 "^1.0.1" + minimist "^1.2.0" + strip-bom "^3.0.0" + tsd@^0.13.1: version "0.13.1" resolved "https://registry.yarnpkg.com/tsd/-/tsd-0.13.1.tgz#d2a8baa80b8319dafea37fbeb29fef3cec86e92b"